Merge remote-tracking branch 'origin/feat/agent_work_orders' into ui/agent-work-order

This commit is contained in:
sean-eskerium
2025-10-25 14:32:33 -04:00
136 changed files with 17959 additions and 145 deletions

View File

@@ -0,0 +1,55 @@
# Create Git Commit
Create an atomic git commit with a properly formatted commit message following best practices for the uncommited changes or these specific files if specified.
Specific files (skip if not specified):
- File 1: $1
- File 2: $2
- File 3: $3
- File 4: $4
- File 5: $5
## Instructions
**Commit Message Format:**
- Use conventional commits: `<type>: <description>`
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Present tense (e.g., "add", "fix", "update", not "added", "fixed", "updated")
- 50 characters or less for the subject line
- Lowercase subject line
- No period at the end
- Be specific and descriptive
**Examples:**
- `feat: add web search tool with structured logging`
- `fix: resolve type errors in middleware`
- `test: add unit tests for config module`
- `docs: update CLAUDE.md with testing guidelines`
- `refactor: simplify logging configuration`
- `chore: update dependencies`
**Atomic Commits:**
- One logical change per commit
- If you've made multiple unrelated changes, consider splitting into separate commits
- Commit should be self-contained and not break the build
**IMPORTANT**
- NEVER mention claude code, anthropic, co authored by or anything similar in the commit messages
## Run
1. Review changes: `git diff HEAD`
2. Check status: `git status`
3. Stage changes: `git add -A`
4. Create commit: `git commit -m "<type>: <description>"`
## Report
- Output the commit message used
- Confirm commit was successful with commit hash
- List files that were committed

View File

@@ -0,0 +1,27 @@
# Execute PRP Plan
Implement a feature plan from the PRPs directory by following its Step by Step Tasks section.
## Variables
Plan file: $ARGUMENTS
## Instructions
- Read the entire plan file carefully
- Execute **every step** in the "Step by Step Tasks" section in order, top to bottom
- Follow the "Testing Strategy" to create proper unit and integration tests
- Complete all "Validation Commands" at the end
- Ensure all linters pass and all tests pass before finishing
- Follow CLAUDE.md guidelines for type safety, logging, and docstrings
## When done
- Move the PRP file to the completed directory in PRPs/features/completed
## Report
- Summarize completed work in a concise bullet point list
- Show files and lines changed: `git diff --stat`
- Confirm all validation commands passed
- Note any deviations from the plan (if any)

View File

@@ -0,0 +1,176 @@
# NOQA Analysis and Resolution
Find all noqa/type:ignore comments in the codebase, investigate why they exist, and provide recommendations for resolution or justification.
## Instructions
**Step 1: Find all NOQA comments**
- Use Grep tool to find all noqa comments: pattern `noqa|type:\s*ignore`
- Use output_mode "content" with line numbers (-n flag)
- Search across all Python files (type: "py")
- Document total count of noqa comments found
**Step 2: For EACH noqa comment (repeat this process):**
- Read the file containing the noqa comment with sufficient context (at least 10 lines before and after)
- Identify the specific linting rule or type error being suppressed
- Understand the code's purpose and why the suppression was added
- Investigate if the suppression is still necessary or can be resolved
**Step 3: Investigation checklist for each noqa:**
- What specific error/warning is being suppressed? (e.g., `type: ignore[arg-type]`, `noqa: F401`)
- Why was the suppression necessary? (legacy code, false positive, legitimate limitation, technical debt)
- Can the underlying issue be fixed? (refactor code, update types, improve imports)
- What would it take to remove the suppression? (effort estimate, breaking changes, architectural changes)
- Is the suppression justified long-term? (external library limitation, Python limitation, intentional design)
**Step 4: Research solutions:**
- Check if newer versions of tools (mypy, ruff) handle the case better
- Look for alternative code patterns that avoid the suppression
- Consider if type stubs or Protocol definitions could help
- Evaluate if refactoring would be worthwhile
## Report Format
Create a markdown report file (create the reports directory if not created yet): `PRPs/reports/noqa-analysis-{YYYY-MM-DD}.md`
Use this structure for the report:
````markdown
# NOQA Analysis Report
**Generated:** {date}
**Total NOQA comments found:** {count}
---
## Summary
- Total suppressions: {count}
- Can be removed: {count}
- Should remain: {count}
- Requires investigation: {count}
---
## Detailed Analysis
### 1. {File path}:{line number}
**Location:** `{file_path}:{line_number}`
**Suppression:** `{noqa comment or type: ignore}`
**Code context:**
```python
{relevant code snippet}
```
````
**Why it exists:**
{explanation of why the suppression was added}
**Options to resolve:**
1. {Option 1: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
2. {Option 2: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
**Tradeoffs:**
- {Tradeoff 1}
- {Tradeoff 2}
**Recommendation:** {Remove | Keep | Refactor}
{Justification for recommendation}
---
{Repeat for each noqa comment}
````
## Example Analysis Entry
```markdown
### 1. src/shared/config.py:45
**Location:** `src/shared/config.py:45`
**Suppression:** `# type: ignore[assignment]`
**Code context:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
return key # type: ignore[assignment]
````
**Why it exists:**
MyPy cannot infer that the ValueError prevents None from being returned, so it thinks the return type could be `str | None`.
**Options to resolve:**
1. Use assert to help mypy narrow the type
- Effort: Low
- Breaking: No
- Impact: Cleaner code, removes suppression
2. Add explicit cast with typing.cast()
- Effort: Low
- Breaking: No
- Impact: More verbose but type-safe
3. Refactor to use separate validation method
- Effort: Medium
- Breaking: No
- Impact: Better separation of concerns
**Tradeoffs:**
- Option 1 (assert) is cleanest but asserts can be disabled with -O flag
- Option 2 (cast) is most explicit but adds import and verbosity
- Option 3 is most robust but requires more refactoring
**Recommendation:** Remove (use Option 1)
Replace the type:ignore with an assert statement after the if check. This helps mypy understand the control flow while maintaining runtime safety. The assert will never fail in practice since the ValueError is raised first.
**Implementation:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
assert key is not None # Help mypy understand control flow
return key
```
```
## Report
After completing the analysis:
- Output the path to the generated report file
- Summarize findings:
- Total suppressions found
- How many can be removed immediately (low effort)
- How many should remain (justified)
- How many need deeper investigation or refactoring
- Highlight any quick wins (suppressions that can be removed with minimal effort)
```

View File

@@ -0,0 +1,176 @@
# Feature Planning
Create a new plan to implement the `PRP` using the exact specified markdown `PRP Format`. Follow the `Instructions` to create the plan use the `Relevant Files` to focus on the right files.
## Variables
FEATURE $1 $2
## Instructions
- IMPORTANT: You're writing a plan to implement a net new feature based on the `Feature` that will add value to the application.
- IMPORTANT: The `Feature` describes the feature that will be implemented but remember we're not implementing a new feature, we're creating the plan that will be used to implement the feature based on the `PRP Format` below.
- Create the plan in the `PRPs/features/` directory with filename: `{descriptive-name}.md`
- Replace `{descriptive-name}` with a short, descriptive name based on the feature (e.g., "add-auth-system", "implement-search", "create-dashboard")
- Use the `PRP Format` below to create the plan.
- Deeply research the codebase to understand existing patterns, architecture, and conventions before planning the feature.
- If no patterns are established or are unclear ask the user for clarifications while providing best recommendations and options
- IMPORTANT: Replace every <placeholder> in the `PRP Format` with the requested value. Add as much detail as needed to implement the feature successfully.
- Use your reasoning model: THINK HARD about the feature requirements, design, and implementation approach.
- Follow existing patterns and conventions in the codebase. Don't reinvent the wheel.
- Design for extensibility and maintainability.
- Deeply do web research to understand the latest trends and technologies in the field.
- Figure out latest best practices and library documentation.
- Include links to relevant resources and documentation with anchor tags for easy navigation.
- If you need a new library, use `uv add <package>` and report it in the `Notes` section.
- Read `CLAUDE.md` for project principles, logging rules, testing requirements, and docstring style.
- All code MUST have type annotations (strict mypy enforcement).
- Use Google-style docstrings for all functions, classes, and modules.
- Every new file in `src/` MUST have a corresponding test file in `tests/`.
- Respect requested files in the `Relevant Files` section.
## Relevant Files
Focus on the following files and vertical slice structure:
**Core Files:**
- `CLAUDE.md` - Project instructions, logging rules, testing requirements, docstring style
app/backend core files
app/frontend core files
## PRP Format
```md
# Feature: <feature name>
## Feature Description
<describe the feature in detail, including its purpose and value to users>
## User Story
As a <type of user>
I want to <action/goal>
So that <benefit/value>
## Problem Statement
<clearly define the specific problem or opportunity this feature addresses>
## Solution Statement
<describe the proposed solution approach and how it solves the problem>
## Relevant Files
Use these files to implement the feature:
<find and list the files that are relevant to the feature describe why they are relevant in bullet points. If there are new files that need to be created to implement the feature, list them in an h3 'New Files' section. inlcude line numbers for the relevant sections>
## Relevant research docstring
Use these documentation files and links to help with understanding the technology to use:
- [Documentation Link 1](https://example.com/doc1)
- [Anchor tag]
- [Short summary]
- [Documentation Link 2](https://example.com/doc2)
- [Anchor tag]
- [Short summary]
## Implementation Plan
### Phase 1: Foundation
<describe the foundational work needed before implementing the main feature>
### Phase 2: Core Implementation
<describe the main implementation work for the feature>
### Phase 3: Integration
<describe how the feature will integrate with existing functionality>
## Step by Step Tasks
IMPORTANT: Execute every step in order, top to bottom.
<list step by step tasks as h3 headers plus bullet points. use as many h3 headers as needed to implement the feature. Order matters:
1. Start with foundational shared changes (schemas, types)
2. Implement core functionality with proper logging
3. Create corresponding test files (unit tests mirror src/ structure)
4. Add integration tests if feature interacts with multiple components
5. Verify linters pass: `uv run ruff check src/ && uv run mypy src/`
6. Ensure all tests pass: `uv run pytest tests/`
7. Your last step should be running the `Validation Commands`>
<For tool implementations:
- Define Pydantic schemas in `schemas.py`
- Implement tool with structured logging and type hints
- Register tool with Pydantic AI agent
- Create unit tests in `tests/tools/<name>/test_<module>.py`
- Add integration test in `tests/integration/` if needed>
## Testing Strategy
See `CLAUDE.md` for complete testing requirements. Every file in `src/` must have a corresponding test file in `tests/`.
### Unit Tests
<describe unit tests needed for the feature. Mark with @pytest.mark.unit. Test individual components in isolation.>
### Integration Tests
<if the feature interacts with multiple components, describe integration tests needed. Mark with @pytest.mark.integration. Place in tests/integration/ when testing full application stack.>
### Edge Cases
<list edge cases that need to be tested>
## Acceptance Criteria
<list specific, measurable criteria that must be met for the feature to be considered complete>
## Validation Commands
Execute every command to validate the feature works correctly with zero regressions.
<list commands you'll use to validate with 100% confidence the feature is implemented correctly with zero regressions. Include (example for BE Biome and TS checks are used for FE):
- Linting: `uv run ruff check src/`
- Type checking: `uv run mypy src/`
- Unit tests: `uv run pytest tests/ -m unit -v`
- Integration tests: `uv run pytest tests/ -m integration -v` (if applicable)
- Full test suite: `uv run pytest tests/ -v`
- Manual API testing if needed (curl commands, test requests)>
**Required validation commands:**
- `uv run ruff check src/` - Lint check must pass
- `uv run mypy src/` - Type check must pass
- `uv run pytest tests/ -v` - All tests must pass with zero regressions
**Run server and test core endpoints:**
- Start server: @.claude/start-server
- Test endpoints with curl (at minimum: health check, main functionality)
- Verify structured logs show proper correlation IDs and context
- Stop server after validation
## Notes
<optionally list any additional notes, future considerations, or context that are relevant to the feature that will be helpful to the developer>
```
## Feature
Extract the feature details from the `issue_json` variable (parse the JSON and use the title and body fields).
## Report
- Summarize the work you've just done in a concise bullet point list.
- Include the full path to the plan file you created (e.g., `PRPs/features/add-auth-system.md`)

View File

@@ -0,0 +1,28 @@
# Prime
Execute the following sections to understand the codebase before starting new work, then summarize your understanding.
## Run
- List all tracked files: `git ls-files`
- Show project structure: `tree -I '.venv|__pycache__|*.pyc|.pytest_cache|.mypy_cache|.ruff_cache' -L 3`
## Read
- `CLAUDE.md` - Core project instructions, principles, logging rules, testing requirements
- `python/src/agent_work_orders` - Project overview and setup (if exists)
- Identify core files in the agent work orders directory to understand what we are woerking on and its intent
## Report
Provide a concise summary of:
1. **Project Purpose**: What this application does
2. **Architecture**: Key patterns (vertical slice, FastAPI + Pydantic AI)
3. **Core Principles**: TYPE SAFETY, KISS, YAGNI
4. **Tech Stack**: Main dependencies and tools
5. **Key Requirements**: Logging, testing, type annotations
6. **Current State**: What's implemented
Keep the summary brief (5-10 bullet points) and focused on what you need to know to contribute effectively.

View File

@@ -0,0 +1,89 @@
# Code Review
Review implemented work against a PRP specification to ensure code quality, correctness, and adherence to project standards.
## Variables
Plan file: $ARGUMENTS (e.g., `PRPs/features/add-web-search.md`)
## Instructions
**Understand the Changes:**
- Check current branch: `git branch`
- Review changes: `git diff origin/main` (or `git diff HEAD` if not on a branch)
- Read the PRP plan file to understand requirements
**Code Quality Review:**
- **Type Safety**: Verify all functions have type annotations, mypy passes
- **Logging**: Check structured logging is used correctly (event names, context, exception handling)
- **Docstrings**: Ensure Google-style docstrings on all functions/classes
- **Testing**: Verify unit tests exist for all new files, integration tests if needed
- **Architecture**: Confirm vertical slice structure is followed
- **CLAUDE.md Compliance**: Check adherence to core principles (KISS, YAGNI, TYPE SAFETY)
**Validation Ruff for BE and Biome for FE:**
- Run linters: `uv run ruff check src/ && uv run mypy src/`
- Run tests: `uv run pytest tests/ -v`
- Start server and test endpoints with curl (if applicable)
- Verify structured logs show proper correlation IDs and context
**Issue Severity:**
- `blocker` - Must fix before merge (breaks build, missing tests, type errors, security issues)
- `major` - Should fix (missing logging, incomplete docstrings, poor patterns)
- `minor` - Nice to have (style improvements, optimization opportunities)
## Report
Return ONLY valid JSON (no markdown, no explanations) save to [report-#.json] in prps/reports directory create the directory if it doesn't exist. Output will be parsed with JSON.parse().
### Output Structure
```json
{
"success": "boolean - true if NO BLOCKER issues, false if BLOCKER issues exist",
"review_summary": "string - 2-4 sentences: what was built, does it match spec, quality assessment",
"review_issues": [
{
"issue_number": "number - issue index",
"file_path": "string - file with the issue (if applicable)",
"issue_description": "string - what's wrong",
"issue_resolution": "string - how to fix it",
"severity": "string - blocker|major|minor"
}
],
"validation_results": {
"linting_passed": "boolean",
"type_checking_passed": "boolean",
"tests_passed": "boolean",
"api_endpoints_tested": "boolean - true if endpoints were tested with curl"
}
}
```
## Example Success Review
```json
{
"success": true,
"review_summary": "The web search tool has been implemented with proper type annotations, structured logging, and comprehensive tests. The implementation follows the vertical slice architecture and matches all spec requirements. Code quality is high with proper error handling and documentation.",
"review_issues": [
{
"issue_number": 1,
"file_path": "src/tools/web_search/tool.py",
"issue_description": "Missing debug log for API response",
"issue_resolution": "Add logger.debug with response metadata",
"severity": "minor"
}
],
"validation_results": {
"linting_passed": true,
"type_checking_passed": true,
"tests_passed": true,
"api_endpoints_tested": true
}
}
```

View File

@@ -0,0 +1,33 @@
# Start Servers
Start both the FastAPI backend and React frontend development servers with hot reload.
## Run
### Run in the background with bash tool
- Ensure you are in the right PWD
- Use the Bash tool to run the servers in the background so you can read the shell outputs
- IMPORTANT: run `git ls-files` first so you know where directories are located before you start
### Backend Server (FastAPI)
- Navigate to backend: `cd app/backend`
- Start server in background: `uv sync && uv run python run_api.py`
- Wait 2-3 seconds for startup
- Test health endpoint: `curl http://localhost:8000/health`
- Test products endpoint: `curl http://localhost:8000/api/products`
### Frontend Server (Bun + React)
- Navigate to frontend: `cd ../app/frontend`
- Start server in background: `bun install && bun dev`
- Wait 2-3 seconds for startup
- Frontend should be accessible at `http://localhost:3000`
## Report
- Confirm backend is running on `http://localhost:8000`
- Confirm frontend is running on `http://localhost:3000`
- Show the health check response from backend
- Mention: "Backend logs will show structured JSON logging for all requests"

View File

@@ -27,15 +27,56 @@ SUPABASE_SERVICE_KEY=
LOGFIRE_TOKEN=
LOG_LEVEL=INFO
# Claude API Key (Required for Agent Work Orders)
# Get your API key from: https://console.anthropic.com/
# Required for the agent work orders service to execute Claude CLI commands
ANTHROPIC_API_KEY=
# GitHub Personal Access Token (Required for Agent Work Orders PR creation)
# Get your token from: https://github.com/settings/tokens
# Required scopes: repo, workflow
# The agent work orders service uses this for gh CLI authentication to create PRs
GITHUB_PAT_TOKEN=
# Service Ports Configuration
# These ports are used for external access to the services
HOST=localhost
ARCHON_SERVER_PORT=8181
ARCHON_MCP_PORT=8051
ARCHON_AGENTS_PORT=8052
# Agent Work Orders Port (Optional - only needed if feature is enabled)
# Leave unset or comment out if you don't plan to use agent work orders
AGENT_WORK_ORDERS_PORT=8053
ARCHON_UI_PORT=3737
ARCHON_DOCS_PORT=3838
# Agent Work Orders Feature (Optional)
# Enable the agent work orders microservice for automated task execution
# Default: false (feature disabled)
# Set to "true" to enable: ENABLE_AGENT_WORK_ORDERS=true
# When enabled, requires Claude API key and GitHub PAT (see above)
ENABLE_AGENT_WORK_ORDERS=false
# Agent Work Orders Service Configuration (Optional)
# Only needed if ENABLE_AGENT_WORK_ORDERS=true
# Set these if running agent work orders service independently
# SERVICE_DISCOVERY_MODE: Controls how services find each other
# - "local": Services run on localhost with different ports
# - "docker_compose": Services use Docker container names
SERVICE_DISCOVERY_MODE=local
# Service URLs (for agent work orders service to call other services)
# These are automatically configured based on SERVICE_DISCOVERY_MODE
# Only override if you need custom service URLs
# ARCHON_SERVER_URL=http://localhost:8181
# ARCHON_MCP_URL=http://localhost:8051
# Agent Work Orders Persistence
# STATE_STORAGE_TYPE: "memory" (default, ephemeral) or "file" (persistent)
# FILE_STATE_DIRECTORY: Directory for file-based state storage
STATE_STORAGE_TYPE=file
FILE_STATE_DIRECTORY=agent-work-orders-state
# Frontend Configuration
# VITE_ALLOWED_HOSTS: Comma-separated list of additional hosts allowed for Vite dev server
# Example: VITE_ALLOWED_HOSTS=192.168.1.100,myhost.local,example.com

12
.gitignore vendored
View File

@@ -5,6 +5,9 @@ __pycache__
PRPs/local
PRPs/completed/
PRPs/stories/
PRPs/examples
PRPs/features
PRPs/specs
PRPs/reviews/
/logs/
.zed
@@ -12,6 +15,15 @@ tmp/
temp/
UAT/
# Temporary validation/report markdown files
/*_RESULTS.md
/*_SUMMARY.md
/*_REPORT.md
/*_SUCCESS.md
/*_COMPLETION*.md
/ACTUAL_*.md
/VALIDATION_*.md
.DS_Store
# Local release notes testing

View File

@@ -104,12 +104,19 @@ uv run ruff check # Run linter
uv run ruff check --fix # Auto-fix linting issues
uv run mypy src/ # Type check
# Agent Work Orders Service (independent microservice)
make agent-work-orders # Run agent work orders service locally on 8053
# Or manually:
uv run python -m uvicorn src.agent_work_orders.server:app --port 8053 --reload
# Docker operations
docker compose up --build -d # Start all services
docker compose --profile backend up -d # Backend only (for hybrid dev)
docker compose logs -f archon-server # View server logs
docker compose logs -f archon-mcp # View MCP server logs
docker compose restart archon-server # Restart after code changes
docker compose --profile work-orders up -d # Include agent work orders service
docker compose logs -f archon-server # View server logs
docker compose logs -f archon-mcp # View MCP server logs
docker compose logs -f archon-agent-work-orders # View agent work orders service logs
docker compose restart archon-server # Restart after code changes
docker compose down # Stop all services
docker compose down -v # Stop and remove volumes
```
@@ -120,8 +127,19 @@ docker compose down -v # Stop and remove volumes
# Hybrid development (recommended) - backend in Docker, frontend local
make dev # Or manually: docker compose --profile backend up -d && cd archon-ui-main && npm run dev
# Hybrid with Agent Work Orders Service - backend in Docker, agent work orders local
make dev-work-orders # Starts backend in Docker, prompts to run agent service in separate terminal
# Then in separate terminal:
make agent-work-orders # Start agent work orders service locally
# Full Docker mode
make dev-docker # Or: docker compose up --build -d
docker compose --profile work-orders up -d # Include agent work orders service
# All Local (3 terminals) - for agent work orders service development
# Terminal 1: uv run python -m uvicorn src.server.main:app --port 8181 --reload
# Terminal 2: make agent-work-orders
# Terminal 3: cd archon-ui-main && npm run dev
# Run linters before committing
make lint # Runs both frontend and backend linters

View File

@@ -5,23 +5,27 @@ SHELL := /bin/bash
# Docker compose command - prefer newer 'docker compose' plugin over standalone 'docker-compose'
COMPOSE ?= $(shell docker compose version >/dev/null 2>&1 && echo "docker compose" || echo "docker-compose")
.PHONY: help dev dev-docker stop test test-fe test-be lint lint-fe lint-be clean install check
.PHONY: help dev dev-docker dev-docker-full dev-work-orders dev-hybrid-work-orders stop test test-fe test-be lint lint-fe lint-be clean install check agent-work-orders
help:
@echo "Archon Development Commands"
@echo "==========================="
@echo " make dev - Backend in Docker, frontend local (recommended)"
@echo " make dev-docker - Everything in Docker"
@echo " make stop - Stop all services"
@echo " make test - Run all tests"
@echo " make test-fe - Run frontend tests only"
@echo " make test-be - Run backend tests only"
@echo " make lint - Run all linters"
@echo " make lint-fe - Run frontend linter only"
@echo " make lint-be - Run backend linter only"
@echo " make clean - Remove containers and volumes"
@echo " make install - Install dependencies"
@echo " make check - Check environment setup"
@echo " make dev - Backend in Docker, frontend local (recommended)"
@echo " make dev-docker - Backend + frontend in Docker"
@echo " make dev-docker-full - Everything in Docker (server + mcp + ui + work orders)"
@echo " make dev-hybrid-work-orders - Server + MCP in Docker, UI + work orders local (2 terminals)"
@echo " make dev-work-orders - Backend in Docker, agent work orders local, frontend local"
@echo " make agent-work-orders - Run agent work orders service locally"
@echo " make stop - Stop all services"
@echo " make test - Run all tests"
@echo " make test-fe - Run frontend tests only"
@echo " make test-be - Run backend tests only"
@echo " make lint - Run all linters"
@echo " make lint-fe - Run frontend linter only"
@echo " make lint-be - Run backend linter only"
@echo " make clean - Remove containers and volumes"
@echo " make install - Install dependencies"
@echo " make check - Check environment setup"
# Install dependencies
install:
@@ -54,18 +58,73 @@ dev: check
VITE_ARCHON_SERVER_HOST=$${HOST:-} \
npm run dev
# Full Docker development
# Full Docker development (backend + frontend, no work orders)
dev-docker: check
@echo "Starting full Docker environment..."
@echo "Starting Docker environment (backend + frontend)..."
@$(COMPOSE) --profile full up -d --build
@echo "✓ All services running"
@echo "✓ Services running"
@echo "Frontend: http://localhost:3737"
@echo "API: http://localhost:8181"
# Full Docker with all services (server + mcp + ui + agent work orders)
dev-docker-full: check
@echo "Starting full Docker environment with agent work orders..."
@$(COMPOSE) up archon-server archon-mcp archon-frontend archon-agent-work-orders -d --build
@set -a; [ -f .env ] && . ./.env; set +a; \
echo "✓ All services running"; \
echo "Frontend: http://localhost:3737"; \
echo "API: http://$${HOST:-localhost}:$${ARCHON_SERVER_PORT:-8181}"; \
echo "MCP: http://$${HOST:-localhost}:$${ARCHON_MCP_PORT:-8051}"; \
echo "Agent Work Orders: http://$${HOST:-localhost}:$${AGENT_WORK_ORDERS_PORT:-8053}"
# Agent work orders service locally (standalone)
agent-work-orders:
@echo "Starting Agent Work Orders service locally..."
@set -a; [ -f .env ] && . ./.env; set +a; \
export SERVICE_DISCOVERY_MODE=local; \
export ARCHON_SERVER_URL=http://localhost:$${ARCHON_SERVER_PORT:-8181}; \
export ARCHON_MCP_URL=http://localhost:$${ARCHON_MCP_PORT:-8051}; \
export AGENT_WORK_ORDERS_PORT=$${AGENT_WORK_ORDERS_PORT:-8053}; \
cd python && uv run python -m uvicorn src.agent_work_orders.server:app --host 0.0.0.0 --port $${AGENT_WORK_ORDERS_PORT:-8053} --reload
# Hybrid development with agent work orders (backend in Docker, agent work orders local, frontend local)
dev-work-orders: check
@echo "Starting hybrid development with agent work orders..."
@echo "Backend: Docker | Agent Work Orders: Local | Frontend: Local"
@$(COMPOSE) up archon-server archon-mcp -d --build
@set -a; [ -f .env ] && . ./.env; set +a; \
echo "Backend running at http://$${HOST:-localhost}:$${ARCHON_SERVER_PORT:-8181}"; \
echo "Starting agent work orders service..."; \
echo "Run in separate terminal: make agent-work-orders"; \
echo "Starting frontend..."; \
cd archon-ui-main && \
VITE_ARCHON_SERVER_PORT=$${ARCHON_SERVER_PORT:-8181} \
VITE_ARCHON_SERVER_HOST=$${HOST:-} \
npm run dev
# Hybrid development: Server + MCP in Docker, UI + Work Orders local (requires 2 terminals)
dev-hybrid-work-orders: check
@echo "Starting hybrid development: Server + MCP in Docker, UI + Work Orders local"
@echo "================================================================"
@$(COMPOSE) up archon-server archon-mcp -d --build
@set -a; [ -f .env ] && . ./.env; set +a; \
echo ""; \
echo "✓ Server + MCP running in Docker"; \
echo " Server: http://$${HOST:-localhost}:$${ARCHON_SERVER_PORT:-8181}"; \
echo " MCP: http://$${HOST:-localhost}:$${ARCHON_MCP_PORT:-8051}"; \
echo ""; \
echo "Next steps:"; \
echo " 1. Terminal 1 (this one): Press Ctrl+C when done"; \
echo " 2. Terminal 2: make agent-work-orders"; \
echo " 3. Terminal 3: cd archon-ui-main && npm run dev"; \
echo ""; \
echo "Or use 'make dev-docker-full' to run everything in Docker."; \
@read -p "Press Enter to continue or Ctrl+C to stop..." _
# Stop all services
stop:
@echo "Stopping all services..."
@$(COMPOSE) --profile backend --profile frontend --profile full down
@$(COMPOSE) --profile backend --profile frontend --profile full --profile work-orders down
@echo "✓ Services stopped"
# Run all tests

View File

@@ -0,0 +1,89 @@
# CLI reference
> Complete reference for Claude Code command-line interface, including commands and flags.
## CLI commands
| Command | Description | Example |
| :--------------------------------- | :--------------------------------------------- | :----------------------------------------------------------------- |
| `claude` | Start interactive REPL | `claude` |
| `claude "query"` | Start REPL with initial prompt | `claude "explain this project"` |
| `claude -p "query"` | Query via SDK, then exit | `claude -p "explain this function"` |
| `cat file \| claude -p "query"` | Process piped content | `cat logs.txt \| claude -p "explain"` |
| `claude -c` | Continue most recent conversation | `claude -c` |
| `claude -c -p "query"` | Continue via SDK | `claude -c -p "Check for type errors"` |
| `claude -r "<session-id>" "query"` | Resume session by ID | `claude -r "abc123" "Finish this PR"` |
| `claude update` | Update to latest version | `claude update` |
| `claude mcp` | Configure Model Context Protocol (MCP) servers | See the [Claude Code MCP documentation](/en/docs/claude-code/mcp). |
## CLI flags
Customize Claude Code's behavior with these command-line flags:
| Flag | Description | Example |
| :------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------- |
| `--add-dir` | Add additional working directories for Claude to access (validates each path exists as a directory) | `claude --add-dir ../apps ../lib` |
| `--agents` | Define custom [subagents](/en/docs/claude-code/sub-agents) dynamically via JSON (see below for format) | `claude --agents '{"reviewer":{"description":"Reviews code","prompt":"You are a code reviewer"}}'` |
| `--allowedTools` | A list of tools that should be allowed without prompting the user for permission, in addition to [settings.json files](/en/docs/claude-code/settings) | `"Bash(git log:*)" "Bash(git diff:*)" "Read"` |
| `--disallowedTools` | A list of tools that should be disallowed without prompting the user for permission, in addition to [settings.json files](/en/docs/claude-code/settings) | `"Bash(git log:*)" "Bash(git diff:*)" "Edit"` |
| `--print`, `-p` | Print response without interactive mode (see [SDK documentation](/en/docs/claude-code/sdk) for programmatic usage details) | `claude -p "query"` |
| `--append-system-prompt` | Append to system prompt (only with `--print`) | `claude --append-system-prompt "Custom instruction"` |
| `--output-format` | Specify output format for print mode (options: `text`, `json`, `stream-json`) | `claude -p "query" --output-format json` |
| `--input-format` | Specify input format for print mode (options: `text`, `stream-json`) | `claude -p --output-format json --input-format stream-json` |
| `--include-partial-messages` | Include partial streaming events in output (requires `--print` and `--output-format=stream-json`) | `claude -p --output-format stream-json --include-partial-messages "query"` |
| `--verbose` | Enable verbose logging, shows full turn-by-turn output (helpful for debugging in both print and interactive modes) | `claude --verbose` |
| `--max-turns` | Limit the number of agentic turns in non-interactive mode | `claude -p --max-turns 3 "query"` |
| `--model` | Sets the model for the current session with an alias for the latest model (`sonnet` or `opus`) or a model's full name | `claude --model claude-sonnet-4-5-20250929` |
| `--permission-mode` | Begin in a specified [permission mode](iam#permission-modes) | `claude --permission-mode plan` |
| `--permission-prompt-tool` | Specify an MCP tool to handle permission prompts in non-interactive mode | `claude -p --permission-prompt-tool mcp_auth_tool "query"` |
| `--resume` | Resume a specific session by ID, or by choosing in interactive mode | `claude --resume abc123 "query"` |
| `--continue` | Load the most recent conversation in the current directory | `claude --continue` |
| `--dangerously-skip-permissions` | Skip permission prompts (use with caution) | `claude --dangerously-skip-permissions` |
<Tip>
The `--output-format json` flag is particularly useful for scripting and
automation, allowing you to parse Claude's responses programmatically.
</Tip>
### Agents flag format
The `--agents` flag accepts a JSON object that defines one or more custom subagents. Each subagent requires a unique name (as the key) and a definition object with the following fields:
| Field | Required | Description |
| :------------ | :------- | :-------------------------------------------------------------------------------------------------------------- |
| `description` | Yes | Natural language description of when the subagent should be invoked |
| `prompt` | Yes | The system prompt that guides the subagent's behavior |
| `tools` | No | Array of specific tools the subagent can use (e.g., `["Read", "Edit", "Bash"]`). If omitted, inherits all tools |
| `model` | No | Model alias to use: `sonnet`, `opus`, or `haiku`. If omitted, uses the default subagent model |
Example:
```bash theme={null}
claude --agents '{
"code-reviewer": {
"description": "Expert code reviewer. Use proactively after code changes.",
"prompt": "You are a senior code reviewer. Focus on code quality, security, and best practices.",
"tools": ["Read", "Grep", "Glob", "Bash"],
"model": "sonnet"
},
"debugger": {
"description": "Debugging specialist for errors and test failures.",
"prompt": "You are an expert debugger. Analyze errors, identify root causes, and provide fixes."
}
}'
```
For more details on creating and using subagents, see the [subagents documentation](/en/docs/claude-code/sub-agents).
For detailed information about print mode (`-p`) including output formats,
streaming, verbose logging, and programmatic usage, see the
[SDK documentation](/en/docs/claude-code/sdk).
## See also
- [Interactive mode](/en/docs/claude-code/interactive-mode) - Shortcuts, input modes, and interactive features
- [Slash commands](/en/docs/claude-code/slash-commands) - Interactive session commands
- [Quickstart guide](/en/docs/claude-code/quickstart) - Getting started with Claude Code
- [Common workflows](/en/docs/claude-code/common-workflows) - Advanced workflows and patterns
- [Settings](/en/docs/claude-code/settings) - Configuration options
- [SDK documentation](/en/docs/claude-code/sdk) - Programmatic usage and integrations

View File

@@ -0,0 +1,13 @@
# Frontend Environment Configuration
# Agent Work Orders Service (Optional)
# Only set if agent work orders service runs on different host/port than main server
# Default: Uses proxy through main server at /api/agent-work-orders
# Set to the base URL (without /api/agent-work-orders path)
# VITE_AGENT_WORK_ORDERS_URL=http://localhost:8053
# Development Tools
# Show TanStack Query DevTools (for developers only)
# Set to "true" to enable the DevTools panel in bottom right corner
# Defaults to "false" for end users
VITE_SHOW_DEVTOOLS=false

View File

@@ -8,6 +8,7 @@
"name": "archon-ui",
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@mdxeditor/editor": "^3.42.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -34,6 +35,7 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",
@@ -1709,6 +1711,15 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
"integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==",
"license": "MIT",
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",

View File

@@ -54,6 +54,8 @@
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"@hookform/resolvers": "^3.10.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.26.2",

View File

@@ -14,6 +14,8 @@ import { SettingsProvider, useSettings } from './contexts/SettingsContext';
import { TooltipProvider } from './features/ui/primitives/tooltip';
import { ProjectPage } from './pages/ProjectPage';
import StyleGuidePage from './pages/StyleGuidePage';
import { AgentWorkOrdersPage } from './pages/AgentWorkOrdersPage';
import { AgentWorkOrderDetailPage } from './pages/AgentWorkOrderDetailPage';
import { DisconnectScreenOverlay } from './components/DisconnectScreenOverlay';
import { ErrorBoundaryWithBugReport } from './components/bug-report/ErrorBoundaryWithBugReport';
import { MigrationBanner } from './components/ui/MigrationBanner';
@@ -43,6 +45,8 @@ const AppRoutes = () => {
) : (
<Route path="/projects" element={<Navigate to="/" replace />} />
)}
<Route path="/agent-work-orders" element={<AgentWorkOrdersPage />} />
<Route path="/agent-work-orders/:id" element={<AgentWorkOrderDetailPage />} />
</Routes>
);
};

View File

@@ -1,4 +1,4 @@
import { BookOpen, Palette, Settings } from "lucide-react";
import { BookOpen, Bot, Palette, Settings } from "lucide-react";
import type React from "react";
import { Link, useLocation } from "react-router-dom";
// TEMPORARY: Use old SettingsContext until settings are migrated
@@ -34,6 +34,12 @@ export function Navigation({ className }: NavigationProps) {
label: "Knowledge Base",
enabled: true,
},
{
path: "/agent-work-orders",
icon: <Bot className="h-5 w-5" />,
label: "Agent Work Orders",
enabled: true,
},
{
path: "/mcp",
icon: (

View File

@@ -0,0 +1,237 @@
/**
* CreateWorkOrderDialog Component
*
* Modal dialog for creating new agent work orders with form validation.
* Includes repository URL, sandbox type, user request, and command selection.
*/
import { zodResolver } from "@hookform/resolvers/zod";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/features/ui/primitives/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/features/ui/primitives/dialog";
import { useCreateWorkOrder } from "../hooks/useAgentWorkOrderQueries";
import type { WorkflowStep } from "../types";
const workOrderSchema = z.object({
repository_url: z.string().url("Must be a valid URL"),
sandbox_type: z.enum(["git_branch", "git_worktree"]),
user_request: z.string().min(10, "Request must be at least 10 characters"),
github_issue_number: z.string().optional(),
});
type WorkOrderFormData = z.infer<typeof workOrderSchema>;
interface CreateWorkOrderDialogProps {
/** Whether dialog is open */
open: boolean;
/** Callback when dialog should close */
onClose: () => void;
/** Callback when work order is created */
onSuccess?: (workOrderId: string) => void;
}
const ALL_COMMANDS: WorkflowStep[] = ["create-branch", "planning", "execute", "commit", "create-pr"];
const COMMAND_LABELS: Record<WorkflowStep, string> = {
"create-branch": "Create Branch",
planning: "Planning",
execute: "Execute",
commit: "Commit",
"create-pr": "Create PR",
"prp-review": "PRP Review",
};
export function CreateWorkOrderDialog({ open, onClose, onSuccess }: CreateWorkOrderDialogProps) {
const [selectedCommands, setSelectedCommands] = useState<WorkflowStep[]>(ALL_COMMANDS);
const createWorkOrder = useCreateWorkOrder();
const formId = useId();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<WorkOrderFormData>({
resolver: zodResolver(workOrderSchema),
defaultValues: {
sandbox_type: "git_branch",
},
});
const handleClose = () => {
reset();
setSelectedCommands(ALL_COMMANDS);
onClose();
};
const onSubmit = async (data: WorkOrderFormData) => {
createWorkOrder.mutate(
{
...data,
selected_commands: selectedCommands,
github_issue_number: data.github_issue_number || null,
},
{
onSuccess: (result) => {
handleClose();
onSuccess?.(result.agent_work_order_id);
},
},
);
};
const toggleCommand = (command: WorkflowStep) => {
setSelectedCommands((prev) => (prev.includes(command) ? prev.filter((c) => c !== command) : [...prev, command]));
};
const setPreset = (preset: "full" | "planning" | "no-pr") => {
switch (preset) {
case "full":
setSelectedCommands(ALL_COMMANDS);
break;
case "planning":
setSelectedCommands(["create-branch", "planning"]);
break;
case "no-pr":
setSelectedCommands(["create-branch", "planning", "execute", "commit"]);
break;
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Agent Work Order</DialogTitle>
<DialogDescription>Configure and launch a new AI-driven development workflow</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor={`${formId}-repository_url`} className="block text-sm font-medium text-gray-300 mb-2">
Repository URL *
</label>
<input
id={`${formId}-repository_url`}
type="text"
{...register("repository_url")}
placeholder="https://github.com/username/repo"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
{errors.repository_url && <p className="mt-1 text-sm text-red-400">{errors.repository_url.message}</p>}
</div>
<div>
<label htmlFor={`${formId}-sandbox_type`} className="block text-sm font-medium text-gray-300 mb-2">
Sandbox Type *
</label>
<select
id={`${formId}-sandbox_type`}
{...register("sandbox_type")}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="git_branch">Git Branch</option>
<option value="git_worktree">Git Worktree</option>
</select>
</div>
<div>
<label htmlFor={`${formId}-user_request`} className="block text-sm font-medium text-gray-300 mb-2">
User Request *
</label>
<textarea
id={`${formId}-user_request`}
{...register("user_request")}
rows={4}
placeholder="Describe the work you want the AI agent to perform..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500 resize-none"
/>
{errors.user_request && <p className="mt-1 text-sm text-red-400">{errors.user_request.message}</p>}
</div>
<div>
<label htmlFor={`${formId}-github_issue_number`} className="block text-sm font-medium text-gray-300 mb-2">
GitHub Issue Number (optional)
</label>
<input
id={`${formId}-github_issue_number`}
type="text"
{...register("github_issue_number")}
placeholder="123"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-300">Workflow Commands</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPreset("full")}
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
>
Full
</button>
<button
type="button"
onClick={() => setPreset("planning")}
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
>
Planning Only
</button>
<button
type="button"
onClick={() => setPreset("no-pr")}
className="text-xs px-2 py-1 bg-gray-700 text-gray-300 rounded hover:bg-gray-600"
>
No PR
</button>
</div>
</div>
<div className="space-y-2">
{ALL_COMMANDS.map((command) => (
<label
key={command}
className="flex items-center gap-3 p-3 bg-gray-800 border border-gray-700 rounded-lg hover:border-gray-600 cursor-pointer"
>
<input
type="checkbox"
checked={selectedCommands.includes(command)}
onChange={() => toggleCommand(command)}
className="w-4 h-4 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500"
/>
<span className="text-gray-300">{COMMAND_LABELS[command]}</span>
</label>
))}
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={handleClose} disabled={createWorkOrder.isPending}>
Cancel
</Button>
<Button type="submit" disabled={createWorkOrder.isPending || selectedCommands.length === 0}>
{createWorkOrder.isPending ? "Creating..." : "Create Work Order"}
</Button>
</DialogFooter>
</form>
{createWorkOrder.isError && (
<div className="mt-4 p-3 bg-red-900 bg-opacity-30 border border-red-700 rounded text-sm text-red-300">
Failed to create work order. Please try again.
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,176 @@
/**
* RealTimeStats Component
*
* Displays real-time execution statistics derived from log stream.
* Shows current step, progress percentage, elapsed time, and current activity.
*/
import { Activity, Clock, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useLogStats } from "../hooks/useLogStats";
import { useWorkOrderLogs } from "../hooks/useWorkOrderLogs";
interface RealTimeStatsProps {
/** Work order ID to stream logs for */
workOrderId: string | undefined;
}
/**
* Format elapsed seconds to human-readable duration
*/
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${secs}s`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}
/**
* Format relative time from ISO timestamp
*/
function formatRelativeTime(timestamp: string): string {
const now = new Date().getTime();
const logTime = new Date(timestamp).getTime();
const diffSeconds = Math.floor((now - logTime) / 1000);
if (diffSeconds < 1) return "just now";
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
return `${Math.floor(diffSeconds / 3600)}h ago`;
}
export function RealTimeStats({ workOrderId }: RealTimeStatsProps) {
const { logs } = useWorkOrderLogs({ workOrderId, autoReconnect: true });
const stats = useLogStats(logs);
// Live elapsed time that updates every second
const [currentElapsedSeconds, setCurrentElapsedSeconds] = useState<number | null>(null);
/**
* Update elapsed time every second if work order is running
*/
useEffect(() => {
if (!stats.hasStarted || stats.hasCompleted || stats.hasFailed) {
setCurrentElapsedSeconds(stats.elapsedSeconds);
return;
}
// Start from last known elapsed time or 0
const startTime = Date.now();
const initialElapsed = stats.elapsedSeconds || 0;
const interval = setInterval(() => {
const additionalSeconds = Math.floor((Date.now() - startTime) / 1000);
setCurrentElapsedSeconds(initialElapsed + additionalSeconds);
}, 1000);
return () => clearInterval(interval);
}, [stats.hasStarted, stats.hasCompleted, stats.hasFailed, stats.elapsedSeconds]);
// Don't render if no logs yet
if (logs.length === 0 || !stats.hasStarted) {
return null;
}
return (
<div className="border border-white/10 rounded-lg p-4 bg-black/20 backdrop-blur">
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
<Activity className="w-4 h-4" />
Real-Time Execution
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Current Step */}
<div className="space-y-1">
<div className="text-xs text-gray-500 uppercase tracking-wide">Current Step</div>
<div className="text-sm font-medium text-gray-200">
{stats.currentStep || "Initializing..."}
{stats.currentStepNumber !== null && stats.totalSteps !== null && (
<span className="text-gray-500 ml-2">
({stats.currentStepNumber}/{stats.totalSteps})
</span>
)}
</div>
</div>
{/* Progress */}
<div className="space-y-1">
<div className="text-xs text-gray-500 uppercase tracking-wide flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
Progress
</div>
{stats.progressPct !== null ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 transition-all duration-500 ease-out"
style={{ width: `${stats.progressPct}%` }}
/>
</div>
<span className="text-sm font-medium text-cyan-400">{stats.progressPct}%</span>
</div>
</div>
) : (
<div className="text-sm text-gray-500">Calculating...</div>
)}
</div>
{/* Elapsed Time */}
<div className="space-y-1">
<div className="text-xs text-gray-500 uppercase tracking-wide flex items-center gap-1">
<Clock className="w-3 h-3" />
Elapsed Time
</div>
<div className="text-sm font-medium text-gray-200">
{currentElapsedSeconds !== null ? formatDuration(currentElapsedSeconds) : "0s"}
</div>
</div>
</div>
{/* Current Activity */}
{stats.currentActivity && (
<div className="mt-4 pt-3 border-t border-white/10">
<div className="flex items-start gap-2">
<div className="text-xs text-gray-500 uppercase tracking-wide whitespace-nowrap">Latest Activity:</div>
<div className="text-sm text-gray-300 flex-1">
{stats.currentActivity}
{stats.lastActivity && (
<span className="text-gray-500 ml-2 text-xs">{formatRelativeTime(stats.lastActivity)}</span>
)}
</div>
</div>
</div>
)}
{/* Status Indicators */}
<div className="mt-3 flex items-center gap-4 text-xs">
{stats.hasCompleted && (
<div className="flex items-center gap-1 text-green-400">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>Completed</span>
</div>
)}
{stats.hasFailed && (
<div className="flex items-center gap-1 text-red-400">
<div className="w-2 h-2 bg-red-500 rounded-full" />
<span>Failed</span>
</div>
)}
{!stats.hasCompleted && !stats.hasFailed && stats.hasStarted && (
<div className="flex items-center gap-1 text-blue-400">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span>Running</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
/**
* StepHistoryTimeline Component
*
* Displays a vertical timeline of step execution history with status,
* duration, and error messages.
*/
import { formatDistanceToNow } from "date-fns";
import type { StepExecutionResult } from "../types";
interface StepHistoryTimelineProps {
/** Array of executed steps */
steps: StepExecutionResult[];
/** Current phase being executed */
currentPhase: string | null;
}
const STEP_LABELS: Record<string, string> = {
"create-branch": "Create Branch",
planning: "Planning",
execute: "Execute",
commit: "Commit",
"create-pr": "Create PR",
"prp-review": "PRP Review",
};
export function StepHistoryTimeline({ steps, currentPhase }: StepHistoryTimelineProps) {
if (steps.length === 0) {
return <div className="text-center py-8 text-gray-400">No steps executed yet</div>;
}
const formatDuration = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
};
return (
<div className="space-y-4">
{steps.map((step, index) => {
const isLast = index === steps.length - 1;
const isCurrent = currentPhase === step.step;
const timeAgo = formatDistanceToNow(new Date(step.timestamp), {
addSuffix: true,
});
return (
<div key={`${step.step}-${step.timestamp}`} className="flex gap-4">
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${
step.success ? "bg-green-500 border-green-400" : "bg-red-500 border-red-400"
} ${isCurrent ? "animate-pulse" : ""}`}
>
{step.success ? (
<span className="text-white text-sm"></span>
) : (
<span className="text-white text-sm"></span>
)}
</div>
{!isLast && (
<div className={`w-0.5 flex-1 min-h-[40px] ${step.success ? "bg-green-500" : "bg-red-500"}`} />
)}
</div>
<div className="flex-1 pb-4">
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-white font-semibold">{STEP_LABELS[step.step] || step.step}</h4>
<p className="text-sm text-gray-400 mt-1">{step.agent_name}</p>
</div>
<div className="text-right">
<div
className={`text-xs font-medium px-2 py-1 rounded ${
step.success
? "bg-green-900 bg-opacity-30 text-green-400"
: "bg-red-900 bg-opacity-30 text-red-400"
}`}
>
{formatDuration(step.duration_seconds)}
</div>
<p className="text-xs text-gray-500 mt-1">{timeAgo}</p>
</div>
</div>
{step.output && (
<div className="mt-3 p-3 bg-gray-900 bg-opacity-50 rounded border border-gray-700">
<p className="text-sm text-gray-300 font-mono whitespace-pre-wrap">
{step.output.length > 500 ? `${step.output.substring(0, 500)}...` : step.output}
</p>
</div>
)}
{step.error_message && (
<div className="mt-3 p-3 bg-red-900 bg-opacity-30 border border-red-700 rounded">
<p className="text-sm text-red-300 font-mono whitespace-pre-wrap">{step.error_message}</p>
</div>
)}
{step.session_id && <div className="mt-2 text-xs text-gray-500">Session: {step.session_id}</div>}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,115 @@
/**
* WorkOrderCard Component
*
* Displays a summary card for a single work order with status badge,
* repository info, and key metadata.
*/
import { formatDistanceToNow } from "date-fns";
import type { AgentWorkOrder } from "../types";
interface WorkOrderCardProps {
/** Work order to display */
workOrder: AgentWorkOrder;
/** Callback when card is clicked */
onClick?: () => void;
}
const STATUS_STYLES: Record<AgentWorkOrder["status"], { bg: string; text: string; label: string }> = {
pending: {
bg: "bg-gray-700",
text: "text-gray-300",
label: "Pending",
},
running: {
bg: "bg-blue-600",
text: "text-blue-100",
label: "Running",
},
completed: {
bg: "bg-green-600",
text: "text-green-100",
label: "Completed",
},
failed: {
bg: "bg-red-600",
text: "text-red-100",
label: "Failed",
},
};
export function WorkOrderCard({ workOrder, onClick }: WorkOrderCardProps) {
const statusStyle = STATUS_STYLES[workOrder.status];
const repoName = workOrder.repository_url.split("/").slice(-2).join("/");
const timeAgo = formatDistanceToNow(new Date(workOrder.created_at), {
addSuffix: true,
});
return (
<div
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
}}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-4 hover:border-blue-500 transition-all cursor-pointer"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white truncate">{repoName}</h3>
<p className="text-sm text-gray-400 mt-1">{timeAgo}</p>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-medium ${statusStyle.bg} ${statusStyle.text} ml-3`}>
{statusStyle.label}
</div>
</div>
{workOrder.current_phase && (
<div className="mb-2">
<p className="text-sm text-gray-300">
Phase: <span className="text-blue-400">{workOrder.current_phase}</span>
</p>
</div>
)}
{workOrder.git_branch_name && (
<div className="mb-2">
<p className="text-sm text-gray-300">
Branch: <span className="text-cyan-400 font-mono text-xs">{workOrder.git_branch_name}</span>
</p>
</div>
)}
{workOrder.github_pull_request_url && (
<div className="mb-2">
<a
href={workOrder.github_pull_request_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-400 hover:text-blue-300 underline"
onClick={(e) => e.stopPropagation()}
>
View Pull Request
</a>
</div>
)}
{workOrder.error_message && (
<div className="mt-2 p-2 bg-red-900 bg-opacity-30 border border-red-700 rounded text-xs text-red-300">
{workOrder.error_message.length > 100
? `${workOrder.error_message.substring(0, 100)}...`
: workOrder.error_message}
</div>
)}
<div className="flex items-center gap-4 mt-3 text-xs text-gray-500">
{workOrder.git_commit_count > 0 && <span>{workOrder.git_commit_count} commits</span>}
{workOrder.git_files_changed > 0 && <span>{workOrder.git_files_changed} files changed</span>}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
/**
* WorkOrderList Component
*
* Displays a filterable list of agent work orders with status filters and search.
*/
import { useMemo, useState } from "react";
import { useWorkOrders } from "../hooks/useAgentWorkOrderQueries";
import type { AgentWorkOrderStatus } from "../types";
import { WorkOrderCard } from "./WorkOrderCard";
interface WorkOrderListProps {
/** Callback when a work order card is clicked */
onWorkOrderClick?: (workOrderId: string) => void;
}
const STATUS_OPTIONS: Array<{
value: AgentWorkOrderStatus | "all";
label: string;
}> = [
{ value: "all", label: "All" },
{ value: "pending", label: "Pending" },
{ value: "running", label: "Running" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
];
export function WorkOrderList({ onWorkOrderClick }: WorkOrderListProps) {
const [statusFilter, setStatusFilter] = useState<AgentWorkOrderStatus | "all">("all");
const [searchQuery, setSearchQuery] = useState("");
const queryFilter = statusFilter === "all" ? undefined : statusFilter;
const { data: workOrders, isLoading, isError } = useWorkOrders(queryFilter);
const filteredWorkOrders = useMemo(() => {
if (!workOrders) return [];
return workOrders.filter((wo) => {
const matchesSearch =
searchQuery === "" ||
wo.repository_url.toLowerCase().includes(searchQuery.toLowerCase()) ||
wo.agent_work_order_id.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
});
}, [workOrders, searchQuery]);
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div
key={`skeleton-${
// biome-ignore lint/suspicious/noArrayIndexKey: skeleton loading
i
}`}
className="h-40 bg-gray-800 bg-opacity-50 rounded-lg animate-pulse"
/>
))}
</div>
);
}
if (isError) {
return (
<div className="text-center py-12">
<p className="text-red-400">Failed to load work orders</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by repository or ID..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as AgentWorkOrderStatus | "all")}
className="w-full sm:w-auto px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{filteredWorkOrders.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-400">{searchQuery ? "No work orders match your search" : "No work orders found"}</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredWorkOrders.map((workOrder) => (
<WorkOrderCard
key={workOrder.agent_work_order_id}
workOrder={workOrder}
onClick={() => onWorkOrderClick?.(workOrder.agent_work_order_id)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,225 @@
/**
* WorkOrderLogsPanel Component
*
* Terminal-style log viewer for real-time work order execution logs.
* Connects to SSE endpoint and displays logs with filtering and auto-scroll capabilities.
*/
import { ChevronDown, ChevronUp, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/features/ui/primitives/button";
import { useWorkOrderLogs } from "../hooks/useWorkOrderLogs";
import type { LogEntry } from "../types";
interface WorkOrderLogsPanelProps {
/** Work order ID to stream logs for */
workOrderId: string | undefined;
}
/**
* Get color class for log level badge
*/
function getLogLevelColor(level: string): string {
switch (level) {
case "info":
return "bg-blue-500/20 text-blue-400 border-blue-400/30";
case "warning":
return "bg-yellow-500/20 text-yellow-400 border-yellow-400/30";
case "error":
return "bg-red-500/20 text-red-400 border-red-400/30";
case "debug":
return "bg-gray-500/20 text-gray-400 border-gray-400/30";
default:
return "bg-gray-500/20 text-gray-400 border-gray-400/30";
}
}
/**
* Format timestamp to relative time
*/
function formatRelativeTime(timestamp: string): string {
const now = Date.now();
const logTime = new Date(timestamp).getTime();
const diffSeconds = Math.floor((now - logTime) / 1000);
if (diffSeconds < 60) return `${diffSeconds}s ago`;
if (diffSeconds < 3600) return `${Math.floor(diffSeconds / 60)}m ago`;
if (diffSeconds < 86400) return `${Math.floor(diffSeconds / 3600)}h ago`;
return `${Math.floor(diffSeconds / 86400)}d ago`;
}
/**
* Individual log entry component
*/
function LogEntryRow({ log }: { log: LogEntry }) {
return (
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 rounded font-mono text-sm">
<span className="text-gray-500 text-xs whitespace-nowrap">{formatRelativeTime(log.timestamp)}</span>
<span
className={`px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap ${getLogLevelColor(log.level)}`}
>
{log.level}
</span>
{log.step && <span className="text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
<span className="text-gray-300 flex-1">{log.event}</span>
{log.progress && <span className="text-gray-500 text-xs whitespace-nowrap">{log.progress}</span>}
</div>
);
}
export function WorkOrderLogsPanel({ workOrderId }: WorkOrderLogsPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [levelFilter, setLevelFilter] = useState<"info" | "warning" | "error" | "debug" | undefined>(undefined);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const { logs, connectionState, isConnected, error, reconnect, clearLogs } = useWorkOrderLogs({
workOrderId,
levelFilter,
autoReconnect: true,
});
/**
* Auto-scroll to bottom when new logs arrive
*/
useEffect(() => {
if (autoScroll && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, [autoScroll]);
/**
* Detect manual scroll and disable auto-scroll
*/
const handleScroll = useCallback(() => {
if (!scrollContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isAtBottom && autoScroll) {
setAutoScroll(false);
} else if (isAtBottom && !autoScroll) {
setAutoScroll(true);
}
}, [autoScroll]);
/**
* Filter logs by level if filter is active
*/
const filteredLogs = levelFilter ? logs.filter((log) => log.level === levelFilter) : logs;
return (
<div className="border border-white/10 rounded-lg overflow-hidden bg-black/20 backdrop-blur">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
<span className="font-semibold">Execution Logs</span>
</button>
{/* Connection status indicator */}
<div className="flex items-center gap-2">
{connectionState === "connecting" && <span className="text-xs text-gray-500">Connecting...</span>}
{isConnected && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs text-green-400">Live</span>
</div>
)}
{connectionState === "error" && (
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-red-500 rounded-full" />
<span className="text-xs text-red-400">Disconnected</span>
</div>
)}
</div>
<span className="text-xs text-gray-500">({filteredLogs.length} entries)</span>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
{/* Level filter */}
<select
value={levelFilter || ""}
onChange={(e) => setLevelFilter((e.target.value as "info" | "warning" | "error" | "debug") || undefined)}
className="bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-gray-300 hover:bg-white/10 transition-colors"
>
<option value="">All Levels</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</select>
{/* Auto-scroll toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => setAutoScroll(!autoScroll)}
className={autoScroll ? "text-cyan-400" : "text-gray-500"}
title={autoScroll ? "Auto-scroll enabled" : "Auto-scroll disabled"}
>
Auto-scroll: {autoScroll ? "ON" : "OFF"}
</Button>
{/* Clear logs */}
<Button variant="ghost" size="sm" onClick={clearLogs} title="Clear logs">
<Trash2 className="w-4 h-4" />
</Button>
{/* Reconnect button */}
{connectionState === "error" && (
<Button variant="ghost" size="sm" onClick={reconnect} title="Reconnect">
<RefreshCw className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* Log content */}
{isExpanded && (
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="max-h-96 overflow-y-auto bg-black/40"
style={{ scrollBehavior: autoScroll ? "smooth" : "auto" }}
>
{/* Empty state */}
{filteredLogs.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
{connectionState === "connecting" && <p>Connecting to log stream...</p>}
{connectionState === "error" && (
<div className="text-center">
<p className="text-red-400">Failed to connect to log stream</p>
{error && <p className="text-xs text-gray-500 mt-1">{error.message}</p>}
<Button onClick={reconnect} className="mt-4">
Retry Connection
</Button>
</div>
)}
{isConnected && logs.length === 0 && <p>No logs yet. Waiting for execution...</p>}
{isConnected && logs.length > 0 && filteredLogs.length === 0 && <p>No logs match the current filter</p>}
</div>
)}
{/* Log entries */}
{filteredLogs.length > 0 && (
<div className="p-2">
{filteredLogs.map((log, index) => (
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,97 @@
/**
* WorkOrderProgressBar Component
*
* Displays visual progress of a work order through its workflow steps.
* Shows 5 steps with visual indicators for pending, running, success, and failed states.
*/
import type { StepExecutionResult, WorkflowStep } from "../types";
interface WorkOrderProgressBarProps {
/** Array of executed steps */
steps: StepExecutionResult[];
/** Current phase/step being executed */
currentPhase: string | null;
}
const WORKFLOW_STEPS: WorkflowStep[] = ["create-branch", "planning", "execute", "commit", "create-pr"];
const STEP_LABELS: Record<WorkflowStep, string> = {
"create-branch": "Create Branch",
planning: "Planning",
execute: "Execute",
commit: "Commit",
"create-pr": "Create PR",
"prp-review": "PRP Review",
};
export function WorkOrderProgressBar({ steps, currentPhase }: WorkOrderProgressBarProps) {
const getStepStatus = (stepName: WorkflowStep): "pending" | "running" | "success" | "failed" => {
const stepResult = steps.find((s) => s.step === stepName);
if (!stepResult) {
return currentPhase === stepName ? "running" : "pending";
}
return stepResult.success ? "success" : "failed";
};
const getStepStyles = (status: string): string => {
switch (status) {
case "success":
return "bg-green-500 border-green-400 text-white";
case "failed":
return "bg-red-500 border-red-400 text-white";
case "running":
return "bg-blue-500 border-blue-400 text-white animate-pulse";
default:
return "bg-gray-700 border-gray-600 text-gray-400";
}
};
const getConnectorStyles = (status: string): string => {
switch (status) {
case "success":
return "bg-green-500";
case "failed":
return "bg-red-500";
case "running":
return "bg-blue-500";
default:
return "bg-gray-700";
}
};
return (
<div className="w-full py-4">
<div className="flex items-center justify-between">
{WORKFLOW_STEPS.map((step, index) => {
const status = getStepStatus(step);
const isLast = index === WORKFLOW_STEPS.length - 1;
return (
<div key={step} className="flex items-center flex-1">
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full border-2 flex items-center justify-center font-semibold transition-all ${getStepStyles(status)}`}
>
{status === "success" ? (
<span></span>
) : status === "failed" ? (
<span></span>
) : status === "running" ? (
<span className="text-sm"></span>
) : (
<span className="text-xs">{index + 1}</span>
)}
</div>
<div className="mt-2 text-xs text-center text-gray-300 max-w-[80px]">{STEP_LABELS[step]}</div>
</div>
{!isLast && <div className={`flex-1 h-1 mx-2 transition-all ${getConnectorStyles(status)}`} />}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,287 @@
/**
* Tests for RealTimeStats Component
*/
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RealTimeStats } from "../RealTimeStats";
import type { LogEntry } from "../../types";
// Mock the hooks
vi.mock("../../hooks/useWorkOrderLogs", () => ({
useWorkOrderLogs: vi.fn(() => ({
logs: [],
})),
}));
vi.mock("../../hooks/useLogStats", () => ({
useLogStats: vi.fn(() => ({
currentStep: null,
currentStepNumber: null,
totalSteps: null,
progressPct: null,
elapsedSeconds: null,
lastActivity: null,
currentActivity: null,
hasStarted: false,
hasCompleted: false,
hasFailed: false,
})),
}));
describe("RealTimeStats", () => {
it("should not render when no logs available", () => {
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: [] });
useLogStats.mockReturnValue({
currentStep: null,
currentStepNumber: null,
totalSteps: null,
progressPct: null,
elapsedSeconds: null,
lastActivity: null,
currentActivity: null,
hasStarted: false,
hasCompleted: false,
hasFailed: false,
});
const { container } = render(<RealTimeStats workOrderId="wo-123" />);
expect(container.firstChild).toBeNull();
});
it("should render with basic stats", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "workflow_started",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "planning",
currentStepNumber: 2,
totalSteps: 5,
progressPct: 40,
elapsedSeconds: 120,
lastActivity: new Date().toISOString(),
currentActivity: "Analyzing codebase",
hasStarted: true,
hasCompleted: false,
hasFailed: false,
});
render(<RealTimeStats workOrderId="wo-123" />);
expect(screen.getByText("Real-Time Execution")).toBeInTheDocument();
expect(screen.getByText("planning")).toBeInTheDocument();
expect(screen.getByText("(2/5)")).toBeInTheDocument();
expect(screen.getByText("40%")).toBeInTheDocument();
expect(screen.getByText("Analyzing codebase")).toBeInTheDocument();
});
it("should show progress bar at correct percentage", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "workflow_started",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "execute",
currentStepNumber: 3,
totalSteps: 5,
progressPct: 60,
elapsedSeconds: 180,
lastActivity: new Date().toISOString(),
currentActivity: "Running tests",
hasStarted: true,
hasCompleted: false,
hasFailed: false,
});
const { container } = render(<RealTimeStats workOrderId="wo-123" />);
// Find progress bar div
const progressBar = container.querySelector('[style*="width: 60%"]');
expect(progressBar).toBeInTheDocument();
});
it("should show completed status", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "workflow_completed",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "create-pr",
currentStepNumber: 5,
totalSteps: 5,
progressPct: 100,
elapsedSeconds: 300,
lastActivity: new Date().toISOString(),
currentActivity: "Pull request created",
hasStarted: true,
hasCompleted: true,
hasFailed: false,
});
render(<RealTimeStats workOrderId="wo-123" />);
expect(screen.getByText("Completed")).toBeInTheDocument();
});
it("should show failed status", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "error",
event: "workflow_failed",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "execute",
currentStepNumber: 3,
totalSteps: 5,
progressPct: 60,
elapsedSeconds: 150,
lastActivity: new Date().toISOString(),
currentActivity: "Error executing command",
hasStarted: true,
hasCompleted: false,
hasFailed: true,
});
render(<RealTimeStats workOrderId="wo-123" />);
expect(screen.getByText("Failed")).toBeInTheDocument();
});
it("should show running status", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "step_started",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "planning",
currentStepNumber: 2,
totalSteps: 5,
progressPct: 40,
elapsedSeconds: 90,
lastActivity: new Date().toISOString(),
currentActivity: "Generating plan",
hasStarted: true,
hasCompleted: false,
hasFailed: false,
});
render(<RealTimeStats workOrderId="wo-123" />);
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should handle missing progress percentage", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "workflow_started",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "planning",
currentStepNumber: null,
totalSteps: null,
progressPct: null,
elapsedSeconds: 30,
lastActivity: new Date().toISOString(),
currentActivity: "Initializing",
hasStarted: true,
hasCompleted: false,
hasFailed: false,
});
render(<RealTimeStats workOrderId="wo-123" />);
expect(screen.getByText("Calculating...")).toBeInTheDocument();
});
it("should format elapsed time correctly", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "workflow_started",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
const { useLogStats } = require("../../hooks/useLogStats");
// Test with 125 seconds (2m 5s)
useWorkOrderLogs.mockReturnValue({ logs: mockLogs });
useLogStats.mockReturnValue({
currentStep: "planning",
currentStepNumber: 2,
totalSteps: 5,
progressPct: 40,
elapsedSeconds: 125,
lastActivity: new Date().toISOString(),
currentActivity: "Working",
hasStarted: true,
hasCompleted: false,
hasFailed: false,
});
render(<RealTimeStats workOrderId="wo-123" />);
// Should show minutes and seconds
expect(screen.getByText(/2m 5s/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,239 @@
/**
* Tests for WorkOrderLogsPanel Component
*/
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WorkOrderLogsPanel } from "../WorkOrderLogsPanel";
import type { LogEntry } from "../../types";
// Mock the hooks
vi.mock("../../hooks/useWorkOrderLogs", () => ({
useWorkOrderLogs: vi.fn(() => ({
logs: [],
connectionState: "disconnected",
isConnected: false,
error: null,
reconnect: vi.fn(),
clearLogs: vi.fn(),
})),
}));
describe("WorkOrderLogsPanel", () => {
it("should render with collapsed state by default", () => {
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
expect(screen.getByText("Execution Logs")).toBeInTheDocument();
expect(screen.queryByText("No logs yet")).not.toBeInTheDocument();
});
it("should expand when clicked", () => {
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: [],
connectionState: "connected",
isConnected: true,
error: null,
reconnect: vi.fn(),
clearLogs: vi.fn(),
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
const expandButton = screen.getByRole("button", { name: /Execution Logs/i });
fireEvent.click(expandButton);
expect(screen.getByText("No logs yet. Waiting for execution...")).toBeInTheDocument();
});
it("should render logs when available", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "workflow_started",
timestamp: new Date().toISOString(),
},
{
work_order_id: "wo-123",
level: "error",
event: "step_failed",
timestamp: new Date().toISOString(),
step: "planning",
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: mockLogs,
connectionState: "connected",
isConnected: true,
error: null,
reconnect: vi.fn(),
clearLogs: vi.fn(),
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
// Expand panel
const expandButton = screen.getByRole("button", { name: /Execution Logs/i });
fireEvent.click(expandButton);
expect(screen.getByText("workflow_started")).toBeInTheDocument();
expect(screen.getByText("step_failed")).toBeInTheDocument();
expect(screen.getByText("[planning]")).toBeInTheDocument();
});
it("should show connection status indicators", () => {
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: [],
connectionState: "connecting",
isConnected: false,
error: null,
reconnect: vi.fn(),
clearLogs: vi.fn(),
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
expect(screen.getByText("Connecting...")).toBeInTheDocument();
});
it("should show error state with retry button", () => {
const mockReconnect = vi.fn();
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: [],
connectionState: "error",
isConnected: false,
error: new Error("Connection failed"),
reconnect: mockReconnect,
clearLogs: vi.fn(),
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
expect(screen.getByText("Disconnected")).toBeInTheDocument();
// Expand to see error details
const expandButton = screen.getByRole("button", { name: /Execution Logs/i });
fireEvent.click(expandButton);
expect(screen.getByText("Failed to connect to log stream")).toBeInTheDocument();
const retryButton = screen.getByRole("button", { name: /Retry Connection/i });
fireEvent.click(retryButton);
expect(mockReconnect).toHaveBeenCalled();
});
it("should call clearLogs when clear button clicked", () => {
const mockClearLogs = vi.fn();
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: [
{
work_order_id: "wo-123",
level: "info",
event: "test",
timestamp: new Date().toISOString(),
},
],
connectionState: "connected",
isConnected: true,
error: null,
reconnect: vi.fn(),
clearLogs: mockClearLogs,
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
const clearButton = screen.getByRole("button", { name: /Clear logs/i });
fireEvent.click(clearButton);
expect(mockClearLogs).toHaveBeenCalled();
});
it("should filter logs by level", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "info_event",
timestamp: new Date().toISOString(),
},
{
work_order_id: "wo-123",
level: "error",
event: "error_event",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: mockLogs,
connectionState: "connected",
isConnected: true,
error: null,
reconnect: vi.fn(),
clearLogs: vi.fn(),
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
// Expand panel
const expandButton = screen.getByRole("button", { name: /Execution Logs/i });
fireEvent.click(expandButton);
// Both logs should be visible initially
expect(screen.getByText("info_event")).toBeInTheDocument();
expect(screen.getByText("error_event")).toBeInTheDocument();
// Filter by error level
const levelFilter = screen.getByRole("combobox");
fireEvent.change(levelFilter, { target: { value: "error" } });
// Only error log should be visible
expect(screen.queryByText("info_event")).not.toBeInTheDocument();
expect(screen.getByText("error_event")).toBeInTheDocument();
});
it("should show entry count", () => {
const mockLogs: LogEntry[] = [
{
work_order_id: "wo-123",
level: "info",
event: "event1",
timestamp: new Date().toISOString(),
},
{
work_order_id: "wo-123",
level: "info",
event: "event2",
timestamp: new Date().toISOString(),
},
{
work_order_id: "wo-123",
level: "info",
event: "event3",
timestamp: new Date().toISOString(),
},
];
const { useWorkOrderLogs } = require("../../hooks/useWorkOrderLogs");
useWorkOrderLogs.mockReturnValue({
logs: mockLogs,
connectionState: "connected",
isConnected: true,
error: null,
reconnect: vi.fn(),
clearLogs: vi.fn(),
});
render(<WorkOrderLogsPanel workOrderId="wo-123" />);
expect(screen.getByText("(3 entries)")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,264 @@
/**
* Tests for Agent Work Order Query Hooks
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentWorkOrderKeys } from "../useAgentWorkOrderQueries";
vi.mock("../../services/agentWorkOrdersService", () => ({
agentWorkOrdersService: {
listWorkOrders: vi.fn(),
getWorkOrder: vi.fn(),
getStepHistory: vi.fn(),
createWorkOrder: vi.fn(),
},
}));
vi.mock("@/features/shared/config/queryPatterns", () => ({
DISABLED_QUERY_KEY: ["disabled"] as const,
STALE_TIMES: {
instant: 0,
realtime: 3_000,
frequent: 5_000,
normal: 30_000,
rare: 300_000,
static: Number.POSITIVE_INFINITY,
},
}));
vi.mock("@/features/shared/hooks/useSmartPolling", () => ({
useSmartPolling: vi.fn(() => 3000),
}));
describe("agentWorkOrderKeys", () => {
it("should generate correct query keys", () => {
expect(agentWorkOrderKeys.all).toEqual(["agent-work-orders"]);
expect(agentWorkOrderKeys.lists()).toEqual(["agent-work-orders", "list"]);
expect(agentWorkOrderKeys.list("running")).toEqual(["agent-work-orders", "list", "running"]);
expect(agentWorkOrderKeys.list(undefined)).toEqual(["agent-work-orders", "list", undefined]);
expect(agentWorkOrderKeys.details()).toEqual(["agent-work-orders", "detail"]);
expect(agentWorkOrderKeys.detail("wo-123")).toEqual(["agent-work-orders", "detail", "wo-123"]);
expect(agentWorkOrderKeys.stepHistory("wo-123")).toEqual(["agent-work-orders", "detail", "wo-123", "steps"]);
});
});
describe("useWorkOrders", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch work orders without filter", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrders } = await import("../useAgentWorkOrderQueries");
const mockWorkOrders = [
{
agent_work_order_id: "wo-1",
status: "running",
},
];
vi.mocked(agentWorkOrdersService.listWorkOrders).mockResolvedValue(mockWorkOrders as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrders(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.listWorkOrders).toHaveBeenCalledWith(undefined);
expect(result.current.data).toEqual(mockWorkOrders);
});
it("should fetch work orders with status filter", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrders } = await import("../useAgentWorkOrderQueries");
const mockWorkOrders = [
{
agent_work_order_id: "wo-1",
status: "completed",
},
];
vi.mocked(agentWorkOrdersService.listWorkOrders).mockResolvedValue(mockWorkOrders as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrders("completed"), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.listWorkOrders).toHaveBeenCalledWith("completed");
expect(result.current.data).toEqual(mockWorkOrders);
});
});
describe("useWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch single work order", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockWorkOrder = {
agent_work_order_id: "wo-123",
status: "running",
};
vi.mocked(agentWorkOrdersService.getWorkOrder).mockResolvedValue(mockWorkOrder as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrder("wo-123"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.getWorkOrder).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockWorkOrder);
});
it("should not fetch when id is undefined", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useWorkOrder } = await import("../useAgentWorkOrderQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useWorkOrder(undefined), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(agentWorkOrdersService.getWorkOrder).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
});
describe("useStepHistory", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});
it("should fetch step history", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStepHistory } = await import("../useAgentWorkOrderQueries");
const mockHistory = {
agent_work_order_id: "wo-123",
steps: [
{
step: "create-branch",
success: true,
},
],
};
vi.mocked(agentWorkOrdersService.getStepHistory).mockResolvedValue(mockHistory as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStepHistory("wo-123"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.getStepHistory).toHaveBeenCalledWith("wo-123");
expect(result.current.data).toEqual(mockHistory);
});
it("should not fetch when workOrderId is undefined", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useStepHistory } = await import("../useAgentWorkOrderQueries");
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useStepHistory(undefined), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(agentWorkOrdersService.getStepHistory).not.toHaveBeenCalled();
expect(result.current.data).toBeUndefined();
});
});
describe("useCreateWorkOrder", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
},
});
vi.clearAllMocks();
});
it("should create work order and invalidate queries", async () => {
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
const { useCreateWorkOrder } = await import("../useAgentWorkOrderQueries");
const mockRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch" as const,
user_request: "Test",
};
const mockCreated = {
agent_work_order_id: "wo-new",
...mockRequest,
status: "pending" as const,
};
vi.mocked(agentWorkOrdersService.createWorkOrder).mockResolvedValue(mockCreated as never);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useCreateWorkOrder(), { wrapper });
result.current.mutate(mockRequest);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(agentWorkOrdersService.createWorkOrder).toHaveBeenCalledWith(mockRequest);
expect(result.current.data).toEqual(mockCreated);
});
});

View File

@@ -0,0 +1,263 @@
/**
* Tests for useWorkOrderLogs Hook
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LogEntry } from "../../types";
import { useWorkOrderLogs } from "../useWorkOrderLogs";
// Mock EventSource
class MockEventSource {
public onopen: ((event: Event) => void) | null = null;
public onmessage: ((event: MessageEvent) => void) | null = null;
public onerror: ((event: Event) => void) | null = null;
public readyState = 0; // CONNECTING
public url: string;
constructor(url: string) {
this.url = url;
// Simulate connection opening after a tick
setTimeout(() => {
this.readyState = 1; // OPEN
if (this.onopen) {
this.onopen(new Event("open"));
}
}, 0);
}
close() {
this.readyState = 2; // CLOSED
}
// Test helper: simulate receiving a message
simulateMessage(data: string) {
if (this.onmessage) {
this.onmessage(new MessageEvent("message", { data }));
}
}
// Test helper: simulate an error
simulateError() {
if (this.onerror) {
this.onerror(new Event("error"));
}
}
}
// Replace global EventSource with mock
global.EventSource = MockEventSource as unknown as typeof EventSource;
describe("useWorkOrderLogs", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should not connect when workOrderId is undefined", () => {
const { result } = renderHook(() =>
useWorkOrderLogs({ workOrderId: undefined, autoReconnect: true }),
);
expect(result.current.logs).toEqual([]);
expect(result.current.connectionState).toBe("disconnected");
expect(result.current.isConnected).toBe(false);
});
it("should connect when workOrderId is provided", async () => {
const workOrderId = "wo-123";
const { result } = renderHook(() => useWorkOrderLogs({ workOrderId, autoReconnect: true }));
// Initially connecting
expect(result.current.connectionState).toBe("connecting");
// Wait for connection to open
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(result.current.connectionState).toBe("connected");
expect(result.current.isConnected).toBe(true);
});
});
it("should parse and append log entries", async () => {
const workOrderId = "wo-123";
const { result } = renderHook(() => useWorkOrderLogs({ workOrderId, autoReconnect: true }));
// Wait for connection
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Get the EventSource instance
const eventSource = (global.EventSource as unknown as typeof MockEventSource).prototype;
// Simulate receiving log entries
const logEntry1: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: "workflow_started",
timestamp: new Date().toISOString(),
};
const logEntry2: LogEntry = {
work_order_id: workOrderId,
level: "info",
event: "step_started",
timestamp: new Date().toISOString(),
step: "planning",
step_number: 1,
total_steps: 5,
};
await act(async () => {
if (result.current.logs.length === 0) {
// Access the actual EventSource instance created by the hook
const instances = Object.values(global).filter(
(v) => v instanceof MockEventSource,
) as MockEventSource[];
if (instances.length > 0) {
instances[0].simulateMessage(JSON.stringify(logEntry1));
instances[0].simulateMessage(JSON.stringify(logEntry2));
}
}
});
// Note: In a real test environment with proper EventSource mocking,
// we would verify the logs array contains the entries.
// This is a simplified test showing the structure.
});
it("should handle malformed JSON gracefully", async () => {
const workOrderId = "wo-123";
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const { result } = renderHook(() => useWorkOrderLogs({ workOrderId, autoReconnect: true }));
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Simulate malformed JSON
const instances = Object.values(global).filter(
(v) => v instanceof MockEventSource,
) as MockEventSource[];
if (instances.length > 0) {
await act(async () => {
instances[0].simulateMessage("{ invalid json }");
});
}
// Hook should not crash, but console.error should be called
expect(result.current.logs).toEqual([]);
consoleErrorSpy.mockRestore();
});
it("should build URL with query parameters", async () => {
const workOrderId = "wo-123";
const { result } = renderHook(() =>
useWorkOrderLogs({
workOrderId,
levelFilter: "error",
stepFilter: "planning",
autoReconnect: true,
}),
);
await act(async () => {
vi.runAllTimers();
});
// Check that EventSource was created with correct URL
const instances = Object.values(global).filter(
(v) => v instanceof MockEventSource,
) as MockEventSource[];
if (instances.length > 0) {
const url = instances[0].url;
expect(url).toContain("level=error");
expect(url).toContain("step=planning");
}
});
it("should clear logs when clearLogs is called", async () => {
const workOrderId = "wo-123";
const { result } = renderHook(() => useWorkOrderLogs({ workOrderId, autoReconnect: true }));
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Add some logs (simulated)
// In real tests, we'd simulate messages here
// Clear logs
act(() => {
result.current.clearLogs();
});
expect(result.current.logs).toEqual([]);
});
it("should cleanup on unmount", async () => {
const workOrderId = "wo-123";
const { result, unmount } = renderHook(() =>
useWorkOrderLogs({ workOrderId, autoReconnect: true }),
);
await act(async () => {
vi.runAllTimers();
});
await waitFor(() => {
expect(result.current.isConnected).toBe(true);
});
// Get EventSource instance
const instances = Object.values(global).filter(
(v) => v instanceof MockEventSource,
) as MockEventSource[];
const closeSpy = vi.spyOn(instances[0], "close");
// Unmount hook
unmount();
// EventSource should be closed
expect(closeSpy).toHaveBeenCalled();
});
it("should limit logs to MAX_LOGS entries", async () => {
const workOrderId = "wo-123";
const { result } = renderHook(() => useWorkOrderLogs({ workOrderId, autoReconnect: true }));
await act(async () => {
vi.runAllTimers();
});
// This test would verify the 500 log limit
// In practice, we'd need to simulate 501+ messages
// and verify only the last 500 are kept
expect(result.current.logs.length).toBeLessThanOrEqual(500);
});
});

View File

@@ -0,0 +1,133 @@
/**
* TanStack Query Hooks for Agent Work Orders
*
* This module provides React hooks for fetching and mutating agent work orders.
* Follows the pattern established in useProjectQueries.ts
*/
import { type UseQueryResult, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
import { useSmartPolling } from "@/features/shared/hooks/useSmartPolling";
import { agentWorkOrdersService } from "../services/agentWorkOrdersService";
import type { AgentWorkOrder, AgentWorkOrderStatus, CreateAgentWorkOrderRequest, StepHistory } from "../types";
/**
* Query key factory for agent work orders
* Provides consistent query keys for cache management
*/
export const agentWorkOrderKeys = {
all: ["agent-work-orders"] as const,
lists: () => [...agentWorkOrderKeys.all, "list"] as const,
list: (filter: AgentWorkOrderStatus | undefined) => [...agentWorkOrderKeys.lists(), filter] as const,
details: () => [...agentWorkOrderKeys.all, "detail"] as const,
detail: (id: string) => [...agentWorkOrderKeys.details(), id] as const,
stepHistory: (id: string) => [...agentWorkOrderKeys.detail(id), "steps"] as const,
};
/**
* Hook to fetch list of agent work orders with smart polling
* Automatically polls when any work order is pending or running
*
* @param statusFilter - Optional status to filter work orders
* @returns Query result with work orders array
*/
export function useWorkOrders(statusFilter?: AgentWorkOrderStatus): UseQueryResult<AgentWorkOrder[], Error> {
const refetchInterval = useSmartPolling({
baseInterval: 3000,
enabled: true,
});
return useQuery({
queryKey: agentWorkOrderKeys.list(statusFilter),
queryFn: () => agentWorkOrdersService.listWorkOrders(statusFilter),
staleTime: STALE_TIMES.instant,
refetchInterval: (query) => {
const data = query.state.data as AgentWorkOrder[] | undefined;
const hasActiveWorkOrders = data?.some(
(wo) => wo.status === "running" || wo.status === "pending"
);
return hasActiveWorkOrders ? refetchInterval : false;
},
});
}
/**
* Hook to fetch a single agent work order with smart polling
* Automatically polls while work order is pending or running
*
* @param id - Work order ID (undefined disables query)
* @returns Query result with work order data
*/
export function useWorkOrder(id: string | undefined): UseQueryResult<AgentWorkOrder, Error> {
const refetchInterval = useSmartPolling({
baseInterval: 3000,
enabled: true,
});
return useQuery({
queryKey: id ? agentWorkOrderKeys.detail(id) : DISABLED_QUERY_KEY,
queryFn: () => (id ? agentWorkOrdersService.getWorkOrder(id) : Promise.reject(new Error("No ID provided"))),
enabled: !!id,
staleTime: STALE_TIMES.instant,
refetchInterval: (query) => {
const data = query.state.data as AgentWorkOrder | undefined;
if (data?.status === "running" || data?.status === "pending") {
return refetchInterval;
}
return false;
},
});
}
/**
* Hook to fetch step execution history for a work order with smart polling
* Automatically polls until workflow completes
*
* @param workOrderId - Work order ID (undefined disables query)
* @returns Query result with step history
*/
export function useStepHistory(workOrderId: string | undefined): UseQueryResult<StepHistory, Error> {
const refetchInterval = useSmartPolling({
baseInterval: 3000,
enabled: true,
});
return useQuery({
queryKey: workOrderId ? agentWorkOrderKeys.stepHistory(workOrderId) : DISABLED_QUERY_KEY,
queryFn: () =>
workOrderId ? agentWorkOrdersService.getStepHistory(workOrderId) : Promise.reject(new Error("No ID provided")),
enabled: !!workOrderId,
staleTime: STALE_TIMES.instant,
refetchInterval: (query) => {
const history = query.state.data as StepHistory | undefined;
const lastStep = history?.steps[history.steps.length - 1];
if (lastStep?.step === "create-pr" && lastStep?.success) {
return false;
}
return refetchInterval;
},
});
}
/**
* Hook to create a new agent work order
* Automatically invalidates work order lists on success
*
* @returns Mutation object with mutate function
*/
export function useCreateWorkOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: CreateAgentWorkOrderRequest) => agentWorkOrdersService.createWorkOrder(request),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: agentWorkOrderKeys.lists() });
queryClient.setQueryData(agentWorkOrderKeys.detail(data.agent_work_order_id), data);
},
onError: (error) => {
console.error("Failed to create work order:", error);
},
});
}

View File

@@ -0,0 +1,125 @@
import { useMemo } from "react";
import type { LogEntry } from "../types";
export interface LogStats {
/** Current step being executed */
currentStep: string | null;
/** Current step number (e.g., 2 from "2/5") */
currentStepNumber: number | null;
/** Total steps */
totalSteps: number | null;
/** Progress percentage (0-100) */
progressPct: number | null;
/** Elapsed time in seconds */
elapsedSeconds: number | null;
/** Last activity timestamp */
lastActivity: string | null;
/** Current substep activity description */
currentActivity: string | null;
/** Whether workflow has started */
hasStarted: boolean;
/** Whether workflow has completed */
hasCompleted: boolean;
/** Whether workflow has failed */
hasFailed: boolean;
}
/**
* Extract real-time metrics from log entries
*
* Analyzes logs to derive current execution status, progress, and activity.
* Uses memoization to avoid recomputing on every render.
*/
export function useLogStats(logs: LogEntry[]): LogStats {
return useMemo(() => {
if (logs.length === 0) {
return {
currentStep: null,
currentStepNumber: null,
totalSteps: null,
progressPct: null,
elapsedSeconds: null,
lastActivity: null,
currentActivity: null,
hasStarted: false,
hasCompleted: false,
hasFailed: false,
};
}
// Find most recent log entry
const latestLog = logs[logs.length - 1];
// Find most recent step_started event
let currentStep: string | null = null;
let currentStepNumber: number | null = null;
let totalSteps: number | null = null;
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i];
if (log.event === "step_started" && log.step) {
currentStep = log.step;
currentStepNumber = log.step_number ?? null;
totalSteps = log.total_steps ?? null;
break;
}
}
// Find most recent progress data
let progressPct: number | null = null;
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i];
if (log.progress_pct !== undefined && log.progress_pct !== null) {
progressPct = log.progress_pct;
break;
}
}
// Find most recent elapsed time
let elapsedSeconds: number | null = null;
for (let i = logs.length - 1; i >= 0; i--) {
const log = logs[i];
if (log.elapsed_seconds !== undefined && log.elapsed_seconds !== null) {
elapsedSeconds = log.elapsed_seconds;
break;
}
}
// Current activity is the latest event description
const currentActivity = latestLog.event || null;
// Last activity timestamp
const lastActivity = latestLog.timestamp;
// Check for workflow lifecycle events
const hasStarted = logs.some((log) => log.event === "workflow_started" || log.event === "step_started");
const hasCompleted = logs.some((log) => log.event === "workflow_completed" || log.event === "agent_work_order_completed");
const hasFailed = logs.some(
(log) => log.event === "workflow_failed" || log.event === "agent_work_order_failed" || log.level === "error",
);
return {
currentStep,
currentStepNumber,
totalSteps,
progressPct,
elapsedSeconds,
lastActivity,
currentActivity,
hasStarted,
hasCompleted,
hasFailed,
};
}, [logs]);
}

View File

@@ -0,0 +1,214 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { API_BASE_URL } from "@/config/api";
import type { LogEntry, SSEConnectionState } from "../types";
export interface UseWorkOrderLogsOptions {
/** Work order ID to stream logs for */
workOrderId: string | undefined;
/** Optional log level filter */
levelFilter?: "info" | "warning" | "error" | "debug";
/** Optional step filter */
stepFilter?: string;
/** Whether to enable auto-reconnect on disconnect */
autoReconnect?: boolean;
}
export interface UseWorkOrderLogsReturn {
/** Array of log entries */
logs: LogEntry[];
/** Connection state */
connectionState: SSEConnectionState;
/** Whether currently connected */
isConnected: boolean;
/** Error if connection failed */
error: Error | null;
/** Manually reconnect */
reconnect: () => void;
/** Clear logs */
clearLogs: () => void;
}
const MAX_LOGS = 500; // Limit stored logs to prevent memory issues
const INITIAL_RETRY_DELAY = 1000; // 1 second
const MAX_RETRY_DELAY = 30000; // 30 seconds
/**
* Hook for streaming work order logs via Server-Sent Events (SSE)
*
* Manages EventSource connection lifecycle, handles reconnection with exponential backoff,
* and maintains a real-time log buffer with automatic cleanup.
*/
export function useWorkOrderLogs({
workOrderId,
levelFilter,
stepFilter,
autoReconnect = true,
}: UseWorkOrderLogsOptions): UseWorkOrderLogsReturn {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [connectionState, setConnectionState] = useState<SSEConnectionState>("disconnected");
const [error, setError] = useState<Error | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const retryDelayRef = useRef<number>(INITIAL_RETRY_DELAY);
const reconnectAttemptRef = useRef<number>(0);
/**
* Build SSE endpoint URL with optional query parameters
*/
const buildUrl = useCallback(() => {
if (!workOrderId) return null;
const params = new URLSearchParams();
if (levelFilter) params.append("level", levelFilter);
if (stepFilter) params.append("step", stepFilter);
const queryString = params.toString();
const baseUrl = `${API_BASE_URL}/agent-work-orders/${workOrderId}/logs/stream`;
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}, [workOrderId, levelFilter, stepFilter]);
/**
* Clear logs from state
*/
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
/**
* Connect to SSE endpoint
*/
const connect = useCallback(() => {
const url = buildUrl();
if (!url) return;
// Cleanup existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setConnectionState("connecting");
setError(null);
try {
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setConnectionState("connected");
setError(null);
// Reset retry delay on successful connection
retryDelayRef.current = INITIAL_RETRY_DELAY;
reconnectAttemptRef.current = 0;
};
eventSource.onmessage = (event) => {
try {
const logEntry: LogEntry = JSON.parse(event.data);
setLogs((prevLogs) => {
const newLogs = [...prevLogs, logEntry];
// Keep only the last MAX_LOGS entries
return newLogs.slice(-MAX_LOGS);
});
} catch (err) {
console.error("Failed to parse log entry:", err, event.data);
}
};
eventSource.onerror = () => {
setConnectionState("error");
const errorObj = new Error("SSE connection error");
setError(errorObj);
// Close the connection
eventSource.close();
eventSourceRef.current = null;
// Auto-reconnect with exponential backoff
if (autoReconnect && workOrderId) {
reconnectAttemptRef.current += 1;
const delay = Math.min(retryDelayRef.current * 2 ** (reconnectAttemptRef.current - 1), MAX_RETRY_DELAY);
retryTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
}
};
} catch (err) {
setConnectionState("error");
setError(err instanceof Error ? err : new Error("Failed to create EventSource"));
}
}, [buildUrl, autoReconnect, workOrderId]);
/**
* Manually trigger reconnection
*/
const reconnect = useCallback(() => {
// Cancel any pending retry
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
// Reset retry state
retryDelayRef.current = INITIAL_RETRY_DELAY;
reconnectAttemptRef.current = 0;
connect();
}, [connect]);
/**
* Connect when workOrderId becomes available
*/
useEffect(() => {
if (workOrderId) {
connect();
}
// Cleanup on unmount or when workOrderId changes
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
setConnectionState("disconnected");
};
}, [workOrderId, connect]);
/**
* Reconnect when filters change
*/
useEffect(() => {
if (workOrderId && eventSourceRef.current) {
// Close existing connection and reconnect with new filters
eventSourceRef.current.close();
eventSourceRef.current = null;
connect();
}
}, [workOrderId, connect]);
const isConnected = connectionState === "connected";
return {
logs,
connectionState,
isConnected,
error,
reconnect,
clearLogs,
};
}

View File

@@ -0,0 +1,158 @@
/**
* Tests for Agent Work Orders Service
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as apiClient from "@/features/shared/api/apiClient";
import type { AgentWorkOrder, CreateAgentWorkOrderRequest, StepHistory } from "../../types";
import { agentWorkOrdersService } from "../agentWorkOrdersService";
vi.mock("@/features/shared/api/apiClient", () => ({
callAPIWithETag: vi.fn(),
}));
describe("agentWorkOrdersService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const mockWorkOrder: AgentWorkOrder = {
agent_work_order_id: "wo-123",
repository_url: "https://github.com/test/repo",
sandbox_identifier: "sandbox-abc",
git_branch_name: "feature/test",
agent_session_id: "session-xyz",
sandbox_type: "git_branch",
github_issue_number: null,
status: "running",
current_phase: "planning",
created_at: "2025-01-15T10:00:00Z",
updated_at: "2025-01-15T10:05:00Z",
github_pull_request_url: null,
git_commit_count: 0,
git_files_changed: 0,
error_message: null,
};
describe("createWorkOrder", () => {
it("should create a work order successfully", async () => {
const request: CreateAgentWorkOrderRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch",
user_request: "Add new feature",
};
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockWorkOrder);
const result = await agentWorkOrdersService.createWorkOrder(request);
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders", {
method: "POST",
body: JSON.stringify(request),
});
expect(result).toEqual(mockWorkOrder);
});
it("should throw error on creation failure", async () => {
const request: CreateAgentWorkOrderRequest = {
repository_url: "https://github.com/test/repo",
sandbox_type: "git_branch",
user_request: "Add new feature",
};
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("Creation failed"));
await expect(agentWorkOrdersService.createWorkOrder(request)).rejects.toThrow("Creation failed");
});
});
describe("listWorkOrders", () => {
it("should list all work orders without filter", async () => {
const mockList: AgentWorkOrder[] = [mockWorkOrder];
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockList);
const result = await agentWorkOrdersService.listWorkOrders();
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders");
expect(result).toEqual(mockList);
});
it("should list work orders with status filter", async () => {
const mockList: AgentWorkOrder[] = [mockWorkOrder];
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockList);
const result = await agentWorkOrdersService.listWorkOrders("running");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders?status=running");
expect(result).toEqual(mockList);
});
it("should throw error on list failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("List failed"));
await expect(agentWorkOrdersService.listWorkOrders()).rejects.toThrow("List failed");
});
});
describe("getWorkOrder", () => {
it("should get a work order by ID", async () => {
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockWorkOrder);
const result = await agentWorkOrdersService.getWorkOrder("wo-123");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/wo-123");
expect(result).toEqual(mockWorkOrder);
});
it("should throw error on get failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("Not found"));
await expect(agentWorkOrdersService.getWorkOrder("wo-123")).rejects.toThrow("Not found");
});
});
describe("getStepHistory", () => {
it("should get step history for a work order", async () => {
const mockHistory: StepHistory = {
agent_work_order_id: "wo-123",
steps: [
{
step: "create-branch",
agent_name: "Branch Agent",
success: true,
output: "Branch created",
error_message: null,
duration_seconds: 5,
session_id: "session-1",
timestamp: "2025-01-15T10:00:00Z",
},
{
step: "planning",
agent_name: "Planning Agent",
success: true,
output: "Plan created",
error_message: null,
duration_seconds: 30,
session_id: "session-2",
timestamp: "2025-01-15T10:01:00Z",
},
],
};
vi.mocked(apiClient.callAPIWithETag).mockResolvedValue(mockHistory);
const result = await agentWorkOrdersService.getStepHistory("wo-123");
expect(apiClient.callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/wo-123/steps");
expect(result).toEqual(mockHistory);
});
it("should throw error on step history failure", async () => {
vi.mocked(apiClient.callAPIWithETag).mockRejectedValue(new Error("History failed"));
await expect(agentWorkOrdersService.getStepHistory("wo-123")).rejects.toThrow("History failed");
});
});
});

View File

@@ -0,0 +1,78 @@
/**
* Agent Work Orders API Service
*
* This service handles all API communication for agent work orders.
* It follows the pattern established in projectService.ts
*/
import { callAPIWithETag } from "@/features/shared/api/apiClient";
import type { AgentWorkOrder, AgentWorkOrderStatus, CreateAgentWorkOrderRequest, StepHistory } from "../types";
/**
* Get the base URL for agent work orders API
* Defaults to /api/agent-work-orders (proxy through main server)
* Can be overridden with VITE_AGENT_WORK_ORDERS_URL for direct connection
*/
const getBaseUrl = (): string => {
const directUrl = import.meta.env.VITE_AGENT_WORK_ORDERS_URL;
if (directUrl) {
// Direct URL should include the full path
return `${directUrl}/api/agent-work-orders`;
}
// Default: proxy through main server
return "/api/agent-work-orders";
};
export const agentWorkOrdersService = {
/**
* Create a new agent work order
*
* @param request - The work order creation request
* @returns Promise resolving to the created work order
* @throws Error if creation fails
*/
async createWorkOrder(request: CreateAgentWorkOrderRequest): Promise<AgentWorkOrder> {
const baseUrl = getBaseUrl();
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/`, {
method: "POST",
body: JSON.stringify(request),
});
},
/**
* List all agent work orders, optionally filtered by status
*
* @param statusFilter - Optional status to filter by
* @returns Promise resolving to array of work orders
* @throws Error if request fails
*/
async listWorkOrders(statusFilter?: AgentWorkOrderStatus): Promise<AgentWorkOrder[]> {
const baseUrl = getBaseUrl();
const params = statusFilter ? `?status=${statusFilter}` : "";
return await callAPIWithETag<AgentWorkOrder[]>(`${baseUrl}/${params}`);
},
/**
* Get a single agent work order by ID
*
* @param id - The work order ID
* @returns Promise resolving to the work order
* @throws Error if work order not found or request fails
*/
async getWorkOrder(id: string): Promise<AgentWorkOrder> {
const baseUrl = getBaseUrl();
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/${id}`);
},
/**
* Get the complete step execution history for a work order
*
* @param id - The work order ID
* @returns Promise resolving to the step history
* @throws Error if work order not found or request fails
*/
async getStepHistory(id: string): Promise<StepHistory> {
const baseUrl = getBaseUrl();
return await callAPIWithETag<StepHistory>(`${baseUrl}/${id}/steps`);
},
};

View File

@@ -0,0 +1,192 @@
/**
* Agent Work Orders Type Definitions
*
* This module defines TypeScript interfaces and types for the Agent Work Orders feature.
* These types mirror the backend models from python/src/agent_work_orders/models.py
*/
/**
* Status of an agent work order
* - pending: Work order created but not started
* - running: Work order is currently executing
* - completed: Work order finished successfully
* - failed: Work order encountered an error
*/
export type AgentWorkOrderStatus = "pending" | "running" | "completed" | "failed";
/**
* Available workflow steps for agent work orders
* Each step represents a command that can be executed
*/
export type WorkflowStep = "create-branch" | "planning" | "execute" | "commit" | "create-pr" | "prp-review";
/**
* Type of git sandbox for work order execution
* - git_branch: Uses standard git branches
* - git_worktree: Uses git worktree for isolation
*/
export type SandboxType = "git_branch" | "git_worktree";
/**
* Agent Work Order entity
* Represents a complete AI-driven development workflow
*/
export interface AgentWorkOrder {
/** Unique identifier for the work order */
agent_work_order_id: string;
/** URL of the git repository to work on */
repository_url: string;
/** Unique identifier for the sandbox instance */
sandbox_identifier: string;
/** Name of the git branch created for this work order (null if not yet created) */
git_branch_name: string | null;
/** ID of the agent session executing this work order (null if not started) */
agent_session_id: string | null;
/** Type of sandbox being used */
sandbox_type: SandboxType;
/** GitHub issue number associated with this work order (optional) */
github_issue_number: string | null;
/** Current status of the work order */
status: AgentWorkOrderStatus;
/** Current workflow phase/step being executed (null if not started) */
current_phase: string | null;
/** Timestamp when work order was created */
created_at: string;
/** Timestamp when work order was last updated */
updated_at: string;
/** URL of the created pull request (null if not yet created) */
github_pull_request_url: string | null;
/** Number of commits made during execution */
git_commit_count: number;
/** Number of files changed during execution */
git_files_changed: number;
/** Error message if work order failed (null if successful or still running) */
error_message: string | null;
}
/**
* Request payload for creating a new agent work order
*/
export interface CreateAgentWorkOrderRequest {
/** URL of the git repository to work on */
repository_url: string;
/** Type of sandbox to use for execution */
sandbox_type: SandboxType;
/** User's natural language request describing the work to be done */
user_request: string;
/** Optional array of specific commands to execute (defaults to all if not provided) */
selected_commands?: WorkflowStep[];
/** Optional GitHub issue number to associate with this work order */
github_issue_number?: string | null;
}
/**
* Result of a single step execution within a workflow
*/
export interface StepExecutionResult {
/** The workflow step that was executed */
step: WorkflowStep;
/** Name of the agent that executed this step */
agent_name: string;
/** Whether the step completed successfully */
success: boolean;
/** Output/result from the step execution (null if no output) */
output: string | null;
/** Error message if step failed (null if successful) */
error_message: string | null;
/** How long the step took to execute (in seconds) */
duration_seconds: number;
/** Agent session ID for this step execution (null if not tracked) */
session_id: string | null;
/** Timestamp when step was executed */
timestamp: string;
}
/**
* Complete history of all steps executed for a work order
*/
export interface StepHistory {
/** The work order ID this history belongs to */
agent_work_order_id: string;
/** Array of all executed steps in chronological order */
steps: StepExecutionResult[];
}
/**
* Log entry from SSE stream
* Structured log event from work order execution
*/
export interface LogEntry {
/** Work order ID this log belongs to */
work_order_id: string;
/** Log level (info, warning, error, debug) */
level: "info" | "warning" | "error" | "debug";
/** Event name describing what happened */
event: string;
/** ISO timestamp when log was created */
timestamp: string;
/** Optional step name if log is associated with a step */
step?: WorkflowStep;
/** Optional step number (e.g., 2 for "2/5") */
step_number?: number;
/** Optional total steps (e.g., 5 for "2/5") */
total_steps?: number;
/** Optional progress string (e.g., "2/5") */
progress?: string;
/** Optional progress percentage (e.g., 40) */
progress_pct?: number;
/** Optional elapsed seconds */
elapsed_seconds?: number;
/** Optional error message */
error?: string;
/** Optional output/result */
output?: string;
/** Optional duration */
duration_seconds?: number;
/** Any additional structured fields from backend */
[key: string]: unknown;
}
/**
* Connection state for SSE stream
*/
export type SSEConnectionState = "connecting" | "connected" | "disconnected" | "error";

View File

@@ -0,0 +1,45 @@
/**
* AgentWorkOrdersView Component
*
* Main view for displaying and managing agent work orders.
* Combines the work order list with create dialog.
*/
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/features/ui/primitives/button";
import { CreateWorkOrderDialog } from "../components/CreateWorkOrderDialog";
import { WorkOrderList } from "../components/WorkOrderList";
export function AgentWorkOrdersView() {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const navigate = useNavigate();
const handleWorkOrderClick = (workOrderId: string) => {
navigate(`/agent-work-orders/${workOrderId}`);
};
const handleCreateSuccess = (workOrderId: string) => {
navigate(`/agent-work-orders/${workOrderId}`);
};
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Agent Work Orders</h1>
<p className="text-gray-400">Create and monitor AI-driven development workflows</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>Create Work Order</Button>
</div>
<WorkOrderList onWorkOrderClick={handleWorkOrderClick} />
<CreateWorkOrderDialog
open={isCreateDialogOpen}
onClose={() => setIsCreateDialogOpen(false)}
onSuccess={handleCreateSuccess}
/>
</div>
);
}

View File

@@ -0,0 +1,200 @@
/**
* WorkOrderDetailView Component
*
* Detailed view of a single agent work order showing progress, step history,
* and full metadata.
*/
import { formatDistanceToNow, parseISO } from "date-fns";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "@/features/ui/primitives/button";
import { StepHistoryTimeline } from "../components/StepHistoryTimeline";
import { WorkOrderProgressBar } from "../components/WorkOrderProgressBar";
import { RealTimeStats } from "../components/RealTimeStats";
import { WorkOrderLogsPanel } from "../components/WorkOrderLogsPanel";
import { useStepHistory, useWorkOrder } from "../hooks/useAgentWorkOrderQueries";
export function WorkOrderDetailView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: workOrder, isLoading: isLoadingWorkOrder, isError: isErrorWorkOrder } = useWorkOrder(id);
const { data: stepHistory, isLoading: isLoadingSteps, isError: isErrorSteps } = useStepHistory(id);
if (isLoadingWorkOrder || isLoadingSteps) {
return (
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-800 rounded w-1/3" />
<div className="h-40 bg-gray-800 rounded" />
<div className="h-60 bg-gray-800 rounded" />
</div>
</div>
);
}
if (isErrorWorkOrder || isErrorSteps || !workOrder || !stepHistory) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<p className="text-red-400 mb-4">Failed to load work order</p>
<Button onClick={() => navigate("/agent-work-orders")}>Back to List</Button>
</div>
</div>
);
}
// Extract repository name from URL with fallback
const repoName = workOrder.repository_url
? workOrder.repository_url.split("/").slice(-2).join("/")
: "Unknown Repository";
// Safely handle potentially invalid dates
// Backend returns UTC timestamps without 'Z' suffix, so we add it to ensure correct parsing
const timeAgo = workOrder.created_at
? formatDistanceToNow(parseISO(workOrder.created_at.endsWith('Z') ? workOrder.created_at : `${workOrder.created_at}Z`), {
addSuffix: true,
})
: "Unknown";
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate("/agent-work-orders")} className="mb-4">
Back to List
</Button>
<h1 className="text-3xl font-bold text-white mb-2">{repoName}</h1>
<p className="text-gray-400">Created {timeAgo}</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
{/* Real-Time Stats Panel */}
<RealTimeStats workOrderId={id} />
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Workflow Progress</h2>
<WorkOrderProgressBar steps={stepHistory.steps} currentPhase={workOrder.current_phase} />
</div>
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Step History</h2>
<StepHistoryTimeline steps={stepHistory.steps} currentPhase={workOrder.current_phase} />
</div>
{/* Real-Time Logs Panel */}
<WorkOrderLogsPanel workOrderId={id} />
</div>
<div className="space-y-6">
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Details</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-400">Status</p>
<p
className={`text-lg font-semibold ${
workOrder.status === "completed"
? "text-green-400"
: workOrder.status === "failed"
? "text-red-400"
: workOrder.status === "running"
? "text-blue-400"
: "text-gray-400"
}`}
>
{workOrder.status.charAt(0).toUpperCase() + workOrder.status.slice(1)}
</p>
</div>
<div>
<p className="text-sm text-gray-400">Sandbox Type</p>
<p className="text-white">{workOrder.sandbox_type}</p>
</div>
<div>
<p className="text-sm text-gray-400">Repository</p>
<a
href={workOrder.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline break-all"
>
{workOrder.repository_url}
</a>
</div>
{workOrder.git_branch_name && (
<div>
<p className="text-sm text-gray-400">Branch</p>
<p className="text-white font-mono text-sm">{workOrder.git_branch_name}</p>
</div>
)}
{workOrder.github_pull_request_url && (
<div>
<p className="text-sm text-gray-400">Pull Request</p>
<a
href={workOrder.github_pull_request_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline break-all"
>
View PR
</a>
</div>
)}
{workOrder.github_issue_number && (
<div>
<p className="text-sm text-gray-400">GitHub Issue</p>
<p className="text-white">#{workOrder.github_issue_number}</p>
</div>
)}
<div>
<p className="text-sm text-gray-400">Work Order ID</p>
<p className="text-white font-mono text-xs break-all">{workOrder.agent_work_order_id}</p>
</div>
{workOrder.agent_session_id && (
<div>
<p className="text-sm text-gray-400">Session ID</p>
<p className="text-white font-mono text-xs break-all">{workOrder.agent_session_id}</p>
</div>
)}
</div>
</div>
{workOrder.error_message && (
<div className="bg-red-900 bg-opacity-30 border border-red-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-red-300 mb-4">Error</h2>
<p className="text-sm text-red-300 font-mono whitespace-pre-wrap">{workOrder.error_message}</p>
</div>
)}
<div className="bg-gray-800 bg-opacity-50 backdrop-blur-sm border border-gray-700 rounded-lg p-6">
<h2 className="text-xl font-semibold text-white mb-4">Statistics</h2>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-400">Commits</p>
<p className="text-white text-lg font-semibold">{workOrder.git_commit_count}</p>
</div>
<div>
<p className="text-sm text-gray-400">Files Changed</p>
<p className="text-white text-lg font-semibold">{workOrder.git_files_changed}</p>
</div>
<div>
<p className="text-sm text-gray-400">Steps Completed</p>
<p className="text-white text-lg font-semibold">
{stepHistory.steps.filter((s) => s.success).length} / {stepHistory.steps.length}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -150,7 +150,7 @@ export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
"focus:ring-1 focus:ring-cyan-400 px-2 py-1",
)}
/>
{description && description.trim() && (
{description?.trim() && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Info
@@ -183,7 +183,7 @@ export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
{title}
</h3>
</SimpleTooltip>
{description && description.trim() && (
{description?.trim() && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Info

View File

@@ -67,17 +67,17 @@ export const LevelSelector: React.FC<LevelSelectorProps> = ({ value, onValueChan
Crawl Depth
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-gray-400 hover:text-cyan-500 transition-colors cursor-help"
aria-label="Show crawl depth level details"
>
<Info className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{tooltipContent}</TooltipContent>
</Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-gray-400 hover:text-cyan-500 transition-colors cursor-help"
aria-label="Show crawl depth level details"
>
<Info className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{tooltipContent}</TooltipContent>
</Tooltip>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
Higher levels crawl deeper into the website structure

View File

@@ -41,10 +41,7 @@ export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCo
try {
// Escape HTML entities FIRST per Prism documentation requirement
// Prism expects pre-escaped input to prevent XSS
const escaped = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escaped = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const lang = language?.toLowerCase() || "javascript";
const grammar = Prism.languages[lang] || Prism.languages.javascript;

View File

@@ -36,7 +36,7 @@ export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
useEffect(() => {
setViewMode(initialTab);
setSelectedItem(null); // Clear selected item when switching tabs
}, [item.source_id, initialTab]);
}, [initialTab]);
// Use pagination hook for current view mode
const paginationData = useInspectorPagination({

View File

@@ -155,7 +155,7 @@ export function usePaginatedInspectorData({
useEffect(() => {
resetDocs();
resetCode();
}, [sourceId, enabled, resetDocs, resetCode]);
}, [resetDocs, resetCode]);
return {
documents: {

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ActiveOperationsResponse, ProgressResponse } from "../../types";

View File

@@ -45,7 +45,7 @@ export function useOperationProgress(
hasCalledComplete.current = false;
hasCalledError.current = false;
consecutiveNotFound.current = 0;
}, [progressId]);
}, []);
const query = useQuery<ProgressResponse | null>({
queryKey: progressId ? progressKeys.detail(progressId) : DISABLED_QUERY_KEY,
@@ -240,12 +240,12 @@ export function useMultipleOperations(
// Reset tracking sets when progress IDs change
// Use sorted JSON stringification for stable dependency that handles reordering
const progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]);
const _progressIdsKey = useMemo(() => JSON.stringify([...progressIds].sort()), [progressIds]);
useEffect(() => {
completedIds.current.clear();
errorIds.current.clear();
notFoundCounts.current.clear();
}, [progressIdsKey]); // Stable dependency across reorderings
}, []); // Stable dependency across reorderings
const queries = useQueries({
queries: progressIds.map((progressId) => ({

View File

@@ -51,7 +51,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
)}
>
{/* Main content area with padding */}
<div className="flex-1 p-4 pb-2">
{/* Title section */}

View File

@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { LayoutGrid, List, Plus, Search, X } from "lucide-react";
import type React from "react";
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { Button } from "../../ui/primitives/button";
import { Input } from "../../ui/primitives/input";
import { cn } from "../../ui/primitives/styles";

View File

@@ -55,7 +55,7 @@ export const DocsTab = ({ project }: DocsTabProps) => {
await createDocumentMutation.mutateAsync({
title,
document_type,
content: { markdown: "# " + title + "\n\nStart writing your document here..." },
content: { markdown: `# ${title}\n\nStart writing your document here...` },
// NOTE: Archon does not have user authentication - this is a single-user local app.
// "User" is a constant representing the sole user of this Archon instance.
author: "User",
@@ -94,7 +94,7 @@ export const DocsTab = ({ project }: DocsTabProps) => {
setShowAddModal(false);
setShowDeleteModal(false);
setDocumentToDelete(null);
}, [projectId]);
}, []);
// Auto-select first document when documents load
useEffect(() => {

View File

@@ -52,13 +52,7 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
setError(null);
onOpenChange(false);
} catch (err) {
setError(
typeof err === "string"
? err
: err instanceof Error
? err.message
: "Failed to create document"
);
setError(typeof err === "string" ? err : err instanceof Error ? err.message : "Failed to create document");
} finally {
setIsAdding(false);
}
@@ -81,7 +75,10 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
)}
<div>
<label htmlFor="document-title" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label
htmlFor="document-title"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Document Title
</label>
<Input
@@ -96,7 +93,10 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
</div>
<div>
<label htmlFor="document-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<label
htmlFor="document-type"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Document Type
</label>
<Select value={type} onValueChange={setType} disabled={isAdding}>
@@ -104,11 +104,21 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
<SelectValue placeholder="Select a document type" />
</SelectTrigger>
<SelectContent color="cyan">
<SelectItem value="spec" color="cyan">Specification</SelectItem>
<SelectItem value="api" color="cyan">API Documentation</SelectItem>
<SelectItem value="guide" color="cyan">Guide</SelectItem>
<SelectItem value="note" color="cyan">Note</SelectItem>
<SelectItem value="design" color="cyan">Design</SelectItem>
<SelectItem value="spec" color="cyan">
Specification
</SelectItem>
<SelectItem value="api" color="cyan">
API Documentation
</SelectItem>
<SelectItem value="guide" color="cyan">
Guide
</SelectItem>
<SelectItem value="note" color="cyan">
Note
</SelectItem>
<SelectItem value="design" color="cyan">
Design
</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -118,7 +118,7 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
aria-label={`${isActive ? "Selected: " : ""}${document.title}`}
className={cn("relative w-full cursor-pointer transition-all duration-300 group", isActive && "scale-[1.02]")}
>
<div>
<div>
{/* Document Type Badge */}
<div
className={cn(
@@ -177,7 +177,7 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
)}
</div>
</div>
</Card>
);
});

View File

@@ -60,11 +60,8 @@ export const documentService = {
* Delete a document
*/
async deleteDocument(projectId: string, documentId: string): Promise<void> {
await callAPIWithETag<{ success: boolean; message: string }>(
`/api/projects/${projectId}/docs/${documentId}`,
{
method: "DELETE",
},
);
await callAPIWithETag<{ success: boolean; message: string }>(`/api/projects/${projectId}/docs/${documentId}`, {
method: "DELETE",
});
},
};

View File

@@ -3,7 +3,7 @@ import { useRef } from "react";
import { useDrop } from "react-dnd";
import { cn } from "../../../ui/primitives/styles";
import type { Task } from "../types";
import { getColumnColor, getColumnGlow, ItemTypes } from "../utils/task-styles";
import { getColumnGlow, ItemTypes } from "../utils/task-styles";
import { TaskCard } from "./TaskCard";
interface KanbanColumnProps {
@@ -90,7 +90,7 @@ export const KanbanColumn = ({
<div
className={cn(
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border backdrop-blur-md",
statusInfo.color
statusInfo.color,
)}
>
{statusInfo.icon}

View File

@@ -3,7 +3,7 @@ import { renderHook, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Task } from "../../types";
import { taskKeys, useCreateTask, useProjectTasks, useTaskCounts } from "../useTaskQueries";
import { taskKeys, useCreateTask, useProjectTasks } from "../useTaskQueries";
// Mock the services
vi.mock("../../services", () => ({

View File

@@ -1,13 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { Activity, CheckCircle2, FileText, LayoutGrid, List, ListTodo, Pin } from "lucide-react";
import { Activity, CheckCircle2, FileText, List, ListTodo, Pin } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
import { isOptimistic } from "../../shared/utils/optimistic";
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { Button, PillNavigation, SelectableCard } from "../../ui/primitives";
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
import { StatPill } from "../../ui/primitives/pill";
import { cn } from "../../ui/primitives/styles";
import { NewProjectModal } from "../components/NewProjectModal";
@@ -71,7 +71,7 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
const sortedProjects = useMemo(() => {
// Filter by search query
const filtered = (projects as Project[]).filter((project) =>
project.title.toLowerCase().includes(searchQuery.toLowerCase())
project.title.toLowerCase().includes(searchQuery.toLowerCase()),
);
// Sort: pinned first, then alphabetically

View File

@@ -42,11 +42,18 @@ function buildFullUrl(cleanEndpoint: string): string {
*/
export async function callAPIWithETag<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
try {
// Clean endpoint
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
// Handle absolute URLs (direct service connections)
const isAbsoluteUrl = endpoint.startsWith("http://") || endpoint.startsWith("https://");
// Construct the full URL
const fullUrl = buildFullUrl(cleanEndpoint);
let fullUrl: string;
if (isAbsoluteUrl) {
// Use absolute URL as-is (for direct service connections)
fullUrl = endpoint;
} else {
// Clean endpoint and build relative URL
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
fullUrl = buildFullUrl(cleanEndpoint);
}
// Build headers - only set Content-Type for requests with a body
// NOTE: We do NOT add If-None-Match headers; the browser handles ETag revalidation automatically
@@ -60,7 +67,7 @@ export async function callAPIWithETag<T = unknown>(endpoint: string, options: Re
// Only set Content-Type for requests that have a body (POST, PUT, PATCH, etc.)
// GET and DELETE requests should not have Content-Type header
const method = options.method?.toUpperCase() || "GET";
const _method = options.method?.toUpperCase() || "GET";
const hasBody = options.body !== undefined && options.body !== null;
if (hasBody && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";

View File

@@ -164,7 +164,7 @@ export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
const highlightedElement = optionsRef.current.querySelector('[data-highlighted="true"]');
highlightedElement?.scrollIntoView({ block: "nearest" });
}
}, [highlightedIndex, open]);
}, [open]);
return (
<Popover.Root open={open} onOpenChange={setOpen}>

View File

@@ -0,0 +1,14 @@
/**
* AgentWorkOrderDetailPage Component
*
* Route wrapper for the agent work order detail view.
* Delegates to WorkOrderDetailView for actual implementation.
*/
import { WorkOrderDetailView } from "@/features/agent-work-orders/views/WorkOrderDetailView";
function AgentWorkOrderDetailPage() {
return <WorkOrderDetailView />;
}
export { AgentWorkOrderDetailPage };

View File

@@ -0,0 +1,14 @@
/**
* AgentWorkOrdersPage Component
*
* Route wrapper for the agent work orders feature.
* Delegates to AgentWorkOrdersView for actual implementation.
*/
import { AgentWorkOrdersView } from "@/features/agent-work-orders/views/AgentWorkOrdersView";
function AgentWorkOrdersPage() {
return <AgentWorkOrdersView />;
}
export { AgentWorkOrdersPage };

View File

@@ -295,6 +295,23 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
return [...new Set([...defaultHosts, ...hostFromEnv, ...customHosts])];
})(),
proxy: {
// Agent Work Orders API proxy (must come before general /api)
'/api/agent-work-orders': {
target: isDocker ? 'http://archon-agent-work-orders:8053' : 'http://localhost:8053',
changeOrigin: true,
secure: false,
configure: (proxy, options) => {
const targetUrl = isDocker ? 'http://archon-agent-work-orders:8053' : 'http://localhost:8053';
proxy.on('error', (err, req, res) => {
console.log('🚨 [VITE PROXY ERROR - Agent Work Orders]:', err.message);
console.log('🚨 [VITE PROXY ERROR] Target:', targetUrl);
console.log('🚨 [VITE PROXY ERROR] Request:', req.url);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
console.log('🔄 [VITE PROXY - Agent Work Orders] Forwarding:', req.method, req.url, 'to', `${targetUrl}${req.url}`);
});
}
},
'/api': {
target: `http://${proxyHost}:${port}`,
changeOrigin: true,

View File

@@ -27,6 +27,7 @@ services:
- ARCHON_SERVER_PORT=${ARCHON_SERVER_PORT:-8181}
- ARCHON_MCP_PORT=${ARCHON_MCP_PORT:-8051}
- ARCHON_AGENTS_PORT=${ARCHON_AGENTS_PORT:-8052}
- AGENT_WORK_ORDERS_PORT=${AGENT_WORK_ORDERS_PORT:-8053}
- AGENTS_ENABLED=${AGENTS_ENABLED:-false}
- ARCHON_HOST=${HOST:-localhost}
networks:
@@ -146,6 +147,57 @@ services:
retries: 3
start_period: 40s
# Agent Work Orders Service (Independent microservice for workflow execution)
archon-agent-work-orders:
profiles:
- work-orders # Only starts when explicitly using --profile work-orders
build:
context: ./python
dockerfile: Dockerfile.agent-work-orders
args:
BUILDKIT_INLINE_CACHE: 1
AGENT_WORK_ORDERS_PORT: ${AGENT_WORK_ORDERS_PORT:-8053}
container_name: archon-agent-work-orders
depends_on:
- archon-server
ports:
- "${AGENT_WORK_ORDERS_PORT:-8053}:${AGENT_WORK_ORDERS_PORT:-8053}"
environment:
- ENABLE_AGENT_WORK_ORDERS=true
- SERVICE_DISCOVERY_MODE=docker_compose
- STATE_STORAGE_TYPE=supabase
- ARCHON_SERVER_URL=http://archon-server:${ARCHON_SERVER_PORT:-8181}
- ARCHON_MCP_URL=http://archon-mcp:${ARCHON_MCP_PORT:-8051}
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- LOGFIRE_TOKEN=${LOGFIRE_TOKEN:-}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- AGENT_WORK_ORDERS_PORT=${AGENT_WORK_ORDERS_PORT:-8053}
- CLAUDE_CLI_PATH=${CLAUDE_CLI_PATH:-claude}
- GH_CLI_PATH=${GH_CLI_PATH:-gh}
- GH_TOKEN=${GITHUB_PAT_TOKEN}
networks:
- app-network
volumes:
- ./python/src/agent_work_orders:/app/src/agent_work_orders # Hot reload for agent work orders
- /tmp/agent-work-orders:/tmp/agent-work-orders # Temp files
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test:
[
"CMD",
"python",
"-c",
'import urllib.request; urllib.request.urlopen("http://localhost:${AGENT_WORK_ORDERS_PORT:-8053}/health")',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Frontend
archon-frontend:
build: ./archon-ui-main

View File

@@ -0,0 +1,135 @@
# Agent Work Orders Database Migrations
This document describes the database migrations for the Agent Work Orders feature.
## Overview
Agent Work Orders is an optional microservice that executes agent-based workflows using Claude Code CLI. These migrations set up the required database tables for the feature.
## Prerequisites
- Supabase project with the same credentials as main Archon server
- `SUPABASE_URL` and `SUPABASE_SERVICE_KEY` environment variables configured
## Migrations
### 1. `agent_work_orders_repositories.sql`
**Purpose**: Configure GitHub repositories for agent work orders
**Creates**:
- `archon_configured_repositories` table for storing repository configurations
- Indexes for fast repository lookups
- RLS policies for access control
- Validation constraints for repository URLs
**When to run**: Before using the repository configuration feature
**Usage**:
```bash
# Open Supabase dashboard → SQL Editor
# Copy and paste the entire migration file
# Execute
```
### 2. `agent_work_orders_state.sql`
**Purpose**: Persistent state management for agent work orders
**Creates**:
- `archon_agent_work_orders` - Main work order state and metadata table
- `archon_agent_work_order_steps` - Step execution history with foreign key constraints
- Indexes for fast queries (status, repository_url, created_at)
- Database triggers for automatic timestamp management
- RLS policies for service and authenticated access
**Features**:
- ACID guarantees for concurrent work order execution
- Foreign key CASCADE delete (steps deleted when work order deleted)
- Hybrid schema (frequently queried columns + JSONB for flexible metadata)
- Automatic `updated_at` timestamp management
**When to run**: To enable Supabase-backed persistent storage for agent work orders
**Usage**:
```bash
# Open Supabase dashboard → SQL Editor
# Copy and paste the entire migration file
# Execute
```
**Verification**:
```sql
-- Check tables exist
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE 'archon_agent_work_order%';
-- Verify indexes
SELECT tablename, indexname FROM pg_indexes
WHERE tablename LIKE 'archon_agent_work_order%'
ORDER BY tablename, indexname;
```
## Configuration
After applying migrations, configure the agent work orders service:
```bash
# Set environment variable
export STATE_STORAGE_TYPE=supabase
# Restart the service
docker compose restart archon-agent-work-orders
# OR
make agent-work-orders
```
## Health Check
Verify the configuration:
```bash
curl http://localhost:8053/health | jq '{storage_type, database}'
```
Expected response:
```json
{
"storage_type": "supabase",
"database": {
"status": "healthy",
"tables_exist": true
}
}
```
## Storage Options
Agent Work Orders supports three storage backends:
1. **Memory** (`STATE_STORAGE_TYPE=memory`) - Default, no persistence
2. **File** (`STATE_STORAGE_TYPE=file`) - Legacy file-based storage
3. **Supabase** (`STATE_STORAGE_TYPE=supabase`) - **Recommended for production**
## Rollback
To remove the agent work orders state tables:
```sql
-- Drop tables (CASCADE will also drop indexes, triggers, and policies)
DROP TABLE IF EXISTS archon_agent_work_order_steps CASCADE;
DROP TABLE IF EXISTS archon_agent_work_orders CASCADE;
```
**Note**: The `update_updated_at_column()` function is shared with other Archon tables and should NOT be dropped.
## Documentation
For detailed setup instructions, see:
- `python/src/agent_work_orders/README.md` - Service configuration guide and migration instructions
## Migration History
- **agent_work_orders_repositories.sql** - Initial repository configuration support
- **agent_work_orders_state.sql** - Supabase persistence migration (replaces file-based storage)

View File

@@ -0,0 +1,233 @@
-- =====================================================
-- Agent Work Orders - Repository Configuration
-- =====================================================
-- This migration creates the archon_configured_repositories table
-- for storing configured GitHub repositories with metadata and preferences
--
-- Features:
-- - Repository URL validation and uniqueness
-- - GitHub metadata storage (display_name, owner, default_branch)
-- - Verification status tracking
-- - Per-repository preferences (sandbox type, workflow commands)
-- - Automatic timestamp management
-- - Row Level Security policies
--
-- Run this in your Supabase SQL Editor
-- =====================================================
-- =====================================================
-- SECTION 1: CREATE TABLE
-- =====================================================
-- Create archon_configured_repositories table
CREATE TABLE IF NOT EXISTS archon_configured_repositories (
-- Primary identification
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Repository identification
repository_url TEXT NOT NULL UNIQUE,
display_name TEXT, -- Extracted from GitHub (e.g., "owner/repo")
owner TEXT, -- Extracted from GitHub
default_branch TEXT, -- Extracted from GitHub (e.g., "main")
-- Verification status
is_verified BOOLEAN DEFAULT false,
last_verified_at TIMESTAMP WITH TIME ZONE,
-- Per-repository preferences
-- Note: default_sandbox_type is intentionally restricted to production-ready types only.
-- Experimental types (git_branch, e2b, dagger) are blocked for safety and stability.
default_sandbox_type TEXT DEFAULT 'git_worktree'
CHECK (default_sandbox_type IN ('git_worktree', 'full_clone', 'tmp_dir')),
default_commands JSONB DEFAULT '["create-branch", "planning", "execute", "commit", "create-pr"]'::jsonb,
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- URL validation constraint
CONSTRAINT valid_repository_url CHECK (
repository_url ~ '^https://github\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+/?$'
)
);
-- =====================================================
-- SECTION 2: CREATE INDEXES
-- =====================================================
-- Unique index on repository_url (enforces constraint)
CREATE UNIQUE INDEX IF NOT EXISTS idx_configured_repositories_url
ON archon_configured_repositories(repository_url);
-- Index on is_verified for filtering verified repositories
CREATE INDEX IF NOT EXISTS idx_configured_repositories_verified
ON archon_configured_repositories(is_verified);
-- Index on created_at for ordering by most recent
CREATE INDEX IF NOT EXISTS idx_configured_repositories_created_at
ON archon_configured_repositories(created_at DESC);
-- GIN index on default_commands JSONB for querying by commands
CREATE INDEX IF NOT EXISTS idx_configured_repositories_commands
ON archon_configured_repositories USING GIN(default_commands);
-- =====================================================
-- SECTION 3: CREATE TRIGGER
-- =====================================================
-- Apply auto-update trigger for updated_at timestamp
-- Reuses existing update_updated_at_column() function from complete_setup.sql
CREATE TRIGGER update_configured_repositories_updated_at
BEFORE UPDATE ON archon_configured_repositories
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =====================================================
-- SECTION 4: ROW LEVEL SECURITY
-- =====================================================
-- Enable Row Level Security on the table
ALTER TABLE archon_configured_repositories ENABLE ROW LEVEL SECURITY;
-- Policy 1: Service role has full access (for API operations)
CREATE POLICY "Allow service role full access to archon_configured_repositories"
ON archon_configured_repositories
FOR ALL
USING (auth.role() = 'service_role');
-- Policy 2: Authenticated users can read and update (for frontend operations)
CREATE POLICY "Allow authenticated users to read and update archon_configured_repositories"
ON archon_configured_repositories
FOR ALL
TO authenticated
USING (true);
-- =====================================================
-- SECTION 5: TABLE COMMENTS
-- =====================================================
-- Add comments to document table structure
COMMENT ON TABLE archon_configured_repositories IS
'Stores configured GitHub repositories for Agent Work Orders with metadata, verification status, and per-repository preferences';
COMMENT ON COLUMN archon_configured_repositories.id IS
'Unique UUID identifier for the configured repository';
COMMENT ON COLUMN archon_configured_repositories.repository_url IS
'GitHub repository URL (must be https://github.com/owner/repo format)';
COMMENT ON COLUMN archon_configured_repositories.display_name IS
'Human-readable repository name extracted from GitHub API (e.g., "owner/repo-name")';
COMMENT ON COLUMN archon_configured_repositories.owner IS
'Repository owner/organization name extracted from GitHub API';
COMMENT ON COLUMN archon_configured_repositories.default_branch IS
'Default branch name extracted from GitHub API (typically "main" or "master")';
COMMENT ON COLUMN archon_configured_repositories.is_verified IS
'Boolean flag indicating if repository access has been verified via GitHub API';
COMMENT ON COLUMN archon_configured_repositories.last_verified_at IS
'Timestamp of last successful repository verification';
COMMENT ON COLUMN archon_configured_repositories.default_sandbox_type IS
'Default sandbox type for work orders: git_worktree (default), full_clone, or tmp_dir.
IMPORTANT: Intentionally restricted to production-ready types only.
Experimental types (git_branch, e2b, dagger) are blocked by CHECK constraint for safety and stability.';
COMMENT ON COLUMN archon_configured_repositories.default_commands IS
'JSONB array of default workflow commands for work orders (e.g., ["create-branch", "planning", "execute", "commit", "create-pr"])';
COMMENT ON COLUMN archon_configured_repositories.created_at IS
'Timestamp when repository configuration was created';
COMMENT ON COLUMN archon_configured_repositories.updated_at IS
'Timestamp when repository configuration was last updated (auto-managed by trigger)';
-- =====================================================
-- SECTION 6: VERIFICATION
-- =====================================================
-- Verify table creation
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_configured_repositories'
) THEN
RAISE NOTICE '✓ Table archon_configured_repositories created successfully';
ELSE
RAISE EXCEPTION '✗ Table archon_configured_repositories was not created';
END IF;
END $$;
-- Verify indexes
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_indexes
WHERE tablename = 'archon_configured_repositories'
) >= 4 THEN
RAISE NOTICE '✓ Indexes created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 4 indexes, found fewer';
END IF;
END $$;
-- Verify trigger
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = 'archon_configured_repositories'::regclass
AND tgname = 'update_configured_repositories_updated_at'
) THEN
RAISE NOTICE '✓ Trigger update_configured_repositories_updated_at created successfully';
ELSE
RAISE EXCEPTION '✗ Trigger update_configured_repositories_updated_at was not created';
END IF;
END $$;
-- Verify RLS policies
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_policies
WHERE tablename = 'archon_configured_repositories'
) >= 2 THEN
RAISE NOTICE '✓ RLS policies created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 RLS policies, found fewer';
END IF;
END $$;
-- =====================================================
-- SECTION 7: ROLLBACK INSTRUCTIONS
-- =====================================================
/*
To rollback this migration, run the following commands:
-- Drop the table (CASCADE will also drop indexes, triggers, and policies)
DROP TABLE IF EXISTS archon_configured_repositories CASCADE;
-- Verify table is dropped
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_configured_repositories';
-- Should return 0 rows
-- Note: The update_updated_at_column() function is shared and should NOT be dropped
*/
-- =====================================================
-- MIGRATION COMPLETE
-- =====================================================
-- The archon_configured_repositories table is now ready for use
-- Next steps:
-- 1. Restart Agent Work Orders service to detect the new table
-- 2. Test repository configuration via API endpoints
-- 3. Verify health endpoint shows table_exists=true
-- =====================================================

View File

@@ -0,0 +1,356 @@
-- =====================================================
-- Agent Work Orders - State Management
-- =====================================================
-- This migration creates tables for agent work order state persistence
-- in PostgreSQL, replacing file-based JSON storage with ACID-compliant
-- database backend.
--
-- Features:
-- - Atomic state updates with ACID guarantees
-- - Row-level locking for concurrent access control
-- - Foreign key constraints for referential integrity
-- - Indexes for fast queries by status, repository, and timestamp
-- - JSONB metadata for flexible storage
-- - Automatic timestamp management via triggers
-- - Step execution history with ordering
--
-- Run this in your Supabase SQL Editor
-- =====================================================
-- =====================================================
-- SECTION 1: CREATE TABLES
-- =====================================================
-- Create archon_agent_work_orders table
CREATE TABLE IF NOT EXISTS archon_agent_work_orders (
-- Primary identification (TEXT not UUID since generated by id_generator.py)
agent_work_order_id TEXT PRIMARY KEY,
-- Core state fields (frequently queried as separate columns)
repository_url TEXT NOT NULL,
sandbox_identifier TEXT NOT NULL,
git_branch_name TEXT,
agent_session_id TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')),
-- Flexible metadata (JSONB for infrequently queried fields)
-- Stores: sandbox_type, github_issue_number, current_phase, error_message, etc.
metadata JSONB DEFAULT '{}'::jsonb,
-- Timestamps (automatically managed)
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create archon_agent_work_order_steps table
-- Stores step execution history with foreign key to work orders
CREATE TABLE IF NOT EXISTS archon_agent_work_order_steps (
-- Primary identification
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Foreign key to work order (CASCADE delete when work order deleted)
agent_work_order_id TEXT NOT NULL REFERENCES archon_agent_work_orders(agent_work_order_id) ON DELETE CASCADE,
-- Step execution details
step TEXT NOT NULL, -- WorkflowStep enum value (e.g., "create-branch", "planning")
agent_name TEXT NOT NULL, -- Name of agent that executed step
success BOOLEAN NOT NULL, -- Whether step succeeded
output TEXT, -- Step output (nullable)
error_message TEXT, -- Error message if failed (nullable)
duration_seconds FLOAT NOT NULL, -- Execution duration
session_id TEXT, -- Agent session ID (nullable)
executed_at TIMESTAMP WITH TIME ZONE NOT NULL, -- When step was executed
step_order INT NOT NULL -- Order within work order (0-indexed for sorting)
);
-- =====================================================
-- SECTION 2: CREATE INDEXES
-- =====================================================
-- Indexes on archon_agent_work_orders for common queries
-- Index on status for filtering by work order status
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_status
ON archon_agent_work_orders(status);
-- Index on created_at for ordering by most recent
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_created_at
ON archon_agent_work_orders(created_at DESC);
-- Index on repository_url for filtering by repository
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_repository
ON archon_agent_work_orders(repository_url);
-- GIN index on metadata JSONB for flexible queries
CREATE INDEX IF NOT EXISTS idx_agent_work_orders_metadata
ON archon_agent_work_orders USING GIN(metadata);
-- Indexes on archon_agent_work_order_steps for step history queries
-- Index on agent_work_order_id for retrieving all steps for a work order
CREATE INDEX IF NOT EXISTS idx_agent_work_order_steps_work_order_id
ON archon_agent_work_order_steps(agent_work_order_id);
-- Index on executed_at for temporal queries
CREATE INDEX IF NOT EXISTS idx_agent_work_order_steps_executed_at
ON archon_agent_work_order_steps(executed_at);
-- =====================================================
-- SECTION 3: CREATE TRIGGER
-- =====================================================
-- Apply auto-update trigger for updated_at timestamp
-- Reuses existing update_updated_at_column() function from Archon migrations
CREATE TRIGGER update_agent_work_orders_updated_at
BEFORE UPDATE ON archon_agent_work_orders
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- =====================================================
-- SECTION 4: ROW LEVEL SECURITY
-- =====================================================
-- Enable Row Level Security on both tables
ALTER TABLE archon_agent_work_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE archon_agent_work_order_steps ENABLE ROW LEVEL SECURITY;
-- Policy 1: Service role has full access (for API operations)
CREATE POLICY "Allow service role full access to archon_agent_work_orders"
ON archon_agent_work_orders
FOR ALL
USING (auth.role() = 'service_role');
CREATE POLICY "Allow service role full access to archon_agent_work_order_steps"
ON archon_agent_work_order_steps
FOR ALL
USING (auth.role() = 'service_role');
-- Policy 2: Authenticated users can read and update (for frontend operations)
CREATE POLICY "Allow authenticated users to read and update archon_agent_work_orders"
ON archon_agent_work_orders
FOR ALL
TO authenticated
USING (true);
CREATE POLICY "Allow authenticated users to read and update archon_agent_work_order_steps"
ON archon_agent_work_order_steps
FOR ALL
TO authenticated
USING (true);
-- =====================================================
-- SECTION 5: TABLE COMMENTS
-- =====================================================
-- Comments on archon_agent_work_orders table
COMMENT ON TABLE archon_agent_work_orders IS
'Stores agent work order state and metadata with ACID guarantees for concurrent access';
COMMENT ON COLUMN archon_agent_work_orders.agent_work_order_id IS
'Unique work order identifier (TEXT format generated by id_generator.py)';
COMMENT ON COLUMN archon_agent_work_orders.repository_url IS
'GitHub repository URL for the work order';
COMMENT ON COLUMN archon_agent_work_orders.sandbox_identifier IS
'Unique identifier for sandbox environment (worktree directory name)';
COMMENT ON COLUMN archon_agent_work_orders.git_branch_name IS
'Git branch name created for work order (nullable if not yet created)';
COMMENT ON COLUMN archon_agent_work_orders.agent_session_id IS
'Agent session ID for tracking agent execution (nullable if not yet started)';
COMMENT ON COLUMN archon_agent_work_orders.status IS
'Current status: pending, running, completed, or failed';
COMMENT ON COLUMN archon_agent_work_orders.metadata IS
'JSONB metadata including sandbox_type, github_issue_number, current_phase, error_message, etc.';
COMMENT ON COLUMN archon_agent_work_orders.created_at IS
'Timestamp when work order was created';
COMMENT ON COLUMN archon_agent_work_orders.updated_at IS
'Timestamp when work order was last updated (auto-managed by trigger)';
-- Comments on archon_agent_work_order_steps table
COMMENT ON TABLE archon_agent_work_order_steps IS
'Stores step execution history for agent work orders with foreign key constraints';
COMMENT ON COLUMN archon_agent_work_order_steps.id IS
'Unique UUID identifier for step record';
COMMENT ON COLUMN archon_agent_work_order_steps.agent_work_order_id IS
'Foreign key to work order (CASCADE delete on work order deletion)';
COMMENT ON COLUMN archon_agent_work_order_steps.step IS
'WorkflowStep enum value (e.g., "create-branch", "planning", "execute")';
COMMENT ON COLUMN archon_agent_work_order_steps.agent_name IS
'Name of agent that executed the step';
COMMENT ON COLUMN archon_agent_work_order_steps.success IS
'Boolean indicating if step execution succeeded';
COMMENT ON COLUMN archon_agent_work_order_steps.output IS
'Step execution output (nullable)';
COMMENT ON COLUMN archon_agent_work_order_steps.error_message IS
'Error message if step failed (nullable)';
COMMENT ON COLUMN archon_agent_work_order_steps.duration_seconds IS
'Step execution duration in seconds';
COMMENT ON COLUMN archon_agent_work_order_steps.session_id IS
'Agent session ID for tracking (nullable)';
COMMENT ON COLUMN archon_agent_work_order_steps.executed_at IS
'Timestamp when step was executed';
COMMENT ON COLUMN archon_agent_work_order_steps.step_order IS
'Order of step within work order (0-indexed for sorting)';
-- =====================================================
-- SECTION 6: VERIFICATION
-- =====================================================
-- Verify archon_agent_work_orders table creation
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_agent_work_orders'
) THEN
RAISE NOTICE '✓ Table archon_agent_work_orders created successfully';
ELSE
RAISE EXCEPTION '✗ Table archon_agent_work_orders was not created';
END IF;
END $$;
-- Verify archon_agent_work_order_steps table creation
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'archon_agent_work_order_steps'
) THEN
RAISE NOTICE '✓ Table archon_agent_work_order_steps created successfully';
ELSE
RAISE EXCEPTION '✗ Table archon_agent_work_order_steps was not created';
END IF;
END $$;
-- Verify indexes on archon_agent_work_orders
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_indexes
WHERE tablename = 'archon_agent_work_orders'
) >= 4 THEN
RAISE NOTICE '✓ Indexes on archon_agent_work_orders created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 4 indexes on archon_agent_work_orders, found fewer';
END IF;
END $$;
-- Verify indexes on archon_agent_work_order_steps
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_indexes
WHERE tablename = 'archon_agent_work_order_steps'
) >= 2 THEN
RAISE NOTICE '✓ Indexes on archon_agent_work_order_steps created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 indexes on archon_agent_work_order_steps, found fewer';
END IF;
END $$;
-- Verify trigger
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgrelid = 'archon_agent_work_orders'::regclass
AND tgname = 'update_agent_work_orders_updated_at'
) THEN
RAISE NOTICE '✓ Trigger update_agent_work_orders_updated_at created successfully';
ELSE
RAISE EXCEPTION '✗ Trigger update_agent_work_orders_updated_at was not created';
END IF;
END $$;
-- Verify RLS policies on archon_agent_work_orders
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_policies
WHERE tablename = 'archon_agent_work_orders'
) >= 2 THEN
RAISE NOTICE '✓ RLS policies on archon_agent_work_orders created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 RLS policies on archon_agent_work_orders, found fewer';
END IF;
END $$;
-- Verify RLS policies on archon_agent_work_order_steps
DO $$
BEGIN
IF (
SELECT COUNT(*) FROM pg_policies
WHERE tablename = 'archon_agent_work_order_steps'
) >= 2 THEN
RAISE NOTICE '✓ RLS policies on archon_agent_work_order_steps created successfully';
ELSE
RAISE WARNING '⚠ Expected at least 2 RLS policies on archon_agent_work_order_steps, found fewer';
END IF;
END $$;
-- Verify foreign key constraint
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'archon_agent_work_order_steps'
AND constraint_type = 'FOREIGN KEY'
) THEN
RAISE NOTICE '✓ Foreign key constraint on archon_agent_work_order_steps created successfully';
ELSE
RAISE EXCEPTION '✗ Foreign key constraint on archon_agent_work_order_steps was not created';
END IF;
END $$;
-- =====================================================
-- SECTION 7: ROLLBACK INSTRUCTIONS
-- =====================================================
/*
To rollback this migration, run the following commands:
-- Drop tables (CASCADE will also drop indexes, triggers, and policies)
DROP TABLE IF EXISTS archon_agent_work_order_steps CASCADE;
DROP TABLE IF EXISTS archon_agent_work_orders CASCADE;
-- Verify tables are dropped
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name LIKE 'archon_agent_work_order%';
-- Should return 0 rows
-- Note: The update_updated_at_column() function is shared and should NOT be dropped
*/
-- =====================================================
-- MIGRATION COMPLETE
-- =====================================================
-- The archon_agent_work_orders and archon_agent_work_order_steps tables
-- are now ready for use.
--
-- Next steps:
-- 1. Set STATE_STORAGE_TYPE=supabase in environment
-- 2. Restart Agent Work Orders service
-- 3. Verify health endpoint shows database status healthy
-- 4. Test work order creation via API
-- =====================================================

View File

@@ -0,0 +1,81 @@
# Create Git Commit
Create an atomic git commit with a properly formatted commit message following best practices for the uncommited changes or these specific files if specified.
Specific files (skip if not specified):
- File 1: $1
- File 2: $2
- File 3: $3
- File 4: $4
- File 5: $5
## Instructions
**Commit Message Format:**
- Use conventional commits: `<type>: <description>`
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
- Present tense (e.g., "add", "fix", "update", not "added", "fixed", "updated")
- 50 characters or less for the subject line
- Lowercase subject line
- No period at the end
- Be specific and descriptive
**Examples:**
- `feat: add web search tool with structured logging`
- `fix: resolve type errors in middleware`
- `test: add unit tests for config module`
- `docs: update CLAUDE.md with testing guidelines`
- `refactor: simplify logging configuration`
- `chore: update dependencies`
**Atomic Commits:**
- One logical change per commit
- If you've made multiple unrelated changes, consider splitting into separate commits
- Commit should be self-contained and not break the build
**IMPORTANT**
- NEVER mention claude code, anthropic, co authored by or anything similar in the commit messages
## Run
1. Review changes: `git diff HEAD`
2. Check status: `git status`
3. Stage changes: `git add -A`
4. Create commit: `git commit -m "<type>: <description>"`
5. Push to remote: `git push -u origin $(git branch --show-current)`
6. Verify push: `git log origin/$(git branch --show-current) -1 --oneline`
## Report
Output in this format (plain text, no markdown):
Commit: <commit-hash>
Branch: <branch-name>
Message: <commit-message>
Pushed: Yes (or No if push failed)
Files: <number> files changed
Then list the files:
- <file1>
- <file2>
- ...
**Example:**
```
Commit: a3c2f1e
Branch: feat/add-user-auth
Message: feat: add user authentication system
Pushed: Yes
Files: 5 files changed
- src/auth/login.py
- src/auth/middleware.py
- tests/auth/test_login.py
- CLAUDE.md
- requirements.txt
```

View File

@@ -0,0 +1,104 @@
# Create Git Branch
Generate a conventional branch name based on user request and create a new git branch.
## Variables
User request: $1
## Instructions
**Step 1: Check Current Branch**
- Check current branch: `git branch --show-current`
- Check if on main/master:
```bash
CURRENT_BRANCH=$(git branch --show-current)
if [[ "$CURRENT_BRANCH" != "main" && "$CURRENT_BRANCH" != "master" ]]; then
echo "Warning: Currently on branch '$CURRENT_BRANCH', not main/master"
echo "Proceeding with branch creation from current branch"
fi
```
- Note: We proceed regardless, but log the warning
**Step 2: Generate Branch Name**
Use conventional branch naming:
**Prefixes:**
- `feat/` - New feature or enhancement
- `fix/` - Bug fix
- `chore/` - Maintenance tasks (dependencies, configs, etc.)
- `docs/` - Documentation only changes
- `refactor/` - Code refactoring (no functionality change)
- `test/` - Adding or updating tests
- `perf/` - Performance improvements
**Naming Rules:**
- Use kebab-case (lowercase with hyphens)
- Be descriptive but concise (max 50 characters)
- Remove special characters except hyphens
- No spaces, use hyphens instead
**Examples:**
- "Add user authentication system" → `feat/add-user-auth`
- "Fix login redirect bug" → `fix/login-redirect`
- "Update README documentation" → `docs/update-readme`
- "Refactor database queries" → `refactor/database-queries`
- "Add unit tests for API" → `test/api-unit-tests`
**Branch Name Generation Logic:**
1. Analyze user request to determine type (feature/fix/chore/docs/refactor/test/perf)
2. Extract key action and subject
3. Convert to kebab-case
4. Truncate if needed to keep under 50 chars
5. Validate name is descriptive and follows conventions
**Step 3: Check Branch Exists**
- Check if branch name already exists:
```bash
if git show-ref --verify --quiet refs/heads/<branch-name>; then
echo "Branch <branch-name> already exists"
# Append version suffix
COUNTER=2
while git show-ref --verify --quiet refs/heads/<branch-name>-v$COUNTER; do
COUNTER=$((COUNTER + 1))
done
BRANCH_NAME="<branch-name>-v$COUNTER"
fi
```
- If exists, append `-v2`, `-v3`, etc. until unique
**Step 4: Create and Checkout Branch**
- Create and checkout new branch: `git checkout -b <branch-name>`
- Verify creation: `git branch --show-current`
- Ensure output matches expected branch name
**Step 5: Verify Branch State**
- Confirm branch created: `git branch --list <branch-name>`
- Confirm currently on branch: `[ "$(git branch --show-current)" = "<branch-name>" ]`
- Check remote tracking: `git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "No upstream set"`
**Important Notes:**
- NEVER mention Claude Code, Anthropic, AI, or co-authoring in any output
- Branch should be created locally only (no push yet)
- Branch will be pushed later by commit.md command
- If user request is unclear, prefer `feat/` prefix as default
## Report
Output ONLY the branch name (no markdown, no explanations, no quotes):
<branch-name>
**Example outputs:**
```
feat/add-user-auth
fix/login-redirect-issue
docs/update-api-documentation
refactor/simplify-middleware
```

View File

@@ -0,0 +1,201 @@
# Create GitHub Pull Request
Create a GitHub pull request for the current branch with auto-generated description.
## Variables
- Branch name: $1
- PRP file path: $2 (optional - may be empty)
## Instructions
**Prerequisites Check:**
1. Verify gh CLI is authenticated:
```bash
gh auth status || {
echo "Error: gh CLI not authenticated. Run: gh auth login"
exit 1
}
```
2. Verify we're in a git repository:
```bash
git rev-parse --git-dir >/dev/null 2>&1 || {
echo "Error: Not in a git repository"
exit 1
}
```
3. Verify changes are pushed to remote:
```bash
BRANCH=$(git branch --show-current)
git rev-parse --verify origin/$BRANCH >/dev/null 2>&1 || {
echo "Error: Branch '$BRANCH' not pushed to remote. Run: git push -u origin $BRANCH"
exit 1
}
```
**Step 1: Gather Information**
1. Get current branch name:
```bash
BRANCH=$(git branch --show-current)
```
2. Get default base branch (usually main or master):
```bash
BASE=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
# Fallback to main if detection fails
[ -z "$BASE" ] && BASE="main"
```
3. Get repository info:
```bash
REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
```
**Step 2: Generate PR Title**
Convert branch name to conventional commit format:
**Rules:**
- `feat/add-user-auth` → `feat: add user authentication`
- `fix/login-bug` → `fix: resolve login bug`
- `docs/update-readme` → `docs: update readme`
- Capitalize first letter after prefix
- Remove hyphens, replace with spaces
- Keep concise (under 72 characters)
**Step 3: Find PR Template**
Look for PR template in these locations (in order):
1. `.github/pull_request_template.md`
2. `.github/PULL_REQUEST_TEMPLATE.md`
3. `.github/PULL_REQUEST_TEMPLATE/pull_request_template.md`
4. `docs/pull_request_template.md`
```bash
PR_TEMPLATE=""
if [ -f ".github/pull_request_template.md" ]; then
PR_TEMPLATE=".github/pull_request_template.md"
elif [ -f ".github/PULL_REQUEST_TEMPLATE.md" ]; then
PR_TEMPLATE=".github/PULL_REQUEST_TEMPLATE.md"
elif [ -f ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md" ]; then
PR_TEMPLATE=".github/PULL_REQUEST_TEMPLATE/pull_request_template.md"
elif [ -f "docs/pull_request_template.md" ]; then
PR_TEMPLATE="docs/pull_request_template.md"
fi
```
**Step 4: Generate PR Body**
**If PR template exists:**
- Read template content
- Fill in placeholders if present
- If PRP file provided: Extract summary and insert into template
**If no PR template (use default):**
```markdown
## Summary
[Brief description of what this PR does]
## Changes
[Bullet list of key changes from git log]
## Implementation Details
[Reference PRP file if provided, otherwise summarize commits]
## Testing
- [ ] All existing tests pass
- [ ] New tests added (if applicable)
- [ ] Manual testing completed
## Related Issues
Closes #[issue number if applicable]
```
**Auto-fill logic:**
1. **Summary section:**
- If PRP file exists: Extract "Feature Description" section
- Otherwise: Use first commit message body
- Fallback: Summarize changes from `git diff --stat`
2. **Changes section:**
- Get commit messages: `git log $BASE..$BRANCH --pretty=format:"- %s"`
- List modified files: `git diff --name-only $BASE...$BRANCH`
- Format as bullet points
3. **Implementation Details:**
- If PRP file exists: Link to it with `See: $PRP_FILE_PATH`
- Extract key technical details from PRP "Solution Statement"
- Otherwise: Summarize from commit messages
4. **Testing section:**
- Check if new test files were added: `git diff --name-only $BASE...$BRANCH | grep test`
- Auto-check test boxes if tests exist
- Include validation results from execute.md if available
**Step 5: Create Pull Request**
```bash
gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--base "$BASE" \
--head "$BRANCH" \
--web
```
**Flags:**
- `--web`: Open PR in browser after creation
- If `--web` not desired, remove it
**Step 6: Capture PR URL**
```bash
PR_URL=$(gh pr view --json url -q .url)
```
**Step 7: Link to Issues (if applicable)**
If PRP file or commits mention issue numbers (#123), link them:
```bash
# Extract issue numbers from commits
ISSUES=$(git log $BASE..$BRANCH --pretty=format:"%s %b" | grep -oP '#\K\d+' | sort -u)
# Link issues to PR
for ISSUE in $ISSUES; do
gh pr comment $PR_URL --body "Relates to #$ISSUE"
done
```
**Important Notes:**
- NEVER mention Claude Code, Anthropic, AI, or co-authoring in PR
- PR title and body should be professional and clear
- Include all relevant context for reviewers
- Link to PRP file in repo if available
- Auto-check completed checkboxes in template
## Report
Output ONLY the PR URL (no markdown, no explanations, no quotes):
https://github.com/owner/repo/pull/123
**Example output:**
```
https://github.com/coleam00/archon/pull/456
```
## Error Handling
If PR creation fails:
- Check if PR already exists for branch: `gh pr list --head $BRANCH`
- If exists: Return existing PR URL
- If other error: Output error message with context

View File

@@ -0,0 +1,27 @@
# Execute PRP Plan
Implement a feature plan from the PRPs directory by following its Step by Step Tasks section.
## Variables
Plan file: $ARGUMENTS
## Instructions
- Read the entire plan file carefully
- Execute **every step** in the "Step by Step Tasks" section in order, top to bottom
- Follow the "Testing Strategy" to create proper unit and integration tests
- Complete all "Validation Commands" at the end
- Ensure all linters pass and all tests pass before finishing
- Follow CLAUDE.md guidelines for type safety, logging, and docstrings
## When done
- Move the PRP file to the completed directory in PRPs/features/completed
## Report
- Summarize completed work in a concise bullet point list
- Show files and lines changed: `git diff --stat`
- Confirm all validation commands passed
- Note any deviations from the plan (if any)

View File

@@ -0,0 +1,176 @@
# NOQA Analysis and Resolution
Find all noqa/type:ignore comments in the codebase, investigate why they exist, and provide recommendations for resolution or justification.
## Instructions
**Step 1: Find all NOQA comments**
- Use Grep tool to find all noqa comments: pattern `noqa|type:\s*ignore`
- Use output_mode "content" with line numbers (-n flag)
- Search across all Python files (type: "py")
- Document total count of noqa comments found
**Step 2: For EACH noqa comment (repeat this process):**
- Read the file containing the noqa comment with sufficient context (at least 10 lines before and after)
- Identify the specific linting rule or type error being suppressed
- Understand the code's purpose and why the suppression was added
- Investigate if the suppression is still necessary or can be resolved
**Step 3: Investigation checklist for each noqa:**
- What specific error/warning is being suppressed? (e.g., `type: ignore[arg-type]`, `noqa: F401`)
- Why was the suppression necessary? (legacy code, false positive, legitimate limitation, technical debt)
- Can the underlying issue be fixed? (refactor code, update types, improve imports)
- What would it take to remove the suppression? (effort estimate, breaking changes, architectural changes)
- Is the suppression justified long-term? (external library limitation, Python limitation, intentional design)
**Step 4: Research solutions:**
- Check if newer versions of tools (mypy, ruff) handle the case better
- Look for alternative code patterns that avoid the suppression
- Consider if type stubs or Protocol definitions could help
- Evaluate if refactoring would be worthwhile
## Report Format
Create a markdown report file (create the reports directory if not created yet): `PRPs/reports/noqa-analysis-{YYYY-MM-DD}.md`
Use this structure for the report:
````markdown
# NOQA Analysis Report
**Generated:** {date}
**Total NOQA comments found:** {count}
---
## Summary
- Total suppressions: {count}
- Can be removed: {count}
- Should remain: {count}
- Requires investigation: {count}
---
## Detailed Analysis
### 1. {File path}:{line number}
**Location:** `{file_path}:{line_number}`
**Suppression:** `{noqa comment or type: ignore}`
**Code context:**
```python
{relevant code snippet}
```
````
**Why it exists:**
{explanation of why the suppression was added}
**Options to resolve:**
1. {Option 1: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
2. {Option 2: description}
- Effort: {Low/Medium/High}
- Breaking: {Yes/No}
- Impact: {description}
**Tradeoffs:**
- {Tradeoff 1}
- {Tradeoff 2}
**Recommendation:** {Remove | Keep | Refactor}
{Justification for recommendation}
---
{Repeat for each noqa comment}
````
## Example Analysis Entry
```markdown
### 1. src/shared/config.py:45
**Location:** `src/shared/config.py:45`
**Suppression:** `# type: ignore[assignment]`
**Code context:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
return key # type: ignore[assignment]
````
**Why it exists:**
MyPy cannot infer that the ValueError prevents None from being returned, so it thinks the return type could be `str | None`.
**Options to resolve:**
1. Use assert to help mypy narrow the type
- Effort: Low
- Breaking: No
- Impact: Cleaner code, removes suppression
2. Add explicit cast with typing.cast()
- Effort: Low
- Breaking: No
- Impact: More verbose but type-safe
3. Refactor to use separate validation method
- Effort: Medium
- Breaking: No
- Impact: Better separation of concerns
**Tradeoffs:**
- Option 1 (assert) is cleanest but asserts can be disabled with -O flag
- Option 2 (cast) is most explicit but adds import and verbosity
- Option 3 is most robust but requires more refactoring
**Recommendation:** Remove (use Option 1)
Replace the type:ignore with an assert statement after the if check. This helps mypy understand the control flow while maintaining runtime safety. The assert will never fail in practice since the ValueError is raised first.
**Implementation:**
```python
@property
def openai_api_key(self) -> str:
key = os.getenv("OPENAI_API_KEY")
if not key:
raise ValueError("OPENAI_API_KEY not set")
assert key is not None # Help mypy understand control flow
return key
```
```
## Report
After completing the analysis:
- Output the path to the generated report file
- Summarize findings:
- Total suppressions found
- How many can be removed immediately (low effort)
- How many should remain (justified)
- How many need deeper investigation or refactoring
- Highlight any quick wins (suppressions that can be removed with minimal effort)
```

View File

@@ -0,0 +1,176 @@
# Feature Planning
Create a new plan to implement the `PRP` using the exact specified markdown `PRP Format`. Follow the `Instructions` to create the plan use the `Relevant Files` to focus on the right files.
## Variables
FEATURE $1 $2
## Instructions
- IMPORTANT: You're writing a plan to implement a net new feature based on the `Feature` that will add value to the application.
- IMPORTANT: The `Feature` describes the feature that will be implemented but remember we're not implementing a new feature, we're creating the plan that will be used to implement the feature based on the `PRP Format` below.
- Create the plan in the `PRPs/features/` directory with filename: `{descriptive-name}.md`
- Replace `{descriptive-name}` with a short, descriptive name based on the feature (e.g., "add-auth-system", "implement-search", "create-dashboard")
- Use the `PRP Format` below to create the plan.
- Deeply research the codebase to understand existing patterns, architecture, and conventions before planning the feature.
- If no patterns are established or are unclear ask the user for clarifications while providing best recommendations and options
- IMPORTANT: Replace every <placeholder> in the `PRP Format` with the requested value. Add as much detail as needed to implement the feature successfully.
- Use your reasoning model: THINK HARD about the feature requirements, design, and implementation approach.
- Follow existing patterns and conventions in the codebase. Don't reinvent the wheel.
- Design for extensibility and maintainability.
- Deeply do web research to understand the latest trends and technologies in the field.
- Figure out latest best practices and library documentation.
- Include links to relevant resources and documentation with anchor tags for easy navigation.
- If you need a new library, use `uv add <package>` and report it in the `Notes` section.
- Read `CLAUDE.md` for project principles, logging rules, testing requirements, and docstring style.
- All code MUST have type annotations (strict mypy enforcement).
- Use Google-style docstrings for all functions, classes, and modules.
- Every new file in `src/` MUST have a corresponding test file in `tests/`.
- Respect requested files in the `Relevant Files` section.
## Relevant Files
Focus on the following files and vertical slice structure:
**Core Files:**
- `CLAUDE.md` - Project instructions, logging rules, testing requirements, docstring style
app/backend core files
app/frontend core files
## PRP Format
```md
# Feature: <feature name>
## Feature Description
<describe the feature in detail, including its purpose and value to users>
## User Story
As a <type of user>
I want to <action/goal>
So that <benefit/value>
## Problem Statement
<clearly define the specific problem or opportunity this feature addresses>
## Solution Statement
<describe the proposed solution approach and how it solves the problem>
## Relevant Files
Use these files to implement the feature:
<find and list the files that are relevant to the feature describe why they are relevant in bullet points. If there are new files that need to be created to implement the feature, list them in an h3 'New Files' section. inlcude line numbers for the relevant sections>
## Relevant research docstring
Use these documentation files and links to help with understanding the technology to use:
- [Documentation Link 1](https://example.com/doc1)
- [Anchor tag]
- [Short summary]
- [Documentation Link 2](https://example.com/doc2)
- [Anchor tag]
- [Short summary]
## Implementation Plan
### Phase 1: Foundation
<describe the foundational work needed before implementing the main feature>
### Phase 2: Core Implementation
<describe the main implementation work for the feature>
### Phase 3: Integration
<describe how the feature will integrate with existing functionality>
## Step by Step Tasks
IMPORTANT: Execute every step in order, top to bottom.
<list step by step tasks as h3 headers plus bullet points. use as many h3 headers as needed to implement the feature. Order matters:
1. Start with foundational shared changes (schemas, types)
2. Implement core functionality with proper logging
3. Create corresponding test files (unit tests mirror src/ structure)
4. Add integration tests if feature interacts with multiple components
5. Verify linters pass: `uv run ruff check src/ && uv run mypy src/`
6. Ensure all tests pass: `uv run pytest tests/`
7. Your last step should be running the `Validation Commands`>
<For tool implementations:
- Define Pydantic schemas in `schemas.py`
- Implement tool with structured logging and type hints
- Register tool with Pydantic AI agent
- Create unit tests in `tests/tools/<name>/test_<module>.py`
- Add integration test in `tests/integration/` if needed>
## Testing Strategy
See `CLAUDE.md` for complete testing requirements. Every file in `src/` must have a corresponding test file in `tests/`.
### Unit Tests
<describe unit tests needed for the feature. Mark with @pytest.mark.unit. Test individual components in isolation.>
### Integration Tests
<if the feature interacts with multiple components, describe integration tests needed. Mark with @pytest.mark.integration. Place in tests/integration/ when testing full application stack.>
### Edge Cases
<list edge cases that need to be tested>
## Acceptance Criteria
<list specific, measurable criteria that must be met for the feature to be considered complete>
## Validation Commands
Execute every command to validate the feature works correctly with zero regressions.
<list commands you'll use to validate with 100% confidence the feature is implemented correctly with zero regressions. Include (example for BE Biome and TS checks are used for FE):
- Linting: `uv run ruff check src/`
- Type checking: `uv run mypy src/`
- Unit tests: `uv run pytest tests/ -m unit -v`
- Integration tests: `uv run pytest tests/ -m integration -v` (if applicable)
- Full test suite: `uv run pytest tests/ -v`
- Manual API testing if needed (curl commands, test requests)>
**Required validation commands:**
- `uv run ruff check src/` - Lint check must pass
- `uv run mypy src/` - Type check must pass
- `uv run pytest tests/ -v` - All tests must pass with zero regressions
**Run server and test core endpoints:**
- Start server: @.claude/start-server
- Test endpoints with curl (at minimum: health check, main functionality)
- Verify structured logs show proper correlation IDs and context
- Stop server after validation
## Notes
<optionally list any additional notes, future considerations, or context that are relevant to the feature that will be helpful to the developer>
```
## Feature
Extract the feature details from the `issue_json` variable (parse the JSON and use the title and body fields).
## Report
- Summarize the work you've just done in a concise bullet point list.
- Include the full path to the plan file you created (e.g., `PRPs/features/add-auth-system.md`)

View File

@@ -0,0 +1,28 @@
# Prime
Execute the following sections to understand the codebase before starting new work, then summarize your understanding.
## Run
- List all tracked files: `git ls-files`
- Show project structure: `tree -I '.venv|__pycache__|*.pyc|.pytest_cache|.mypy_cache|.ruff_cache' -L 3`
## Read
- `CLAUDE.md` - Core project instructions, principles, logging rules, testing requirements
- `python/src/agent_work_orders` - Project overview and setup (if exists)
- Identify core files in the agent work orders directory to understand what we are woerking on and its intent
## Report
Provide a concise summary of:
1. **Project Purpose**: What this application does
2. **Architecture**: Key patterns (vertical slice, FastAPI + Pydantic AI)
3. **Core Principles**: TYPE SAFETY, KISS, YAGNI
4. **Tech Stack**: Main dependencies and tools
5. **Key Requirements**: Logging, testing, type annotations
6. **Current State**: What's implemented
Keep the summary brief (5-10 bullet points) and focused on what you need to know to contribute effectively.

View File

@@ -0,0 +1,89 @@
# Code Review
Review implemented work against a PRP specification to ensure code quality, correctness, and adherence to project standards.
## Variables
Plan file: $ARGUMENTS (e.g., `PRPs/features/add-web-search.md`)
## Instructions
**Understand the Changes:**
- Check current branch: `git branch`
- Review changes: `git diff origin/main` (or `git diff HEAD` if not on a branch)
- Read the PRP plan file to understand requirements
**Code Quality Review:**
- **Type Safety**: Verify all functions have type annotations, mypy passes
- **Logging**: Check structured logging is used correctly (event names, context, exception handling)
- **Docstrings**: Ensure Google-style docstrings on all functions/classes
- **Testing**: Verify unit tests exist for all new files, integration tests if needed
- **Architecture**: Confirm vertical slice structure is followed
- **CLAUDE.md Compliance**: Check adherence to core principles (KISS, YAGNI, TYPE SAFETY)
**Validation Ruff for BE and Biome for FE:**
- Run linters: `uv run ruff check src/ && uv run mypy src/`
- Run tests: `uv run pytest tests/ -v`
- Start server and test endpoints with curl (if applicable)
- Verify structured logs show proper correlation IDs and context
**Issue Severity:**
- `blocker` - Must fix before merge (breaks build, missing tests, type errors, security issues)
- `major` - Should fix (missing logging, incomplete docstrings, poor patterns)
- `minor` - Nice to have (style improvements, optimization opportunities)
## Report
Return ONLY valid JSON (no markdown, no explanations) save to [report-#.json] in prps/reports directory create the directory if it doesn't exist. Output will be parsed with JSON.parse().
### Output Structure
```json
{
"success": "boolean - true if NO BLOCKER issues, false if BLOCKER issues exist",
"review_summary": "string - 2-4 sentences: what was built, does it match spec, quality assessment",
"review_issues": [
{
"issue_number": "number - issue index",
"file_path": "string - file with the issue (if applicable)",
"issue_description": "string - what's wrong",
"issue_resolution": "string - how to fix it",
"severity": "string - blocker|major|minor"
}
],
"validation_results": {
"linting_passed": "boolean",
"type_checking_passed": "boolean",
"tests_passed": "boolean",
"api_endpoints_tested": "boolean - true if endpoints were tested with curl"
}
}
```
## Example Success Review
```json
{
"success": true,
"review_summary": "The web search tool has been implemented with proper type annotations, structured logging, and comprehensive tests. The implementation follows the vertical slice architecture and matches all spec requirements. Code quality is high with proper error handling and documentation.",
"review_issues": [
{
"issue_number": 1,
"file_path": "src/tools/web_search/tool.py",
"issue_description": "Missing debug log for API response",
"issue_resolution": "Add logger.debug with response metadata",
"severity": "minor"
}
],
"validation_results": {
"linting_passed": true,
"type_checking_passed": true,
"tests_passed": true,
"api_endpoints_tested": true
}
}
```

View File

@@ -0,0 +1,33 @@
# Start Servers
Start both the FastAPI backend and React frontend development servers with hot reload.
## Run
### Run in the background with bash tool
- Ensure you are in the right PWD
- Use the Bash tool to run the servers in the background so you can read the shell outputs
- IMPORTANT: run `git ls-files` first so you know where directories are located before you start
### Backend Server (FastAPI)
- Navigate to backend: `cd app/backend`
- Start server in background: `uv sync && uv run python run_api.py`
- Wait 2-3 seconds for startup
- Test health endpoint: `curl http://localhost:8000/health`
- Test products endpoint: `curl http://localhost:8000/api/products`
### Frontend Server (Bun + React)
- Navigate to frontend: `cd ../app/frontend`
- Start server in background: `bun install && bun dev`
- Wait 2-3 seconds for startup
- Frontend should be accessible at `http://localhost:3000`
## Report
- Confirm backend is running on `http://localhost:8000`
- Confirm frontend is running on `http://localhost:3000`
- Show the health check response from backend
- Mention: "Backend logs will show structured JSON logging for all requests"

View File

@@ -0,0 +1,77 @@
# Agent Work Orders Service - Independent microservice for agent execution
FROM python:3.12 AS builder
WORKDIR /build
# Install build dependencies and uv
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Copy pyproject.toml for dependency installation
COPY pyproject.toml .
# Install agent work orders dependencies to a virtual environment using uv
RUN uv venv /venv && \
. /venv/bin/activate && \
uv pip install . --group agent-work-orders
# Runtime stage
FROM python:3.12-slim
WORKDIR /app
# Install runtime dependencies: git, gh CLI, curl
RUN apt-get update && apt-get install -y \
git \
curl \
ca-certificates \
wget \
gnupg \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Copy the virtual environment from builder
COPY --from=builder /venv /venv
# Copy agent work orders source code only (not entire server)
COPY src/agent_work_orders/ src/agent_work_orders/
COPY src/__init__.py src/
# Copy Claude command files for agent work orders
COPY .claude/ .claude/
# Create non-root user for security (Claude CLI blocks --dangerously-skip-permissions with root)
RUN useradd -m -u 1000 -s /bin/bash agentuser && \
chown -R agentuser:agentuser /app /venv
# Create volume mount points for git operations and temp files
RUN mkdir -p /repos /tmp/agent-work-orders && \
chown -R agentuser:agentuser /repos /tmp/agent-work-orders && \
chmod -R 755 /repos /tmp/agent-work-orders
# Install Claude CLI for non-root user
USER agentuser
RUN curl -fsSL https://claude.ai/install.sh | bash
# Set environment variables
ENV PYTHONPATH="/app:$PYTHONPATH"
ENV PYTHONUNBUFFERED=1
ENV PATH="/venv/bin:/home/agentuser/.local/bin:$PATH"
# Expose agent work orders service port
ARG AGENT_WORK_ORDERS_PORT=8053
ENV AGENT_WORK_ORDERS_PORT=${AGENT_WORK_ORDERS_PORT}
EXPOSE ${AGENT_WORK_ORDERS_PORT}
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:${AGENT_WORK_ORDERS_PORT}/health')"
# Run the Agent Work Orders service
CMD python -m uvicorn src.agent_work_orders.server:app --host 0.0.0.0 --port ${AGENT_WORK_ORDERS_PORT}

View File

@@ -13,9 +13,10 @@ RUN apt-get update && apt-get install -y \
COPY pyproject.toml .
# Install server dependencies to a virtual environment using uv
# Install base dependencies (includes structlog) and server groups
RUN uv venv /venv && \
. /venv/bin/activate && \
uv pip install --group server --group server-reranking
uv pip install . --group server --group server-reranking
# Runtime stage
FROM python:3.12-slim
@@ -56,8 +57,9 @@ ENV PATH=/venv/bin:$PATH
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
RUN playwright install chromium
# Copy server code and tests
# Copy server code, agent work orders, and tests
COPY src/server/ src/server/
COPY src/agent_work_orders/ src/agent_work_orders/
COPY src/__init__.py src/
COPY tests/ tests/
@@ -76,4 +78,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD sh -c "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:${ARCHON_SERVER_PORT}/health')\""
# Run the Server service
CMD sh -c "python -m uvicorn src.server.main:socket_app --host 0.0.0.0 --port ${ARCHON_SERVER_PORT} --workers 1"
CMD sh -c "python -m uvicorn src.server.main:app --host 0.0.0.0 --port ${ARCHON_SERVER_PORT} --workers 1"

View File

@@ -86,7 +86,7 @@ mcp = [
"fastapi>=0.104.0",
]
# Agents container dependencies
# Agents container dependencies (ML/reranking service)
agents = [
"pydantic-ai>=0.0.13",
"pydantic>=2.0.0",
@@ -97,6 +97,18 @@ agents = [
"structlog>=23.1.0",
]
# Agent Work Orders container dependencies (workflow orchestration service)
agent-work-orders = [
"fastapi>=0.119.1",
"uvicorn>=0.38.0",
"pydantic>=2.12.3",
"httpx>=0.28.1",
"python-dotenv>=1.1.1",
"structlog>=25.4.0",
"sse-starlette>=2.3.3",
"supabase==2.15.1",
]
# All dependencies for running unit tests locally
# This combines all container dependencies plus test-specific ones
all = [
@@ -124,6 +136,8 @@ all = [
# Agents specific
"pydantic-ai>=0.0.13",
"structlog>=23.1.0",
# Agent Work Orders specific
"sse-starlette>=2.3.3",
# Shared utilities
"httpx>=0.24.0",
"pydantic>=2.0.0",
@@ -178,4 +192,4 @@ check_untyped_defs = true
# Third-party libraries often don't have type stubs
# We'll explicitly type our own code but not fail on external libs
ignore_missing_imports = true
ignore_missing_imports = true

View File

@@ -0,0 +1,168 @@
# AI Agent Development Instructions
## Project Overview
agent_work_orders for claude code cli automation stichting modular workflows together
## Core Principles
1. **TYPE SAFETY IS NON-NEGOTIABLE**
- All functions, methods, and variables MUST have type annotations
- Strict mypy configuration is enforced
- No `Any` types without explicit justification
2. **KISS** (Keep It Simple, Stupid)
- Prefer simple, readable solutions over clever abstractions
3. **YAGNI** (You Aren't Gonna Need It)
- Don't build features until they're actually needed
**Architecture:**
```
src/agent_work_orders
```
Each tool is a vertical slice containing tool.py, schemas.py, service.py.
---
## Documentation Style
**Use Google-style docstrings** for all functions, classes, and modules:
```python
def process_request(user_id: str, query: str) -> dict[str, Any]:
"""Process a user request and return results.
Args:
user_id: Unique identifier for the user.
query: The search query string.
Returns:
Dictionary containing results and metadata.
Raises:
ValueError: If query is empty or invalid.
ProcessingError: If processing fails after retries.
"""
```
---
## Logging Rules
**Philosophy:** Logs are optimized for AI agent consumption. Include enough context for an LLM to understand and fix issues without human intervention.
### Required (MUST)
1. **Import shared logger:** from python/src/agent_work_orders/utils/structured_logger.py
2. **Use appropriate levels:** `debug` (diagnostics), `info` (operations), `warning` (recoverable), `error` (non-fatal), `exception` (in except blocks with stack traces)
3. **Use structured logging:** Always use keyword arguments, never string formatting
```python
logger.info("user_created", user_id="123", role="admin") # ✅
logger.info(f"User {user_id} created") # ❌ NO
```
4. **Descriptive event names:** Use `snake_case` that answers "what happened?"
- Good: `database_connection_established`, `tool_execution_started`, `api_request_completed`
- Bad: `connected`, `done`, `success`
5. **Use logger.exception() in except blocks:** Captures full stack trace automatically
```python
try:
result = await operation()
except ValueError:
logger.exception("operation_failed", expected="int", received=type(value).__name__)
raise
```
6. **Include debugging context:** IDs (user_id, request_id, session_id), input values, expected vs actual, external responses, performance metrics (duration_ms)
### Recommended (SHOULD)
- Log entry/exit for complex operations with relevant metadata
- Log performance metrics for bottlenecks (timing, counts)
- Log state transitions (old_state, new_state)
- Log external system interactions (API calls, database queries, tool executions)
### DO NOT
- **DO NOT log sensitive data:** No passwords, API keys, tokens (mask: `api_key[:8] + "..."`)
- **DO NOT use string formatting:** Always use structured kwargs
- **DO NOT spam logs in loops:** Log batch summaries instead
- **DO NOT silently catch exceptions:** Always log with `logger.exception()` or re-raise
- **DO NOT use vague event names:** Be specific about what happened
### Common Patterns
**Tool execution:**
```python
logger.info("tool_execution_started", tool=name, params=params)
try:
result = await tool.execute(params)
logger.info("tool_execution_completed", tool=name, duration_ms=duration)
except ToolError:
logger.exception("tool_execution_failed", tool=name, retry_count=count)
raise
```
**External API calls:**
```python
logger.info("api_call", provider="openai", endpoint="/v1/chat", status=200,
duration_ms=1245.5, tokens={"prompt": 245, "completion": 128})
```
### Debugging
Logs include: `correlation_id` (links request logs), `source` (file:function:line), `duration_ms` (performance), `exc_type/exc_message` (errors). Use `grep "correlation_id=abc-123"` to trace requests.
---
## Development Workflow
**Run server:** `uv run uvicorn src.main:app --host 0.0.0.0 --port 8030 --reload`
**Lint/check (must pass):** `uv run ruff check src/ && uv run mypy src/`
**Auto-fix:** `uv run ruff check --fix src/`
**Run tests:** `uv run pytest tests/ -v`
---
## Testing
**Tests mirror the source directory structure.** Every file in `src/agent_work_orders` MUST have a corresponding test file.
**Structure:**
**Requirements:**
- **Unit tests:** Test individual components in isolation. Mark with `@pytest.mark.unit`
- **Integration tests:** Test multiple components together. Mark with `@pytest.mark.integration`
- Place integration tests in `tests/integration/` when testing full application stack
**Run tests:** `uv run pytest tests/ -v`
**Run specific types:** `uv run pytest tests/ -m unit` or `uv run pytest tests/ -m integration`
---
---
## AI Agent Notes
When debugging:
- Check `source` field for file/function location
- Use `correlation_id` to trace full request flow
- Look for `duration_ms` to identify bottlenecks
- Exception logs include full stack traces with local variables (dev mode)
- All context is in structured log fields—use them to understand and fix issues

View File

@@ -0,0 +1,429 @@
# Agent Work Orders Service
Independent microservice for executing agent-based workflows using Claude Code CLI.
## Purpose
The Agent Work Orders service is a standalone FastAPI application that:
- Executes Claude Code CLI commands for automated development workflows
- Manages git worktrees for isolated execution environments
- Integrates with GitHub for PR creation and management
- Provides a complete workflow orchestration system with 6 compositional commands
## Architecture
This service runs independently from the main Archon server and can be deployed:
- **Locally**: For development using `uv run`
- **Docker**: As a standalone container
- **Hybrid**: Mix of local and Docker services
### Service Communication
The agent service communicates with:
- **Archon Server** (`http://archon-server:8181` or `http://localhost:8181`)
- **Archon MCP** (`http://archon-mcp:8051` or `http://localhost:8051`)
Service discovery is automatic based on `SERVICE_DISCOVERY_MODE`:
- `local`: Uses localhost URLs
- `docker_compose`: Uses Docker container names
## Running Locally
### Prerequisites
- Python 3.12+
- Claude Code CLI installed (`curl -fsSL https://claude.ai/install.sh | bash`)
- Git and GitHub CLI (`gh`)
- uv package manager
### Quick Start
```bash
# Using make (recommended)
make agent-work-orders
# Or using the provided script
cd python
./scripts/start-agent-service.sh
# Or manually
export SERVICE_DISCOVERY_MODE=local
export ARCHON_SERVER_URL=http://localhost:8181
export ARCHON_MCP_URL=http://localhost:8051
uv run python -m uvicorn src.agent_work_orders.server:app --port 8053 --reload
```
## Running with Docker
### Build and Run
```bash
# Build the Docker image
cd python
docker build -f Dockerfile.agent-work-orders -t archon-agent-work-orders .
# Run the container
docker run -p 8053:8053 \
-e SERVICE_DISCOVERY_MODE=local \
-e ARCHON_SERVER_URL=http://localhost:8181 \
archon-agent-work-orders
```
### Docker Compose
```bash
# Start with agent work orders service profile
docker compose --profile work-orders up -d
# Or include in default services (edit docker-compose.yml to remove profile)
docker compose up -d
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `AGENT_WORK_ORDERS_PORT` | `8053` | Port for agent work orders service |
| `SERVICE_DISCOVERY_MODE` | `local` | Service discovery mode (`local` or `docker_compose`) |
| `ARCHON_SERVER_URL` | Auto | Main server URL (auto-configured by discovery mode) |
| `ARCHON_MCP_URL` | Auto | MCP server URL (auto-configured by discovery mode) |
| `CLAUDE_CLI_PATH` | `claude` | Path to Claude CLI executable |
| `GH_CLI_PATH` | `gh` | Path to GitHub CLI executable |
| `GH_TOKEN` | - | GitHub Personal Access Token for gh CLI authentication (required for PR creation) |
| `LOG_LEVEL` | `INFO` | Logging level |
| `STATE_STORAGE_TYPE` | `memory` | State storage (`memory`, `file`, or `supabase`) - Use `supabase` for production |
| `FILE_STATE_DIRECTORY` | `agent-work-orders-state` | Directory for file-based state (when `STATE_STORAGE_TYPE=file`) |
| `SUPABASE_URL` | - | Supabase project URL (required when `STATE_STORAGE_TYPE=supabase`) |
| `SUPABASE_SERVICE_KEY` | - | Supabase service key (required when `STATE_STORAGE_TYPE=supabase`) |
### State Storage Options
The service supports three state storage backends:
**Memory Storage** (`STATE_STORAGE_TYPE=memory`):
- **Default**: Easiest for development/testing
- **Pros**: No setup required, fast
- **Cons**: State lost on service restart, no persistence
- **Use for**: Local development, unit tests
**File Storage** (`STATE_STORAGE_TYPE=file`):
- **Legacy**: File-based JSON persistence
- **Pros**: Simple, no external dependencies
- **Cons**: No ACID guarantees, race conditions possible, file corruption risk
- **Use for**: Single-instance deployments, backward compatibility
**Supabase Storage** (`STATE_STORAGE_TYPE=supabase`):
- **Recommended for production**: PostgreSQL-backed persistence via Supabase
- **Pros**: ACID guarantees, concurrent access support, foreign key constraints, indexes
- **Cons**: Requires Supabase configuration and credentials
- **Use for**: Production deployments, multi-instance setups
### Supabase Configuration
Agent Work Orders can use Supabase for production-ready persistent state management.
#### Setup Steps
1. **Reuse existing Archon Supabase credentials** - No new database or credentials needed. The agent work orders service shares the same Supabase project as the main Archon server.
2. **Apply database migration**:
- Navigate to your Supabase project dashboard at https://app.supabase.com
- Open SQL Editor
- Copy and paste the migration from `migration/agent_work_orders_state.sql` (in the project root)
- Execute the migration
- See `migration/AGENT_WORK_ORDERS.md` for detailed instructions
3. **Set environment variable**:
```bash
export STATE_STORAGE_TYPE=supabase
```
4. **Verify configuration**:
```bash
# Start the service
make agent-work-orders
# Check health endpoint
curl http://localhost:8053/health | jq
```
Expected response:
```json
{
"status": "healthy",
"storage_type": "supabase",
"database": {
"status": "healthy",
"tables_exist": true
}
}
```
#### Database Tables
When using Supabase storage, two tables are created:
- **`archon_agent_work_orders`**: Main work order state and metadata
- **`archon_agent_work_order_steps`**: Step execution history with foreign key constraints
#### Troubleshooting
**Error: "tables_exist": false**
- Migration not applied - see `database/migrations/README.md`
- Check Supabase dashboard SQL Editor for error messages
**Error: "SUPABASE_URL and SUPABASE_SERVICE_KEY must be set"**
- Environment variables not configured
- Ensure same credentials as main Archon server are set
**Service starts but work orders not persisted**
- Check `STATE_STORAGE_TYPE` is set to `supabase` (case-insensitive)
- Verify health endpoint shows `"storage_type": "supabase"`
### Service Discovery Modes
**Local Mode** (`SERVICE_DISCOVERY_MODE=local`):
- Default for development
- Services on `localhost` with different ports
- Ideal for mixed local/Docker setup
**Docker Compose Mode** (`SERVICE_DISCOVERY_MODE=docker_compose`):
- Automatic in Docker deployments
- Uses container names for service discovery
- All services in same Docker network
## API Endpoints
### Core Endpoints
- `GET /health` - Health check with dependency validation
- `GET /` - Service information
- `GET /docs` - OpenAPI documentation
### Work Order Endpoints
All endpoints under `/api/agent-work-orders`:
- `POST /` - Create new work order
- `GET /` - List all work orders (optional status filter)
- `GET /{id}` - Get specific work order
- `GET /{id}/steps` - Get step execution history
## Development Workflows
### Hybrid (Recommended - Backend in Docker, Agent Work Orders Local)
```bash
# Terminal 1: Start backend in Docker and frontend
make dev-work-orders
# Terminal 2: Start agent work orders service
make agent-work-orders
```
### All Local (3 terminals)
```bash
# Terminal 1: Backend
cd python
uv run python -m uvicorn src.server.main:app --port 8181 --reload
# Terminal 2: Agent Work Orders Service
make agent-work-orders
# Terminal 3: Frontend
cd archon-ui-main
npm run dev
```
### Full Docker
```bash
# All services in Docker
docker compose --profile work-orders up -d
# View agent work orders service logs
docker compose logs -f archon-agent-work-orders
```
## Troubleshooting
### GitHub Authentication (PR Creation Fails)
The `gh` CLI requires authentication for PR creation. There are two options:
**Option 1: PAT Token (Recommended for Docker)**
Set `GH_TOKEN` or `GITHUB_TOKEN` environment variable with your Personal Access Token:
```bash
# In .env file
GITHUB_PAT_TOKEN=ghp_your_token_here
# Docker compose automatically maps GITHUB_PAT_TOKEN to GH_TOKEN
```
The token needs these scopes:
- `repo` (full control of private repositories)
- `workflow` (if creating PRs with workflow files)
**Option 2: gh auth login (Local development only)**
```bash
gh auth login
# Follow interactive prompts
```
### Claude CLI Not Found
```bash
# Install Claude Code CLI
curl -fsSL https://claude.ai/install.sh | bash
# Verify installation
claude --version
```
### Service Connection Errors
Check health endpoint to see dependency status:
```bash
curl http://localhost:8052/health
```
This shows:
- Claude CLI availability
- Git availability
- Archon server connectivity
- MCP server connectivity
### Port Conflicts
If port 8053 is in use:
```bash
# Change port
export AGENT_WORK_ORDERS_PORT=9053
./scripts/start-agent-service.sh
```
### Docker Service Discovery
If services can't reach each other in Docker:
```bash
# Verify network
docker network inspect archon_app-network
# Test connectivity
docker exec archon-agent-work-orders ping archon-server
docker exec archon-agent-work-orders curl http://archon-server:8181/health
```
## Testing
### Unit Tests
```bash
cd python
uv run pytest tests/agent_work_orders/ -m unit -v
```
### Integration Tests
```bash
uv run pytest tests/integration/test_agent_service_communication.py -v
```
### Manual Testing
```bash
# Create a work order
curl -X POST http://localhost:8053/api/agent-work-orders/ \
-H "Content-Type: application/json" \
-d '{
"repository_url": "https://github.com/test/repo",
"sandbox_type": "worktree",
"user_request": "Fix authentication bug",
"selected_commands": ["create-branch", "planning"]
}'
# List work orders
curl http://localhost:8053/api/agent-work-orders/
# Get specific work order
curl http://localhost:8053/api/agent-work-orders/<id>
```
## Monitoring
### Health Checks
The `/health` endpoint provides detailed status:
```json
{
"status": "healthy",
"service": "agent-work-orders",
"version": "0.1.0",
"dependencies": {
"claude_cli": { "available": true, "version": "2.0.21" },
"git": { "available": true },
"archon_server": { "available": true, "url": "..." },
"archon_mcp": { "available": true, "url": "..." }
}
}
```
### Logs
Structured logging with context:
```bash
# Docker logs
docker compose logs -f archon-agent-work-orders
# Local logs (stdout)
# Already visible in terminal running the service
```
## Architecture Details
### Dependencies
- **FastAPI**: Web framework
- **httpx**: HTTP client for service communication
- **Claude Code CLI**: Agent execution
- **Git**: Repository operations
- **GitHub CLI**: PR management
### File Structure
```
src/agent_work_orders/
├── server.py # Standalone server entry point
├── main.py # Legacy FastAPI app (deprecated)
├── config.py # Configuration management
├── api/
│ └── routes.py # API route handlers
├── agent_executor/ # Claude CLI execution
├── workflow_engine/ # Workflow orchestration
├── sandbox_manager/ # Git worktree management
└── github_integration/ # GitHub operations
```
## Future Improvements
- Claude Agent SDK migration (replace CLI with Python SDK)
- Direct MCP tool integration
- Multiple instance scaling with load balancing
- Prometheus metrics and distributed tracing
- WebSocket support for real-time log streaming
- Queue system (RabbitMQ/Redis) for work order management

View File

@@ -0,0 +1,7 @@
"""Agent Work Orders Module
PRD-compliant implementation of the Agent Work Order System.
Provides workflow-based agent execution in isolated sandboxes.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,4 @@
"""Agent Executor Module
Executes Claude CLI commands for agent workflows.
"""

View File

@@ -0,0 +1,386 @@
"""Agent CLI Executor
Executes Claude CLI commands for agent workflows.
"""
import asyncio
import json
import time
from pathlib import Path
from ..config import config
from ..models import CommandExecutionResult
from ..utils.structured_logger import get_logger
logger = get_logger(__name__)
class AgentCLIExecutor:
"""Executes Claude CLI commands"""
def __init__(self, cli_path: str | None = None):
self.cli_path = cli_path or config.CLAUDE_CLI_PATH
self._logger = logger
def build_command(
self,
command_file_path: str,
args: list[str] | None = None,
model: str | None = None,
) -> tuple[str, str]:
"""Build Claude CLI command
Builds a Claude Code CLI command with all required flags for automated execution.
The command uses stdin for prompt input and stream-json output format.
Flags (per PRPs/ai_docs/cc_cli_ref.md):
- --verbose: Required when using --print with --output-format=stream-json
- --model: Claude model to use (sonnet, opus, haiku)
- --max-turns: Optional limit for agent executions (None = unlimited)
- --dangerously-skip-permissions: Enables non-interactive automation
Args:
command_file_path: Path to command file containing the prompt
args: Optional arguments to append to prompt
model: Model to use (default: from config)
Returns:
Tuple of (command string, prompt text for stdin)
Raises:
ValueError: If command file cannot be read
"""
# Read command file content
try:
with open(command_file_path) as f:
prompt_text = f.read()
except Exception as e:
raise ValueError(f"Failed to read command file {command_file_path}: {e}") from e
# Replace argument placeholders in prompt text
if args:
# Replace $ARGUMENTS with first arg (or all args joined if multiple)
prompt_text = prompt_text.replace("$ARGUMENTS", args[0] if len(args) == 1 else ", ".join(args))
# Replace positional placeholders ($1, $2, $3, etc.)
for i, arg in enumerate(args, start=1):
prompt_text = prompt_text.replace(f"${i}", arg)
# Build command with all required flags
cmd_parts = [
self.cli_path,
"--print",
"--output-format",
"stream-json",
]
# Add --verbose (required for stream-json with --print)
if config.CLAUDE_CLI_VERBOSE:
cmd_parts.append("--verbose")
# Add --model (specify which Claude model to use)
model_to_use = model or config.CLAUDE_CLI_MODEL
cmd_parts.extend(["--model", model_to_use])
# Add --max-turns only if configured (None = unlimited)
if config.CLAUDE_CLI_MAX_TURNS is not None:
cmd_parts.extend(["--max-turns", str(config.CLAUDE_CLI_MAX_TURNS)])
# Add --dangerously-skip-permissions (automation)
if config.CLAUDE_CLI_SKIP_PERMISSIONS:
cmd_parts.append("--dangerously-skip-permissions")
return " ".join(cmd_parts), prompt_text
async def execute_async(
self,
command: str,
working_directory: str,
timeout_seconds: int | None = None,
prompt_text: str | None = None,
work_order_id: str | None = None,
) -> CommandExecutionResult:
"""Execute Claude CLI command asynchronously
Args:
command: Complete command to execute
working_directory: Directory to execute in
timeout_seconds: Optional timeout (defaults to config)
prompt_text: Optional prompt text to pass via stdin
work_order_id: Optional work order ID for logging artifacts
Returns:
CommandExecutionResult with execution details
"""
timeout = timeout_seconds or config.EXECUTION_TIMEOUT
self._logger.info(
"agent_command_started",
command=command,
working_directory=working_directory,
timeout=timeout,
work_order_id=work_order_id,
)
# Save prompt if enabled and work_order_id provided
if work_order_id and prompt_text:
self._save_prompt(prompt_text, work_order_id)
start_time = time.time()
session_id: str | None = None
try:
process = await asyncio.create_subprocess_shell(
command,
cwd=working_directory,
stdin=asyncio.subprocess.PIPE if prompt_text else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
# Pass prompt via stdin if provided
stdin_data = prompt_text.encode() if prompt_text else None
stdout, stderr = await asyncio.wait_for(
process.communicate(input=stdin_data), timeout=timeout
)
except TimeoutError:
process.kill()
await process.wait()
duration = time.time() - start_time
self._logger.error(
"agent_command_timeout",
command=command,
timeout=timeout,
duration=duration,
)
return CommandExecutionResult(
success=False,
stdout=None,
stderr=None,
exit_code=-1,
error_message=f"Command timed out after {timeout}s",
duration_seconds=duration,
)
duration = time.time() - start_time
# Decode output
stdout_text = stdout.decode() if stdout else ""
stderr_text = stderr.decode() if stderr else ""
# Save output artifacts if enabled
if work_order_id and stdout_text:
self._save_output_artifacts(stdout_text, work_order_id)
# Parse session ID and result message from JSONL output
if stdout_text:
session_id = self._extract_session_id(stdout_text)
result_message = self._extract_result_message(stdout_text)
else:
result_message = None
# Extract result text from JSONL result message
result_text: str | None = None
if result_message and "result" in result_message:
result_value = result_message.get("result")
# Convert result to string (handles both str and other types)
result_text = str(result_value) if result_value is not None else None
else:
result_text = None
# Determine success based on exit code AND result message
success = process.returncode == 0
error_message: str | None = None
# Check for error_during_execution subtype (agent error without result)
if result_message and result_message.get("subtype") == "error_during_execution":
success = False
error_message = "Error during execution: Agent encountered an error and did not return a result"
elif result_message and result_message.get("is_error"):
success = False
error_message = str(result_message.get("result", "Unknown error"))
elif not success:
error_message = stderr_text if stderr_text else "Command failed"
# Log extracted result text for debugging
if result_text:
self._logger.debug(
"result_text_extracted",
result_text_preview=result_text[:100] if len(result_text) > 100 else result_text,
work_order_id=work_order_id,
)
result = CommandExecutionResult(
success=success,
stdout=stdout_text,
result_text=result_text,
stderr=stderr_text,
exit_code=process.returncode or 0,
session_id=session_id,
error_message=error_message,
duration_seconds=duration,
)
if success:
self._logger.info(
"agent_command_completed",
session_id=session_id,
duration=duration,
work_order_id=work_order_id,
)
else:
self._logger.error(
"agent_command_failed",
exit_code=process.returncode,
duration=duration,
error=result.error_message,
work_order_id=work_order_id,
)
return result
except Exception as e:
duration = time.time() - start_time
self._logger.error(
"agent_command_error",
command=command,
error=str(e),
duration=duration,
exc_info=True,
)
return CommandExecutionResult(
success=False,
stdout=None,
stderr=None,
exit_code=-1,
error_message=str(e),
duration_seconds=duration,
)
def _save_prompt(self, prompt_text: str, work_order_id: str) -> Path | None:
"""Save prompt to file for debugging
Args:
prompt_text: The prompt text to save
work_order_id: Work order ID for directory organization
Returns:
Path to saved file, or None if logging disabled
"""
if not config.ENABLE_PROMPT_LOGGING:
return None
try:
# Create directory: /tmp/agent-work-orders/{work_order_id}/prompts/
prompt_dir = Path(config.TEMP_DIR_BASE) / work_order_id / "prompts"
prompt_dir.mkdir(parents=True, exist_ok=True)
# Save with timestamp
timestamp = time.strftime("%Y%m%d_%H%M%S")
prompt_file = prompt_dir / f"prompt_{timestamp}.txt"
with open(prompt_file, "w") as f:
f.write(prompt_text)
self._logger.info("prompt_saved", file_path=str(prompt_file))
return prompt_file
except Exception as e:
self._logger.warning("prompt_save_failed", error=str(e))
return None
def _save_output_artifacts(self, jsonl_output: str, work_order_id: str) -> tuple[Path | None, Path | None]:
"""Save JSONL output and convert to JSON for easier consumption
Args:
jsonl_output: Raw JSONL output from Claude CLI
work_order_id: Work order ID for directory organization
Returns:
Tuple of (jsonl_path, json_path) or (None, None) if disabled
"""
if not config.ENABLE_OUTPUT_ARTIFACTS:
return None, None
try:
# Create directory: /tmp/agent-work-orders/{work_order_id}/outputs/
output_dir = Path(config.TEMP_DIR_BASE) / work_order_id / "outputs"
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y%m%d_%H%M%S")
# Save JSONL
jsonl_file = output_dir / f"output_{timestamp}.jsonl"
with open(jsonl_file, "w") as f:
f.write(jsonl_output)
# Convert to JSON array
json_file = output_dir / f"output_{timestamp}.json"
try:
messages = [json.loads(line) for line in jsonl_output.strip().split("\n") if line.strip()]
with open(json_file, "w") as f:
json.dump(messages, f, indent=2)
except Exception as e:
self._logger.warning("jsonl_to_json_conversion_failed", error=str(e))
json_file = None # type: ignore[assignment]
self._logger.info("output_artifacts_saved", jsonl=str(jsonl_file), json=str(json_file) if json_file else None)
return jsonl_file, json_file
except Exception as e:
self._logger.warning("output_artifacts_save_failed", error=str(e))
return None, None
def _extract_session_id(self, jsonl_output: str) -> str | None:
"""Extract session ID from JSONL output
Looks for session_id in JSON lines output from Claude CLI.
Args:
jsonl_output: JSONL output from Claude CLI
Returns:
Session ID if found, else None
"""
try:
lines = jsonl_output.strip().split("\n")
for line in lines:
if not line.strip():
continue
try:
data = json.loads(line)
if "session_id" in data:
session_id: str = data["session_id"]
return session_id
except json.JSONDecodeError:
continue
except Exception as e:
self._logger.warning("session_id_extraction_failed", error=str(e))
return None
def _extract_result_message(self, jsonl_output: str) -> dict[str, object] | None:
"""Extract result message from JSONL output
Looks for the final result message with error details.
Args:
jsonl_output: JSONL output from Claude CLI
Returns:
Result message dict if found, else None
"""
try:
lines = jsonl_output.strip().split("\n")
# Result message should be last, but search from end to be safe
for line in reversed(lines):
if not line.strip():
continue
try:
data = json.loads(line)
if data.get("type") == "result":
return data # type: ignore[no-any-return]
except json.JSONDecodeError:
continue
except Exception as e:
self._logger.warning("result_message_extraction_failed", error=str(e))
return None

View File

@@ -0,0 +1,4 @@
"""API Module
FastAPI routes for agent work orders.
"""

View File

@@ -0,0 +1,814 @@
"""API Routes
FastAPI routes for agent work orders.
"""
import asyncio
from datetime import datetime
from typing import Any
from fastapi import APIRouter, HTTPException, Query
from sse_starlette.sse import EventSourceResponse
from ..agent_executor.agent_cli_executor import AgentCLIExecutor
from ..command_loader.claude_command_loader import ClaudeCommandLoader
from ..github_integration.github_client import GitHubClient
from ..models import (
AgentPromptRequest,
AgentWorkflowPhase,
AgentWorkOrder,
AgentWorkOrderResponse,
AgentWorkOrderState,
AgentWorkOrderStatus,
ConfiguredRepository,
CreateAgentWorkOrderRequest,
CreateRepositoryRequest,
GitHubRepositoryVerificationRequest,
GitHubRepositoryVerificationResponse,
GitProgressSnapshot,
StepHistory,
UpdateRepositoryRequest,
)
from ..sandbox_manager.sandbox_factory import SandboxFactory
from ..state_manager.repository_config_repository import RepositoryConfigRepository
from ..state_manager.repository_factory import create_repository
from ..utils.id_generator import generate_work_order_id
from ..utils.log_buffer import WorkOrderLogBuffer
from ..utils.structured_logger import get_logger
from ..workflow_engine.workflow_orchestrator import WorkflowOrchestrator
from .sse_streams import stream_work_order_logs
logger = get_logger(__name__)
router = APIRouter()
# Initialize dependencies (singletons for MVP)
state_repository = create_repository()
repository_config_repo = RepositoryConfigRepository()
agent_executor = AgentCLIExecutor()
sandbox_factory = SandboxFactory()
github_client = GitHubClient()
command_loader = ClaudeCommandLoader()
log_buffer = WorkOrderLogBuffer()
orchestrator = WorkflowOrchestrator(
agent_executor=agent_executor,
sandbox_factory=sandbox_factory,
github_client=github_client,
command_loader=command_loader,
state_repository=state_repository,
)
@router.post("/", status_code=201)
async def create_agent_work_order(
request: CreateAgentWorkOrderRequest,
) -> AgentWorkOrderResponse:
"""Create a new agent work order
Creates a work order and starts workflow execution in the background.
"""
logger.info(
"agent_work_order_creation_started",
repository_url=request.repository_url,
sandbox_type=request.sandbox_type.value,
selected_commands=request.selected_commands,
)
try:
# Generate ID
agent_work_order_id = generate_work_order_id()
# Create state
state = AgentWorkOrderState(
agent_work_order_id=agent_work_order_id,
repository_url=request.repository_url,
sandbox_identifier=f"sandbox-{agent_work_order_id}",
git_branch_name=None,
agent_session_id=None,
)
# Create metadata
metadata = {
"sandbox_type": request.sandbox_type,
"github_issue_number": request.github_issue_number,
"status": AgentWorkOrderStatus.PENDING,
"current_phase": None,
"created_at": datetime.now(),
"updated_at": datetime.now(),
"github_pull_request_url": None,
"git_commit_count": 0,
"git_files_changed": 0,
"error_message": None,
}
# Save to repository
await state_repository.create(state, metadata)
# Start workflow in background
asyncio.create_task(
orchestrator.execute_workflow(
agent_work_order_id=agent_work_order_id,
repository_url=request.repository_url,
sandbox_type=request.sandbox_type,
user_request=request.user_request,
selected_commands=request.selected_commands,
github_issue_number=request.github_issue_number,
)
)
logger.info(
"agent_work_order_created",
agent_work_order_id=agent_work_order_id,
)
return AgentWorkOrderResponse(
agent_work_order_id=agent_work_order_id,
status=AgentWorkOrderStatus.PENDING,
message="Agent work order created and workflow execution started",
)
except Exception as e:
logger.error("agent_work_order_creation_failed", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to create work order: {e}") from e
# =====================================================
# Repository Configuration Endpoints
# NOTE: These MUST come before the catch-all /{agent_work_order_id} route
# =====================================================
@router.get("/repositories")
async def list_configured_repositories() -> list[ConfiguredRepository]:
"""List all configured repositories
Returns list of all configured repositories ordered by created_at DESC.
Each repository includes metadata, verification status, and preferences.
"""
logger.info("repository_list_started")
try:
repositories = await repository_config_repo.list_repositories()
logger.info(
"repository_list_completed",
count=len(repositories)
)
return repositories
except Exception as e:
logger.exception(
"repository_list_failed",
error=str(e)
)
raise HTTPException(status_code=500, detail=f"Failed to list repositories: {e}") from e
@router.post("/repositories", status_code=201)
async def create_configured_repository(
request: CreateRepositoryRequest,
) -> ConfiguredRepository:
"""Create a new configured repository
If verify=True (default), validates repository access via GitHub API
and extracts metadata (display_name, owner, default_branch).
"""
logger.info(
"repository_creation_started",
repository_url=request.repository_url,
verify=request.verify
)
try:
# Initialize metadata variables
display_name: str | None = None
owner: str | None = None
default_branch: str | None = None
is_verified = False
# Verify repository and extract metadata if requested
if request.verify:
try:
is_accessible = await github_client.verify_repository_access(request.repository_url)
if is_accessible:
repo_info = await github_client.get_repository_info(request.repository_url)
display_name = repo_info.name
owner = repo_info.owner
default_branch = repo_info.default_branch
is_verified = True
logger.info(
"repository_verified",
repository_url=request.repository_url,
display_name=display_name
)
else:
logger.warning(
"repository_verification_failed",
repository_url=request.repository_url
)
raise HTTPException(
status_code=400,
detail="Repository not accessible or not found"
)
except HTTPException:
raise
except Exception as github_error:
logger.error(
"github_api_error_during_verification",
repository_url=request.repository_url,
error=str(github_error),
exc_info=True
)
raise HTTPException(
status_code=502,
detail=f"GitHub API error during repository verification: {str(github_error)}"
) from github_error
# Create repository in database
repository = await repository_config_repo.create_repository(
repository_url=request.repository_url,
display_name=display_name,
owner=owner,
default_branch=default_branch,
is_verified=is_verified,
)
logger.info(
"repository_created",
repository_id=repository.id,
repository_url=request.repository_url
)
return repository
except HTTPException:
raise
except ValueError as e:
# Validation errors (e.g., invalid enum values from database)
logger.error(
"repository_validation_error",
repository_url=request.repository_url,
error=str(e),
exc_info=True
)
raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}") from e
except Exception as e:
# Check for unique constraint violation (duplicate repository_url)
error_message = str(e).lower()
if "unique" in error_message or "duplicate" in error_message:
logger.error(
"repository_url_already_exists",
repository_url=request.repository_url,
error=str(e)
)
raise HTTPException(
status_code=409,
detail=f"Repository URL already configured: {request.repository_url}"
) from e
# All other database/unexpected errors
logger.exception(
"repository_creation_unexpected_error",
repository_url=request.repository_url,
error=str(e)
)
# For beta: expose detailed error for debugging (as per CLAUDE.md principles)
raise HTTPException(
status_code=500,
detail=f"Failed to create repository: {str(e)}"
) from e
@router.patch("/repositories/{repository_id}")
async def update_configured_repository(
repository_id: str,
request: UpdateRepositoryRequest,
) -> ConfiguredRepository:
"""Update an existing configured repository
Supports partial updates - only provided fields will be updated.
Returns 404 if repository not found.
"""
logger.info(
"repository_update_started",
repository_id=repository_id
)
try:
# Build updates dict from non-None fields
updates: dict[str, Any] = {}
if request.default_sandbox_type is not None:
updates["default_sandbox_type"] = request.default_sandbox_type
if request.default_commands is not None:
updates["default_commands"] = request.default_commands
# Update repository
repository = await repository_config_repo.update_repository(repository_id, **updates)
if repository is None:
logger.warning(
"repository_not_found_for_update",
repository_id=repository_id
)
raise HTTPException(status_code=404, detail="Repository not found")
logger.info(
"repository_updated",
repository_id=repository_id,
updated_fields=list(updates.keys())
)
return repository
except HTTPException:
raise
except Exception as e:
logger.exception(
"repository_update_failed",
repository_id=repository_id,
error=str(e)
)
raise HTTPException(status_code=500, detail=f"Failed to update repository: {e}") from e
@router.delete("/repositories/{repository_id}", status_code=204)
async def delete_configured_repository(repository_id: str) -> None:
"""Delete a configured repository
Returns 204 No Content on success, 404 if repository not found.
"""
logger.info(
"repository_deletion_started",
repository_id=repository_id
)
try:
deleted = await repository_config_repo.delete_repository(repository_id)
if not deleted:
logger.warning(
"repository_not_found_for_delete",
repository_id=repository_id
)
raise HTTPException(status_code=404, detail="Repository not found")
logger.info(
"repository_deleted",
repository_id=repository_id
)
except HTTPException:
raise
except Exception as e:
logger.exception(
"repository_deletion_failed",
repository_id=repository_id,
error=str(e)
)
raise HTTPException(status_code=500, detail=f"Failed to delete repository: {e}") from e
@router.post("/repositories/{repository_id}/verify")
async def verify_repository_access(repository_id: str) -> dict[str, bool | str]:
"""Re-verify repository access and update metadata
Calls GitHub API to verify current access and updates repository
metadata if accessible (display_name, owner, default_branch, is_verified, last_verified_at).
Returns verification result with is_accessible boolean.
"""
logger.info(
"repository_verification_started",
repository_id=repository_id
)
try:
# Fetch repository from database
repository = await repository_config_repo.get_repository(repository_id)
if repository is None:
logger.warning(
"repository_not_found_for_verification",
repository_id=repository_id
)
raise HTTPException(status_code=404, detail="Repository not found")
# Verify repository access
is_accessible = await github_client.verify_repository_access(repository.repository_url)
if is_accessible:
# Fetch updated metadata
repo_info = await github_client.get_repository_info(repository.repository_url)
# Update repository with new metadata
await repository_config_repo.update_repository(
repository_id,
display_name=repo_info.name,
owner=repo_info.owner,
default_branch=repo_info.default_branch,
is_verified=True,
last_verified_at=datetime.now(),
)
logger.info(
"repository_verification_success",
repository_id=repository_id,
repository_url=repository.repository_url
)
else:
# Update verification status to false
await repository_config_repo.update_repository(
repository_id,
is_verified=False,
)
logger.warning(
"repository_verification_not_accessible",
repository_id=repository_id,
repository_url=repository.repository_url
)
return {
"is_accessible": is_accessible,
"repository_id": repository_id,
}
except HTTPException:
raise
except Exception as e:
logger.exception(
"repository_verification_failed",
repository_id=repository_id,
error=str(e)
)
raise HTTPException(status_code=500, detail=f"Failed to verify repository: {e}") from e
@router.get("/{agent_work_order_id}")
async def get_agent_work_order(agent_work_order_id: str) -> AgentWorkOrder:
"""Get agent work order by ID"""
logger.info("agent_work_order_get_started", agent_work_order_id=agent_work_order_id)
try:
result = await state_repository.get(agent_work_order_id)
if not result:
raise HTTPException(status_code=404, detail="Work order not found")
state, metadata = result
# Build full model
work_order = AgentWorkOrder(
agent_work_order_id=state.agent_work_order_id,
repository_url=state.repository_url,
sandbox_identifier=state.sandbox_identifier,
git_branch_name=state.git_branch_name,
agent_session_id=state.agent_session_id,
sandbox_type=metadata["sandbox_type"],
github_issue_number=metadata["github_issue_number"],
status=metadata["status"],
current_phase=metadata["current_phase"],
created_at=metadata["created_at"],
updated_at=metadata["updated_at"],
github_pull_request_url=metadata.get("github_pull_request_url"),
git_commit_count=metadata.get("git_commit_count", 0),
git_files_changed=metadata.get("git_files_changed", 0),
error_message=metadata.get("error_message"),
)
logger.info("agent_work_order_get_completed", agent_work_order_id=agent_work_order_id)
return work_order
except HTTPException:
raise
except Exception as e:
logger.error(
"agent_work_order_get_failed",
agent_work_order_id=agent_work_order_id,
error=str(e),
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"Failed to get work order: {e}") from e
@router.get("/")
async def list_agent_work_orders(
status: AgentWorkOrderStatus | None = None,
) -> list[AgentWorkOrder]:
"""List all agent work orders
Args:
status: Optional status filter
"""
logger.info("agent_work_orders_list_started", status=status.value if status else None)
try:
results = await state_repository.list(status_filter=status)
work_orders = []
for state, metadata in results:
work_order = AgentWorkOrder(
agent_work_order_id=state.agent_work_order_id,
repository_url=state.repository_url,
sandbox_identifier=state.sandbox_identifier,
git_branch_name=state.git_branch_name,
agent_session_id=state.agent_session_id,
sandbox_type=metadata["sandbox_type"],
github_issue_number=metadata["github_issue_number"],
status=metadata["status"],
current_phase=metadata["current_phase"],
created_at=metadata["created_at"],
updated_at=metadata["updated_at"],
github_pull_request_url=metadata.get("github_pull_request_url"),
git_commit_count=metadata.get("git_commit_count", 0),
git_files_changed=metadata.get("git_files_changed", 0),
error_message=metadata.get("error_message"),
)
work_orders.append(work_order)
logger.info("agent_work_orders_list_completed", count=len(work_orders))
return work_orders
except Exception as e:
logger.error("agent_work_orders_list_failed", error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list work orders: {e}") from e
@router.post("/{agent_work_order_id}/prompt")
async def send_prompt_to_agent(
agent_work_order_id: str,
request: AgentPromptRequest,
) -> dict:
"""Send prompt to running agent
TODO Phase 2+: Implement agent session resumption
For MVP, this is a placeholder.
"""
logger.info(
"agent_prompt_send_started",
agent_work_order_id=agent_work_order_id,
prompt=request.prompt_text,
)
# TODO Phase 2+: Implement session resumption
# For now, return success but don't actually send
return {
"success": True,
"message": "Prompt sending not yet implemented (Phase 2+)",
"agent_work_order_id": agent_work_order_id,
}
@router.get("/{agent_work_order_id}/git-progress")
async def get_git_progress(agent_work_order_id: str) -> GitProgressSnapshot:
"""Get git progress for a work order"""
logger.info("git_progress_get_started", agent_work_order_id=agent_work_order_id)
try:
result = await state_repository.get(agent_work_order_id)
if not result:
raise HTTPException(status_code=404, detail="Work order not found")
state, metadata = result
if not state.git_branch_name:
# No branch yet, return minimal snapshot
current_phase = metadata.get("current_phase")
return GitProgressSnapshot(
agent_work_order_id=agent_work_order_id,
current_phase=current_phase if current_phase else AgentWorkflowPhase.PLANNING,
git_commit_count=0,
git_files_changed=0,
latest_commit_message=None,
git_branch_name=None,
)
# TODO Phase 2+: Get actual progress from sandbox
# For MVP, return metadata values
current_phase = metadata.get("current_phase")
return GitProgressSnapshot(
agent_work_order_id=agent_work_order_id,
current_phase=current_phase if current_phase else AgentWorkflowPhase.PLANNING,
git_commit_count=metadata.get("git_commit_count", 0),
git_files_changed=metadata.get("git_files_changed", 0),
latest_commit_message=None,
git_branch_name=state.git_branch_name,
)
except HTTPException:
raise
except Exception as e:
logger.error(
"git_progress_get_failed",
agent_work_order_id=agent_work_order_id,
error=str(e),
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"Failed to get git progress: {e}") from e
@router.get("/{agent_work_order_id}/logs")
async def get_agent_work_order_logs(
agent_work_order_id: str,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
level: str | None = Query(None, description="Filter by log level (info, warning, error, debug)"),
step: str | None = Query(None, description="Filter by step name"),
) -> dict:
"""Get buffered logs for a work order.
Returns logs from the in-memory buffer. For real-time streaming, use the
/logs/stream endpoint.
Args:
agent_work_order_id: Work order ID
limit: Maximum number of logs to return (1-1000)
offset: Number of logs to skip for pagination
level: Optional log level filter
step: Optional step name filter
Returns:
Dictionary with log entries and pagination metadata
"""
logger.info(
"agent_logs_get_started",
agent_work_order_id=agent_work_order_id,
limit=limit,
offset=offset,
level=level,
step=step,
)
# Verify work order exists
work_order = await state_repository.get(agent_work_order_id)
if not work_order:
raise HTTPException(status_code=404, detail="Agent work order not found")
# Get logs from buffer
log_entries = log_buffer.get_logs(
work_order_id=agent_work_order_id,
level=level,
step=step,
limit=limit,
offset=offset,
)
return {
"agent_work_order_id": agent_work_order_id,
"log_entries": log_entries,
"total": log_buffer.get_log_count(agent_work_order_id),
"limit": limit,
"offset": offset,
}
@router.get("/{agent_work_order_id}/logs/stream")
async def stream_agent_work_order_logs(
agent_work_order_id: str,
level: str | None = Query(None, description="Filter by log level (info, warning, error, debug)"),
step: str | None = Query(None, description="Filter by step name"),
since: str | None = Query(None, description="ISO timestamp - only return logs after this time"),
) -> EventSourceResponse:
"""Stream work order logs in real-time via Server-Sent Events.
Connects to a live stream that delivers logs as they are generated.
Connection stays open until work order completes or client disconnects.
Args:
agent_work_order_id: Work order ID
level: Optional log level filter (info, warning, error, debug)
step: Optional step name filter (exact match)
since: Optional ISO timestamp - only return logs after this time
Returns:
EventSourceResponse streaming log events
Examples:
curl -N http://localhost:8053/api/agent-work-orders/wo-123/logs/stream
curl -N "http://localhost:8053/api/agent-work-orders/wo-123/logs/stream?level=error"
Notes:
- Uses Server-Sent Events (SSE) protocol
- Sends heartbeat every 15 seconds to keep connection alive
- Automatically handles client disconnect
- Each event is JSON with timestamp, level, event, work_order_id, and extra fields
"""
logger.info(
"agent_logs_stream_started",
agent_work_order_id=agent_work_order_id,
level=level,
step=step,
since=since,
)
# Verify work order exists
work_order = await state_repository.get(agent_work_order_id)
if not work_order:
raise HTTPException(status_code=404, detail="Agent work order not found")
# Create SSE stream
return EventSourceResponse(
stream_work_order_logs(
work_order_id=agent_work_order_id,
log_buffer=log_buffer,
level_filter=level,
step_filter=step,
since_timestamp=since,
),
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@router.get("/{agent_work_order_id}/steps")
async def get_agent_work_order_steps(agent_work_order_id: str) -> StepHistory:
"""Get step execution history for a work order
Returns detailed history of each step executed,
including success/failure, duration, and errors.
Returns empty history if work order exists but has no steps yet.
"""
logger.info("agent_step_history_get_started", agent_work_order_id=agent_work_order_id)
try:
# First check if work order exists
result = await state_repository.get(agent_work_order_id)
if not result:
raise HTTPException(status_code=404, detail="Work order not found")
step_history = await state_repository.get_step_history(agent_work_order_id)
if not step_history:
# Work order exists but no steps yet - return empty history
logger.info(
"agent_step_history_empty",
agent_work_order_id=agent_work_order_id,
)
return StepHistory(agent_work_order_id=agent_work_order_id, steps=[])
logger.info(
"agent_step_history_get_completed",
agent_work_order_id=agent_work_order_id,
step_count=len(step_history.steps),
)
return step_history
except HTTPException:
raise
except Exception as e:
logger.error(
"agent_step_history_get_failed",
agent_work_order_id=agent_work_order_id,
error=str(e),
exc_info=True,
)
raise HTTPException(status_code=500, detail=f"Failed to get step history: {e}") from e
@router.post("/github/verify-repository")
async def verify_github_repository(
request: GitHubRepositoryVerificationRequest,
) -> GitHubRepositoryVerificationResponse:
"""Verify GitHub repository access"""
logger.info("github_repository_verification_started", repository_url=request.repository_url)
try:
is_accessible = await github_client.verify_repository_access(request.repository_url)
if is_accessible:
repo_info = await github_client.get_repository_info(request.repository_url)
logger.info("github_repository_verified", repository_url=request.repository_url)
return GitHubRepositoryVerificationResponse(
is_accessible=True,
repository_name=repo_info.name,
repository_owner=repo_info.owner,
default_branch=repo_info.default_branch,
error_message=None,
)
else:
logger.warning("github_repository_not_accessible", repository_url=request.repository_url)
return GitHubRepositoryVerificationResponse(
is_accessible=False,
repository_name=None,
repository_owner=None,
default_branch=None,
error_message="Repository not accessible or not found",
)
except Exception as e:
logger.error(
"github_repository_verification_failed",
repository_url=request.repository_url,
error=str(e),
exc_info=True,
)
return GitHubRepositoryVerificationResponse(
is_accessible=False,
repository_name=None,
repository_owner=None,
default_branch=None,
error_message=str(e),
)

View File

@@ -0,0 +1,134 @@
"""Server-Sent Events (SSE) Streaming for Work Order Logs
Implements SSE streaming endpoint for real-time log delivery.
Uses sse-starlette for W3C SSE specification compliance.
"""
import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import UTC, datetime
from typing import Any
from ..utils.log_buffer import WorkOrderLogBuffer
async def stream_work_order_logs(
work_order_id: str,
log_buffer: WorkOrderLogBuffer,
level_filter: str | None = None,
step_filter: str | None = None,
since_timestamp: str | None = None,
) -> AsyncGenerator[dict[str, Any], None]:
"""Stream work order logs via Server-Sent Events.
Yields existing buffered logs first, then new logs as they arrive.
Sends heartbeat comments every 15 seconds to prevent connection timeout.
Args:
work_order_id: ID of the work order to stream logs for
log_buffer: The WorkOrderLogBuffer instance to read from
level_filter: Optional log level filter (info, warning, error, debug)
step_filter: Optional step name filter (exact match)
since_timestamp: Optional ISO timestamp - only return logs after this time
Yields:
SSE event dictionaries with "data" key containing JSON log entry
Examples:
async for event in stream_work_order_logs("wo-123", buffer):
# event = {"data": '{"timestamp": "...", "level": "info", ...}'}
print(event)
Notes:
- Generator automatically handles client disconnects via CancelledError
- Heartbeat comments prevent proxy/load balancer timeouts
- Non-blocking polling with 0.5s interval
"""
# Get existing buffered logs first
existing_logs = log_buffer.get_logs(
work_order_id=work_order_id,
level=level_filter,
step=step_filter,
since=since_timestamp,
)
# Yield existing logs as SSE events
for log_entry in existing_logs:
yield format_log_event(log_entry)
# Track last seen timestamp to avoid duplicates
last_timestamp = (
existing_logs[-1]["timestamp"] if existing_logs else since_timestamp or ""
)
# Stream new logs as they arrive
heartbeat_counter = 0
heartbeat_interval = 30 # 30 iterations * 0.5s = 15 seconds
try:
while True:
# Poll for new logs
new_logs = log_buffer.get_logs_since(
work_order_id=work_order_id,
since_timestamp=last_timestamp,
level=level_filter,
step=step_filter,
)
# Yield new logs
for log_entry in new_logs:
yield format_log_event(log_entry)
last_timestamp = log_entry["timestamp"]
# Send heartbeat comment every 15 seconds to keep connection alive
heartbeat_counter += 1
if heartbeat_counter >= heartbeat_interval:
yield {"comment": "keepalive"}
heartbeat_counter = 0
# Non-blocking sleep before next poll
await asyncio.sleep(0.5)
except asyncio.CancelledError:
# Client disconnected - clean exit
pass
def format_log_event(log_dict: dict[str, Any]) -> dict[str, str]:
"""Format a log dictionary as an SSE event.
Args:
log_dict: Dictionary containing log entry data
Returns:
SSE event dictionary with "data" key containing JSON string
Examples:
event = format_log_event({
"timestamp": "2025-10-23T12:00:00Z",
"level": "info",
"event": "step_started",
"work_order_id": "wo-123",
"step": "planning"
})
# Returns: {"data": '{"timestamp": "...", "level": "info", ...}'}
Notes:
- JSON serialization handles datetime conversion
- Event format follows SSE specification: data: {json}
"""
return {"data": json.dumps(log_dict)}
def get_current_timestamp() -> str:
"""Get current timestamp in ISO format with timezone.
Returns:
ISO format timestamp string (e.g., "2025-10-23T12:34:56.789Z")
Examples:
timestamp = get_current_timestamp()
# "2025-10-23T12:34:56.789123Z"
"""
return datetime.now(UTC).isoformat()

View File

@@ -0,0 +1,4 @@
"""Command Loader Module
Loads Claude command files from .claude/commands directory.
"""

View File

@@ -0,0 +1,64 @@
"""Claude Command Loader
Loads command files from .claude/commands directory.
"""
from pathlib import Path
from ..config import config
from ..models import CommandNotFoundError
from ..utils.structured_logger import get_logger
logger = get_logger(__name__)
class ClaudeCommandLoader:
"""Loads Claude command files"""
def __init__(self, commands_directory: str | None = None):
self.commands_directory = Path(commands_directory or config.COMMANDS_DIRECTORY)
self._logger = logger.bind(commands_directory=str(self.commands_directory))
def load_command(self, command_name: str) -> str:
"""Load command file content
Args:
command_name: Command name (e.g., 'agent_workflow_plan')
Will load {command_name}.md
Returns:
Path to the command file
Raises:
CommandNotFoundError: If command file not found
"""
file_path = self.commands_directory / f"{command_name}.md"
self._logger.info("command_load_started", command_name=command_name, file_path=str(file_path))
if not file_path.exists():
self._logger.error("command_not_found", command_name=command_name, file_path=str(file_path))
raise CommandNotFoundError(
f"Command file not found: {file_path}. "
f"Please create it at {file_path}"
)
self._logger.info("command_load_completed", command_name=command_name)
return str(file_path)
def list_available_commands(self) -> list[str]:
"""List all available command files
Returns:
List of command names (without .md extension)
"""
if not self.commands_directory.exists():
self._logger.warning("commands_directory_not_found")
return []
commands = []
for file_path in self.commands_directory.glob("*.md"):
commands.append(file_path.stem)
self._logger.info("commands_listed", count=len(commands), commands=commands)
return commands

View File

@@ -0,0 +1,109 @@
"""Configuration Management
Loads configuration from environment variables with sensible defaults.
"""
import os
from pathlib import Path
def get_project_root() -> Path:
"""Get the project root directory (one level up from python/)"""
# This file is in python/src/agent_work_orders/config.py
# So go up 3 levels to get to project root
return Path(__file__).parent.parent.parent.parent
class AgentWorkOrdersConfig:
"""Configuration for Agent Work Orders service"""
# Feature flag - allows disabling agent work orders entirely
ENABLED: bool = os.getenv("ENABLE_AGENT_WORK_ORDERS", "false").lower() == "true"
CLAUDE_CLI_PATH: str = os.getenv("CLAUDE_CLI_PATH", "claude")
EXECUTION_TIMEOUT: int = int(os.getenv("AGENT_WORK_ORDER_TIMEOUT", "3600"))
# Default to python/.claude/commands/agent-work-orders
_python_root = Path(__file__).parent.parent.parent
_default_commands_dir = str(_python_root / ".claude" / "commands" / "agent-work-orders")
COMMANDS_DIRECTORY: str = os.getenv("AGENT_WORK_ORDER_COMMANDS_DIR", _default_commands_dir)
TEMP_DIR_BASE: str = os.getenv("AGENT_WORK_ORDER_TEMP_DIR", "/tmp/agent-work-orders")
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
GH_CLI_PATH: str = os.getenv("GH_CLI_PATH", "gh")
# Service discovery configuration
SERVICE_DISCOVERY_MODE: str = os.getenv("SERVICE_DISCOVERY_MODE", "local")
# CORS configuration
CORS_ORIGINS: str = os.getenv("CORS_ORIGINS", "http://localhost:3737,http://host.docker.internal:3737,*")
# Claude CLI flags configuration
# --verbose: Required when using --print with --output-format=stream-json
CLAUDE_CLI_VERBOSE: bool = os.getenv("CLAUDE_CLI_VERBOSE", "true").lower() == "true"
# --max-turns: Optional limit for agent executions. Set to None for unlimited.
# Default: None (no limit - let agent run until completion)
_max_turns_env = os.getenv("CLAUDE_CLI_MAX_TURNS")
CLAUDE_CLI_MAX_TURNS: int | None = int(_max_turns_env) if _max_turns_env else None
# --model: Claude model to use (sonnet, opus, haiku)
CLAUDE_CLI_MODEL: str = os.getenv("CLAUDE_CLI_MODEL", "sonnet")
# --dangerously-skip-permissions: Required for non-interactive automation
CLAUDE_CLI_SKIP_PERMISSIONS: bool = os.getenv("CLAUDE_CLI_SKIP_PERMISSIONS", "true").lower() == "true"
# Logging configuration
# Enable saving prompts and outputs for debugging
ENABLE_PROMPT_LOGGING: bool = os.getenv("ENABLE_PROMPT_LOGGING", "true").lower() == "true"
ENABLE_OUTPUT_ARTIFACTS: bool = os.getenv("ENABLE_OUTPUT_ARTIFACTS", "true").lower() == "true"
# Worktree configuration
WORKTREE_BASE_DIR: str = os.getenv("WORKTREE_BASE_DIR", "trees")
# Port allocation for parallel execution
BACKEND_PORT_RANGE_START: int = int(os.getenv("BACKEND_PORT_START", "9100"))
BACKEND_PORT_RANGE_END: int = int(os.getenv("BACKEND_PORT_END", "9114"))
FRONTEND_PORT_RANGE_START: int = int(os.getenv("FRONTEND_PORT_START", "9200"))
FRONTEND_PORT_RANGE_END: int = int(os.getenv("FRONTEND_PORT_END", "9214"))
# State management configuration
STATE_STORAGE_TYPE: str = os.getenv("STATE_STORAGE_TYPE", "memory") # "memory" or "file"
FILE_STATE_DIRECTORY: str = os.getenv("FILE_STATE_DIRECTORY", "agent-work-orders-state")
@classmethod
def ensure_temp_dir(cls) -> Path:
"""Ensure temp directory exists and return Path"""
temp_dir = Path(cls.TEMP_DIR_BASE)
temp_dir.mkdir(parents=True, exist_ok=True)
return temp_dir
@classmethod
def get_archon_server_url(cls) -> str:
"""Get Archon server URL based on service discovery mode"""
# Allow explicit override
explicit_url = os.getenv("ARCHON_SERVER_URL")
if explicit_url:
return explicit_url
# Otherwise use service discovery mode
if cls.SERVICE_DISCOVERY_MODE == "docker_compose":
return "http://archon-server:8181"
return "http://localhost:8181"
@classmethod
def get_archon_mcp_url(cls) -> str:
"""Get Archon MCP server URL based on service discovery mode"""
# Allow explicit override
explicit_url = os.getenv("ARCHON_MCP_URL")
if explicit_url:
return explicit_url
# Otherwise use service discovery mode
if cls.SERVICE_DISCOVERY_MODE == "docker_compose":
return "http://archon-mcp:8051"
return "http://localhost:8051"
# Global config instance
config = AgentWorkOrdersConfig()

View File

@@ -0,0 +1,8 @@
"""Database client module for Agent Work Orders.
Provides Supabase client initialization and health checks for work order persistence.
"""
from .client import check_database_health, get_agent_work_orders_client
__all__ = ["get_agent_work_orders_client", "check_database_health"]

View File

@@ -0,0 +1,74 @@
"""Supabase client for Agent Work Orders.
Provides database connection management and health checks for work order state persistence.
Reuses same Supabase credentials as main Archon server (SUPABASE_URL, SUPABASE_SERVICE_KEY).
"""
import os
from typing import Any
from supabase import Client, create_client
from ..utils.structured_logger import get_logger
logger = get_logger(__name__)
def get_agent_work_orders_client() -> Client:
"""Get Supabase client for agent work orders.
Reuses same credentials as main Archon server (SUPABASE_URL, SUPABASE_SERVICE_KEY).
The service key provides full access and bypasses Row Level Security policies.
Returns:
Supabase client instance configured for work order operations
Raises:
ValueError: If SUPABASE_URL or SUPABASE_SERVICE_KEY environment variables are not set
Example:
>>> client = get_agent_work_orders_client()
>>> response = client.table("archon_agent_work_orders").select("*").execute()
"""
url = os.getenv("SUPABASE_URL")
key = os.getenv("SUPABASE_SERVICE_KEY")
if not url or not key:
raise ValueError(
"SUPABASE_URL and SUPABASE_SERVICE_KEY must be set in environment variables. "
"These should match the credentials used by the main Archon server."
)
return create_client(url, key)
async def check_database_health() -> dict[str, Any]:
"""Check if agent work orders tables exist and are accessible.
Verifies that both archon_agent_work_orders and archon_agent_work_order_steps
tables exist and can be queried. This is a lightweight check using limit(0)
to avoid fetching actual data.
Returns:
Dictionary with health check results:
- status: "healthy" or "unhealthy"
- tables_exist: True if both tables are accessible, False otherwise
- error: Error message if check failed (only present when unhealthy)
Example:
>>> health = await check_database_health()
>>> if health["status"] == "healthy":
... print("Database is ready")
"""
try:
client = get_agent_work_orders_client()
# Try to query both tables (limit 0 to avoid fetching data)
client.table("archon_agent_work_orders").select("agent_work_order_id").limit(0).execute()
client.table("archon_agent_work_order_steps").select("id").limit(0).execute()
logger.info("database_health_check_passed", tables=["archon_agent_work_orders", "archon_agent_work_order_steps"])
return {"status": "healthy", "tables_exist": True}
except Exception as e:
logger.error("database_health_check_failed", error=str(e), exc_info=True)
return {"status": "unhealthy", "tables_exist": False, "error": str(e)}

View File

@@ -0,0 +1,4 @@
"""GitHub Integration Module
Handles GitHub operations via gh CLI.
"""

View File

@@ -0,0 +1,308 @@
"""GitHub Client
Handles GitHub operations via gh CLI.
"""
import asyncio
import json
import re
from ..config import config
from ..models import GitHubOperationError, GitHubPullRequest, GitHubRepository
from ..utils.structured_logger import get_logger
logger = get_logger(__name__)
class GitHubClient:
"""GitHub operations using gh CLI"""
def __init__(self, gh_cli_path: str | None = None):
self.gh_cli_path = gh_cli_path or config.GH_CLI_PATH
self._logger = logger
async def verify_repository_access(self, repository_url: str) -> bool:
"""Check if repository is accessible via gh CLI
Args:
repository_url: GitHub repository URL
Returns:
True if accessible
"""
self._logger.info("github_repository_verification_started", repository_url=repository_url)
try:
owner, repo = self._parse_repository_url(repository_url)
repo_path = f"{owner}/{repo}"
process = await asyncio.create_subprocess_exec(
self.gh_cli_path,
"repo",
"view",
repo_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
if process.returncode == 0:
self._logger.info("github_repository_verified", repository_url=repository_url)
return True
else:
error = stderr.decode() if stderr else "Unknown error"
self._logger.warning(
"github_repository_not_accessible",
repository_url=repository_url,
error=error,
)
return False
except Exception as e:
self._logger.error(
"github_repository_verification_failed",
repository_url=repository_url,
error=str(e),
exc_info=True,
)
return False
async def get_repository_info(self, repository_url: str) -> GitHubRepository:
"""Get repository metadata
Args:
repository_url: GitHub repository URL
Returns:
GitHubRepository with metadata
Raises:
GitHubOperationError: If unable to get repository info
"""
self._logger.info("github_repository_info_started", repository_url=repository_url)
try:
owner, repo = self._parse_repository_url(repository_url)
repo_path = f"{owner}/{repo}"
process = await asyncio.create_subprocess_exec(
self.gh_cli_path,
"repo",
"view",
repo_path,
"--json",
"name,owner,defaultBranchRef",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
if process.returncode != 0:
error = stderr.decode() if stderr else "Unknown error"
self._logger.error(
"github_repository_info_failed",
repository_url=repository_url,
error=error,
)
raise GitHubOperationError(f"Failed to get repository info: {error}")
data = json.loads(stdout.decode())
repo_info = GitHubRepository(
name=data["name"],
owner=data["owner"]["login"],
default_branch=data["defaultBranchRef"]["name"],
url=repository_url,
)
self._logger.info("github_repository_info_completed", repository_url=repository_url)
return repo_info
except GitHubOperationError:
raise
except Exception as e:
self._logger.error(
"github_repository_info_error",
repository_url=repository_url,
error=str(e),
exc_info=True,
)
raise GitHubOperationError(f"Failed to get repository info: {e}") from e
async def get_issue(self, repository_url: str, issue_number: str) -> dict:
"""Get GitHub issue details
Args:
repository_url: GitHub repository URL
issue_number: Issue number
Returns:
Issue details as JSON dict
Raises:
GitHubOperationError: If unable to fetch issue
"""
self._logger.info("github_issue_fetch_started", repository_url=repository_url, issue_number=issue_number)
try:
owner, repo = self._parse_repository_url(repository_url)
repo_path = f"{owner}/{repo}"
process = await asyncio.create_subprocess_exec(
self.gh_cli_path,
"issue",
"view",
issue_number,
"--repo",
repo_path,
"--json",
"number,title,body,state,url",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30)
if process.returncode != 0:
error = stderr.decode() if stderr else "Unknown error"
raise GitHubOperationError(f"Failed to fetch issue: {error}")
issue_data: dict = json.loads(stdout.decode())
self._logger.info("github_issue_fetched", issue_number=issue_number)
return issue_data
except Exception as e:
self._logger.error("github_issue_fetch_failed", error=str(e), exc_info=True)
raise GitHubOperationError(f"Failed to fetch GitHub issue: {e}") from e
async def create_pull_request(
self,
repository_url: str,
head_branch: str,
base_branch: str,
title: str,
body: str,
) -> GitHubPullRequest:
"""Create pull request via gh CLI
Args:
repository_url: GitHub repository URL
head_branch: Source branch
base_branch: Target branch
title: PR title
body: PR body
Returns:
GitHubPullRequest with PR details
Raises:
GitHubOperationError: If PR creation fails
"""
self._logger.info(
"github_pull_request_creation_started",
repository_url=repository_url,
head_branch=head_branch,
base_branch=base_branch,
)
try:
owner, repo = self._parse_repository_url(repository_url)
repo_path = f"{owner}/{repo}"
process = await asyncio.create_subprocess_exec(
self.gh_cli_path,
"pr",
"create",
"--repo",
repo_path,
"--title",
title,
"--body",
body,
"--head",
head_branch,
"--base",
base_branch,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60)
if process.returncode != 0:
error = stderr.decode() if stderr else "Unknown error"
self._logger.error(
"github_pull_request_creation_failed",
repository_url=repository_url,
error=error,
)
raise GitHubOperationError(f"Failed to create pull request: {error}")
# Parse PR URL from output
pr_url = stdout.decode().strip()
# Extract PR number from URL
pr_number_match = re.search(r"/pull/(\d+)", pr_url)
pr_number = int(pr_number_match.group(1)) if pr_number_match else 0
pr = GitHubPullRequest(
pull_request_url=pr_url,
pull_request_number=pr_number,
title=title,
head_branch=head_branch,
base_branch=base_branch,
)
self._logger.info(
"github_pull_request_created",
pr_url=pr_url,
pr_number=pr_number,
)
return pr
except GitHubOperationError:
raise
except Exception as e:
self._logger.error(
"github_pull_request_creation_error",
repository_url=repository_url,
error=str(e),
exc_info=True,
)
raise GitHubOperationError(f"Failed to create pull request: {e}") from e
def _parse_repository_url(self, repository_url: str) -> tuple[str, str]:
"""Parse GitHub repository URL
Args:
repository_url: GitHub repository URL
Returns:
Tuple of (owner, repo)
Raises:
ValueError: If URL format is invalid
"""
# Handle formats:
# - https://github.com/owner/repo
# - https://github.com/owner/repo.git
# - owner/repo
if "/" not in repository_url:
raise ValueError("Invalid repository URL format")
if repository_url.startswith("http"):
# Extract from URL
match = re.search(r"github\.com[/:]([^/]+)/([^/\.]+)", repository_url)
if not match:
raise ValueError("Invalid GitHub URL format")
return match.group(1), match.group(2)
else:
# Direct owner/repo format
parts = repository_url.split("/")
if len(parts) != 2:
raise ValueError("Invalid repository format, expected owner/repo")
return parts[0], parts[1]

View File

@@ -0,0 +1,42 @@
"""Agent Work Orders FastAPI Application
PRD-compliant agent work order system.
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .api.routes import router
from .config import config
from .utils.structured_logger import configure_structured_logging
# Configure logging on startup
configure_structured_logging(config.LOG_LEVEL)
app = FastAPI(
title="Agent Work Orders API",
description="Agent work order system for workflow-based agent execution",
version="0.1.0",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routes
app.include_router(router)
@app.get("/health")
async def health() -> dict:
"""Health check endpoint"""
return {
"status": "healthy",
"service": "agent-work-orders",
"version": "0.1.0",
}

View File

@@ -0,0 +1,344 @@
"""PRD-Compliant Pydantic Models
All models follow exact naming from the PRD specification.
"""
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class AgentWorkOrderStatus(str, Enum):
"""Work order execution status"""
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class AgentWorkflowType(str, Enum):
"""Workflow types for agent execution"""
PLAN = "agent_workflow_plan"
class SandboxType(str, Enum):
"""Sandbox environment types"""
GIT_BRANCH = "git_branch"
GIT_WORKTREE = "git_worktree" # Fully implemented - recommended for concurrent execution
E2B = "e2b" # Placeholder for Phase 2+
DAGGER = "dagger" # Placeholder for Phase 2+
class AgentWorkflowPhase(str, Enum):
"""Workflow execution phases"""
PLANNING = "planning"
COMPLETED = "completed"
class WorkflowStep(str, Enum):
"""User-selectable workflow commands"""
CREATE_BRANCH = "create-branch"
PLANNING = "planning"
EXECUTE = "execute"
COMMIT = "commit"
CREATE_PR = "create-pr"
REVIEW = "prp-review"
class AgentWorkOrderState(BaseModel):
"""Minimal state model (5 core fields)
This represents the minimal persistent state stored in the database.
All other fields are computed from git or metadata.
"""
agent_work_order_id: str = Field(..., description="Unique work order identifier")
repository_url: str = Field(..., description="Git repository URL")
sandbox_identifier: str = Field(..., description="Sandbox identifier")
git_branch_name: str | None = Field(None, description="Git branch created by agent")
agent_session_id: str | None = Field(None, description="Claude CLI session ID")
class AgentWorkOrder(BaseModel):
"""Complete agent work order model
Combines core state with metadata and computed fields from git.
"""
# Core fields (from AgentWorkOrderState)
agent_work_order_id: str
repository_url: str
sandbox_identifier: str
git_branch_name: str | None = None
agent_session_id: str | None = None
# Metadata fields
sandbox_type: SandboxType
github_issue_number: str | None = None
status: AgentWorkOrderStatus
current_phase: AgentWorkflowPhase | None = None
created_at: datetime
updated_at: datetime
# Computed fields (from git inspection)
github_pull_request_url: str | None = None
git_commit_count: int = 0
git_files_changed: int = 0
error_message: str | None = None
class CreateAgentWorkOrderRequest(BaseModel):
"""Request to create a new agent work order
The user_request field is the primary input describing the work to be done.
If a GitHub issue reference is mentioned (e.g., "issue #42"), the system will
automatically detect and fetch the issue details.
"""
repository_url: str = Field(..., description="Git repository URL")
sandbox_type: SandboxType = Field(
default=SandboxType.GIT_WORKTREE,
description="Sandbox environment type (defaults to git_worktree for efficient concurrent execution)"
)
user_request: str = Field(..., description="User's description of the work to be done")
selected_commands: list[str] = Field(
default=["create-branch", "planning", "execute", "commit", "create-pr"],
description="Commands to run in sequence"
)
github_issue_number: str | None = Field(None, description="Optional explicit GitHub issue number for reference")
@field_validator('selected_commands')
@classmethod
def validate_commands(cls, v: list[str]) -> list[str]:
"""Validate that all commands are valid WorkflowStep values"""
valid = {step.value for step in WorkflowStep}
for cmd in v:
if cmd not in valid:
raise ValueError(f"Invalid command: {cmd}. Must be one of {valid}")
return v
class AgentWorkOrderResponse(BaseModel):
"""Response after creating an agent work order"""
agent_work_order_id: str
status: AgentWorkOrderStatus
message: str
class AgentPromptRequest(BaseModel):
"""Request to send a prompt to a running agent"""
agent_work_order_id: str
prompt_text: str
class GitProgressSnapshot(BaseModel):
"""Git progress information for UI display"""
agent_work_order_id: str
current_phase: AgentWorkflowPhase
git_commit_count: int
git_files_changed: int
latest_commit_message: str | None = None
git_branch_name: str | None = None
class GitHubRepositoryVerificationRequest(BaseModel):
"""Request to verify GitHub repository access"""
repository_url: str
class GitHubRepositoryVerificationResponse(BaseModel):
"""Response from repository verification"""
is_accessible: bool
repository_name: str | None = None
repository_owner: str | None = None
default_branch: str | None = None
error_message: str | None = None
class GitHubRepository(BaseModel):
"""GitHub repository information"""
name: str
owner: str
default_branch: str
url: str
class ConfiguredRepository(BaseModel):
"""Configured repository with metadata and preferences
Stores GitHub repository configuration for Agent Work Orders, including
verification status, metadata extracted from GitHub API, and per-repository
preferences for sandbox type and workflow commands.
"""
id: str = Field(..., description="Unique UUID identifier for the configured repository")
repository_url: str = Field(..., description="GitHub repository URL (https://github.com/owner/repo format)")
display_name: str | None = Field(None, description="Human-readable repository name (e.g., 'owner/repo-name')")
owner: str | None = Field(None, description="Repository owner/organization name")
default_branch: str | None = Field(None, description="Default branch name (e.g., 'main' or 'master')")
is_verified: bool = Field(default=False, description="Boolean flag indicating if repository access has been verified")
last_verified_at: datetime | None = Field(None, description="Timestamp of last successful repository verification")
default_sandbox_type: SandboxType = Field(
default=SandboxType.GIT_WORKTREE,
description="Default sandbox type for work orders: git_worktree (default), full_clone, or tmp_dir"
)
default_commands: list[WorkflowStep] = Field(
default=[
WorkflowStep.CREATE_BRANCH,
WorkflowStep.PLANNING,
WorkflowStep.EXECUTE,
WorkflowStep.COMMIT,
WorkflowStep.CREATE_PR,
],
description="Default workflow commands for work orders"
)
created_at: datetime = Field(..., description="Timestamp when repository configuration was created")
updated_at: datetime = Field(..., description="Timestamp when repository configuration was last updated")
class CreateRepositoryRequest(BaseModel):
"""Request to create a new configured repository
Creates a new repository configuration. If verify=True, the system will
call the GitHub API to validate repository access and extract metadata
(display_name, owner, default_branch) before storing.
"""
repository_url: str = Field(..., description="GitHub repository URL to configure")
verify: bool = Field(
default=True,
description="Whether to verify repository access via GitHub API and extract metadata"
)
class UpdateRepositoryRequest(BaseModel):
"""Request to update an existing configured repository
All fields are optional for partial updates. Only provided fields will be
updated in the database.
"""
default_sandbox_type: SandboxType | None = Field(
None,
description="Update the default sandbox type for this repository"
)
default_commands: list[WorkflowStep] | None = Field(
None,
description="Update the default workflow commands for this repository"
)
class GitHubPullRequest(BaseModel):
"""GitHub pull request information"""
pull_request_url: str
pull_request_number: int
title: str
head_branch: str
base_branch: str
class GitHubIssue(BaseModel):
"""GitHub issue information"""
number: int
title: str
body: str | None = None
state: str
html_url: str
class CommandExecutionResult(BaseModel):
"""Result from command execution"""
success: bool
stdout: str | None = None
# Extracted result text from JSONL "result" field (if available)
result_text: str | None = None
stderr: str | None = None
exit_code: int
session_id: str | None = None
error_message: str | None = None
duration_seconds: float | None = None
class StepExecutionResult(BaseModel):
"""Result of executing a single workflow step"""
step: WorkflowStep
agent_name: str
success: bool
output: str | None = None
error_message: str | None = None
duration_seconds: float
session_id: str | None = None
timestamp: datetime = Field(default_factory=datetime.now)
class StepHistory(BaseModel):
"""History of all step executions for a work order"""
agent_work_order_id: str
steps: list[StepExecutionResult] = []
def get_current_step(self) -> WorkflowStep | None:
"""Get next step to execute"""
if not self.steps:
return WorkflowStep.CREATE_BRANCH
last_step = self.steps[-1]
if not last_step.success:
return last_step.step # Retry failed step
step_sequence = [
WorkflowStep.CREATE_BRANCH,
WorkflowStep.PLANNING,
WorkflowStep.EXECUTE,
WorkflowStep.COMMIT,
WorkflowStep.CREATE_PR,
]
try:
current_index = step_sequence.index(last_step.step)
if current_index < len(step_sequence) - 1:
return step_sequence[current_index + 1]
except ValueError:
pass
return None # All steps complete
class CommandNotFoundError(Exception):
"""Raised when a command file is not found"""
pass
class WorkflowExecutionError(Exception):
"""Raised when workflow execution fails"""
pass
class SandboxSetupError(Exception):
"""Raised when sandbox setup fails"""
pass
class GitHubOperationError(Exception):
"""Raised when GitHub operation fails"""
pass

View File

@@ -0,0 +1,4 @@
"""Sandbox Manager Module
Provides isolated execution environments for agents.
"""

View File

@@ -0,0 +1,179 @@
"""Git Branch Sandbox Implementation
Provides isolated execution environment using git branches.
Agent creates the branch during execution (git-first philosophy).
"""
import asyncio
import shutil
import time
from pathlib import Path
from ..config import config
from ..models import CommandExecutionResult, SandboxSetupError
from ..utils.git_operations import get_current_branch
from ..utils.structured_logger import get_logger
logger = get_logger(__name__)
class GitBranchSandbox:
"""Git branch-based sandbox implementation
Creates a temporary clone of the repository where the agent
executes workflows. Agent creates branches during execution.
"""
def __init__(self, repository_url: str, sandbox_identifier: str):
self.repository_url = repository_url
self.sandbox_identifier = sandbox_identifier
self.working_dir = str(
config.ensure_temp_dir() / sandbox_identifier
)
self._logger = logger.bind(
sandbox_identifier=sandbox_identifier,
repository_url=repository_url,
)
async def setup(self) -> None:
"""Clone repository to temporary directory
Does NOT create a branch - agent creates branch during execution.
"""
self._logger.info("sandbox_setup_started")
try:
# Clone repository
process = await asyncio.create_subprocess_exec(
"git",
"clone",
self.repository_url,
self.working_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown git error"
self._logger.error(
"sandbox_setup_failed",
error=error_msg,
returncode=process.returncode,
)
raise SandboxSetupError(f"Failed to clone repository: {error_msg}")
self._logger.info("sandbox_setup_completed", working_dir=self.working_dir)
except Exception as e:
self._logger.error("sandbox_setup_failed", error=str(e), exc_info=True)
raise SandboxSetupError(f"Sandbox setup failed: {e}") from e
async def execute_command(
self, command: str, timeout: int = 300
) -> CommandExecutionResult:
"""Execute command in the sandbox directory
Args:
command: Shell command to execute
timeout: Timeout in seconds
Returns:
CommandExecutionResult
"""
self._logger.info("command_execution_started", command=command)
start_time = time.time()
try:
process = await asyncio.create_subprocess_shell(
command,
cwd=self.working_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
except TimeoutError:
process.kill()
await process.wait()
duration = time.time() - start_time
self._logger.error(
"command_execution_timeout", command=command, timeout=timeout
)
return CommandExecutionResult(
success=False,
stdout=None,
stderr=None,
exit_code=-1,
error_message=f"Command timed out after {timeout}s",
duration_seconds=duration,
)
duration = time.time() - start_time
success = process.returncode == 0
result = CommandExecutionResult(
success=success,
stdout=stdout.decode() if stdout else None,
stderr=stderr.decode() if stderr else None,
exit_code=process.returncode or 0,
error_message=None if success else stderr.decode() if stderr else "Command failed",
duration_seconds=duration,
)
if success:
self._logger.info(
"command_execution_completed", command=command, duration=duration
)
else:
self._logger.error(
"command_execution_failed",
command=command,
exit_code=process.returncode,
duration=duration,
)
return result
except Exception as e:
duration = time.time() - start_time
self._logger.error(
"command_execution_error", command=command, error=str(e), exc_info=True
)
return CommandExecutionResult(
success=False,
stdout=None,
stderr=None,
exit_code=-1,
error_message=str(e),
duration_seconds=duration,
)
async def get_git_branch_name(self) -> str | None:
"""Get current git branch name in sandbox
Returns:
Current branch name or None
"""
try:
return await get_current_branch(self.working_dir)
except Exception as e:
self._logger.error("git_branch_query_failed", error=str(e))
return None
async def cleanup(self) -> None:
"""Remove temporary sandbox directory"""
self._logger.info("sandbox_cleanup_started")
try:
path = Path(self.working_dir)
if path.exists():
shutil.rmtree(path)
self._logger.info("sandbox_cleanup_completed")
else:
self._logger.warning("sandbox_cleanup_skipped", reason="Directory does not exist")
except Exception as e:
self._logger.error("sandbox_cleanup_failed", error=str(e), exc_info=True)

View File

@@ -0,0 +1,219 @@
"""Git Worktree Sandbox Implementation
Provides isolated execution environment using git worktrees.
Enables parallel execution of multiple work orders without conflicts.
"""
import asyncio
import time
from ..models import CommandExecutionResult, SandboxSetupError
from ..utils.git_operations import get_current_branch
from ..utils.port_allocation import find_available_port_range
from ..utils.structured_logger import get_logger
from ..utils.worktree_operations import (
create_worktree,
get_worktree_path,
remove_worktree,
setup_worktree_environment,
)
logger = get_logger(__name__)
class GitWorktreeSandbox:
"""Git worktree-based sandbox implementation
Creates a git worktree under trees/<work_order_id>/ where the agent
executes workflows. Enables parallel execution with isolated environments
and deterministic port allocation.
"""
def __init__(self, repository_url: str, sandbox_identifier: str):
self.repository_url = repository_url
self.sandbox_identifier = sandbox_identifier
self.working_dir = get_worktree_path(repository_url, sandbox_identifier)
self.port_range_start: int | None = None
self.port_range_end: int | None = None
self.available_ports: list[int] = []
self._logger = logger.bind(
sandbox_identifier=sandbox_identifier,
repository_url=repository_url,
)
async def setup(self) -> None:
"""Create worktree and set up isolated environment
Creates worktree from origin/main and allocates a port range.
Each work order gets 10 ports for flexibility.
"""
self._logger.info("worktree_sandbox_setup_started")
try:
# Allocate port range deterministically
self.port_range_start, self.port_range_end, self.available_ports = find_available_port_range(
self.sandbox_identifier
)
self._logger.info(
"port_range_allocated",
port_range_start=self.port_range_start,
port_range_end=self.port_range_end,
available_ports_count=len(self.available_ports),
)
# Create worktree with temporary branch name
# Agent will create the actual feature branch during execution
temp_branch = f"wo-{self.sandbox_identifier}"
worktree_path, error = create_worktree(
self.repository_url,
self.sandbox_identifier,
temp_branch,
self._logger
)
if error or not worktree_path:
raise SandboxSetupError(f"Failed to create worktree: {error}")
# Set up environment with port configuration
setup_worktree_environment(
worktree_path,
self.port_range_start,
self.port_range_end,
self.available_ports,
self._logger
)
self._logger.info(
"worktree_sandbox_setup_completed",
working_dir=self.working_dir,
port_range=f"{self.port_range_start}-{self.port_range_end}",
available_ports_count=len(self.available_ports),
)
except Exception as e:
self._logger.error(
"worktree_sandbox_setup_failed",
error=str(e),
exc_info=True
)
raise SandboxSetupError(f"Worktree sandbox setup failed: {e}") from e
async def execute_command(
self, command: str, timeout: int = 300
) -> CommandExecutionResult:
"""Execute command in the worktree directory
Args:
command: Shell command to execute
timeout: Timeout in seconds
Returns:
CommandExecutionResult
"""
self._logger.info("command_execution_started", command=command)
start_time = time.time()
try:
process = await asyncio.create_subprocess_shell(
command,
cwd=self.working_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
except TimeoutError:
process.kill()
await process.wait()
duration = time.time() - start_time
self._logger.error(
"command_execution_timeout", command=command, timeout=timeout
)
return CommandExecutionResult(
success=False,
stdout=None,
stderr=None,
exit_code=-1,
error_message=f"Command timed out after {timeout}s",
duration_seconds=duration,
)
duration = time.time() - start_time
success = process.returncode == 0
result = CommandExecutionResult(
success=success,
stdout=stdout.decode() if stdout else None,
stderr=stderr.decode() if stderr else None,
exit_code=process.returncode or 0,
error_message=None if success else stderr.decode() if stderr else "Command failed",
duration_seconds=duration,
)
if success:
self._logger.info(
"command_execution_completed", command=command, duration=duration
)
else:
self._logger.error(
"command_execution_failed",
command=command,
exit_code=process.returncode,
duration=duration,
)
return result
except Exception as e:
duration = time.time() - start_time
self._logger.error(
"command_execution_error", command=command, error=str(e), exc_info=True
)
return CommandExecutionResult(
success=False,
stdout=None,
stderr=None,
exit_code=-1,
error_message=str(e),
duration_seconds=duration,
)
async def get_git_branch_name(self) -> str | None:
"""Get current git branch name in worktree
Returns:
Current branch name or None
"""
try:
return await get_current_branch(self.working_dir)
except Exception as e:
self._logger.error("git_branch_query_failed", error=str(e))
return None
async def cleanup(self) -> None:
"""Remove worktree"""
self._logger.info("worktree_sandbox_cleanup_started")
try:
success, error = remove_worktree(
self.repository_url,
self.sandbox_identifier,
self._logger
)
if success:
self._logger.info("worktree_sandbox_cleanup_completed")
else:
self._logger.error(
"worktree_sandbox_cleanup_failed",
error=error
)
except Exception as e:
self._logger.error(
"worktree_sandbox_cleanup_failed",
error=str(e),
exc_info=True
)

View File

@@ -0,0 +1,43 @@
"""Sandbox Factory
Creates appropriate sandbox instances based on sandbox type.
"""
from ..models import SandboxType
from .git_branch_sandbox import GitBranchSandbox
from .git_worktree_sandbox import GitWorktreeSandbox
from .sandbox_protocol import AgentSandbox
class SandboxFactory:
"""Factory for creating sandbox instances"""
def create_sandbox(
self,
sandbox_type: SandboxType,
repository_url: str,
sandbox_identifier: str,
) -> AgentSandbox:
"""Create a sandbox instance
Args:
sandbox_type: Type of sandbox to create
repository_url: Git repository URL
sandbox_identifier: Unique identifier for this sandbox
Returns:
AgentSandbox instance
Raises:
NotImplementedError: If sandbox type is not yet implemented
"""
if sandbox_type == SandboxType.GIT_BRANCH:
return GitBranchSandbox(repository_url, sandbox_identifier)
elif sandbox_type == SandboxType.GIT_WORKTREE:
return GitWorktreeSandbox(repository_url, sandbox_identifier)
elif sandbox_type == SandboxType.E2B:
raise NotImplementedError("E2B sandbox not implemented (Phase 2+)")
elif sandbox_type == SandboxType.DAGGER:
raise NotImplementedError("Dagger sandbox not implemented (Phase 2+)")
else:
raise ValueError(f"Unknown sandbox type: {sandbox_type}")

View File

@@ -0,0 +1,56 @@
"""Sandbox Protocol
Defines the interface that all sandbox implementations must follow.
"""
from typing import Protocol
from ..models import CommandExecutionResult
class AgentSandbox(Protocol):
"""Protocol for agent sandbox implementations
All sandbox types must implement this interface to provide
isolated execution environments for agents.
"""
sandbox_identifier: str
repository_url: str
working_dir: str
async def setup(self) -> None:
"""Set up the sandbox environment
This should prepare the sandbox for agent execution.
For git-based sandboxes, this typically clones the repository.
Does NOT create a branch - agent creates branch during execution.
"""
...
async def execute_command(self, command: str, timeout: int = 300) -> CommandExecutionResult:
"""Execute a command in the sandbox
Args:
command: Shell command to execute
timeout: Timeout in seconds
Returns:
CommandExecutionResult with execution details
"""
...
async def get_git_branch_name(self) -> str | None:
"""Get the current git branch name
Returns:
Current branch name or None if no branch is checked out
"""
...
async def cleanup(self) -> None:
"""Clean up the sandbox environment
This should remove temporary files and directories.
"""
...

View File

@@ -0,0 +1,280 @@
"""Standalone Server Entry Point
FastAPI server for independent agent work order service.
"""
import os
import shutil
import subprocess
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import httpx
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .api.routes import log_buffer, router
from .config import config
from .database.client import check_database_health
from .utils.structured_logger import (
configure_structured_logging_with_buffer,
get_logger,
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Lifespan context manager for startup and shutdown tasks"""
# Configure structured logging with buffer for SSE streaming
configure_structured_logging_with_buffer(config.LOG_LEVEL, log_buffer)
logger = get_logger(__name__)
logger.info(
"Starting Agent Work Orders service",
extra={
"port": os.getenv("AGENT_WORK_ORDERS_PORT", "8053"),
"service_discovery_mode": os.getenv("SERVICE_DISCOVERY_MODE", "local"),
},
)
# Start log buffer cleanup task
await log_buffer.start_cleanup_task()
# Validate Claude CLI is available
try:
result = subprocess.run(
[config.CLAUDE_CLI_PATH, "--version"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
logger.info(
"Claude CLI validation successful",
extra={"version": result.stdout.strip()},
)
else:
logger.error(
"Claude CLI validation failed",
extra={"error": result.stderr},
)
except FileNotFoundError:
logger.error(
"Claude CLI not found",
extra={"path": config.CLAUDE_CLI_PATH},
)
except Exception as e:
logger.error(
"Claude CLI validation error",
extra={"error": str(e)},
)
# Validate git is available
if not shutil.which("git"):
logger.error("Git not found in PATH")
else:
logger.info("Git validation successful")
# Log service URLs
archon_server_url = os.getenv("ARCHON_SERVER_URL")
archon_mcp_url = os.getenv("ARCHON_MCP_URL")
if archon_server_url:
logger.info(
"Service discovery configured",
extra={
"archon_server_url": archon_server_url,
"archon_mcp_url": archon_mcp_url,
},
)
yield
logger.info("Shutting down Agent Work Orders service")
# Stop log buffer cleanup task
await log_buffer.stop_cleanup_task()
# Create FastAPI app with lifespan
app = FastAPI(
title="Agent Work Orders API",
description="Independent agent work order service for workflow-based agent execution",
version="0.1.0",
lifespan=lifespan,
)
# CORS middleware with permissive settings for development
cors_origins = os.getenv("CORS_ORIGINS", "*").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routes with /api/agent-work-orders prefix
app.include_router(router, prefix="/api/agent-work-orders")
@app.get("/health")
async def health_check() -> dict[str, Any]:
"""Health check endpoint with dependency validation"""
health_status: dict[str, Any] = {
"status": "healthy",
"service": "agent-work-orders",
"version": "0.1.0",
"enabled": config.ENABLED,
"dependencies": {},
}
# If feature is not enabled, return early with healthy status
# (disabled features are healthy - they're just not active)
if not config.ENABLED:
health_status["message"] = "Agent work orders feature is disabled. Set ENABLE_AGENT_WORK_ORDERS=true to enable."
return health_status
# Check Claude CLI
try:
result = subprocess.run(
[config.CLAUDE_CLI_PATH, "--version"],
capture_output=True,
text=True,
timeout=5,
)
health_status["dependencies"]["claude_cli"] = {
"available": result.returncode == 0,
"version": result.stdout.strip() if result.returncode == 0 else None,
}
except Exception as e:
health_status["dependencies"]["claude_cli"] = {
"available": False,
"error": str(e),
}
# Check git
health_status["dependencies"]["git"] = {
"available": shutil.which("git") is not None,
}
# Check GitHub CLI authentication
try:
result = subprocess.run(
[config.GH_CLI_PATH, "auth", "status"],
capture_output=True,
text=True,
timeout=5,
)
# gh auth status returns 0 if authenticated
health_status["dependencies"]["github_cli"] = {
"available": shutil.which(config.GH_CLI_PATH) is not None,
"authenticated": result.returncode == 0,
"token_configured": os.getenv("GH_TOKEN") is not None or os.getenv("GITHUB_TOKEN") is not None,
}
except Exception as e:
health_status["dependencies"]["github_cli"] = {
"available": False,
"authenticated": False,
"error": str(e),
}
# Check Archon server connectivity (if configured)
archon_server_url = os.getenv("ARCHON_SERVER_URL")
if archon_server_url:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{archon_server_url}/health")
health_status["dependencies"]["archon_server"] = {
"available": response.status_code == 200,
"url": archon_server_url,
}
except Exception as e:
health_status["dependencies"]["archon_server"] = {
"available": False,
"url": archon_server_url,
"error": str(e),
}
# Check database health if using Supabase storage
if config.STATE_STORAGE_TYPE.lower() == "supabase":
db_health = await check_database_health()
health_status["storage_type"] = "supabase"
health_status["database"] = db_health
else:
health_status["storage_type"] = config.STATE_STORAGE_TYPE
# Check MCP server connectivity (if configured)
archon_mcp_url = os.getenv("ARCHON_MCP_URL")
if archon_mcp_url:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{archon_mcp_url}/health")
health_status["dependencies"]["archon_mcp"] = {
"available": response.status_code == 200,
"url": archon_mcp_url,
}
except Exception as e:
health_status["dependencies"]["archon_mcp"] = {
"available": False,
"url": archon_mcp_url,
"error": str(e),
}
# Check Supabase database connectivity (if configured)
supabase_url = os.getenv("SUPABASE_URL")
if supabase_url:
try:
from .state_manager.repository_config_repository import get_supabase_client
client = get_supabase_client()
# Check if archon_configured_repositories table exists
response = client.table("archon_configured_repositories").select("id").limit(1).execute()
health_status["dependencies"]["supabase"] = {
"available": True,
"table_exists": True,
"url": supabase_url.split("@")[-1] if "@" in supabase_url else supabase_url.split("//")[-1],
}
except Exception as e:
health_status["dependencies"]["supabase"] = {
"available": False,
"table_exists": False,
"error": str(e),
}
# Determine overall status
critical_deps_ok = (
health_status["dependencies"].get("claude_cli", {}).get("available", False)
and health_status["dependencies"].get("git", {}).get("available", False)
)
if not critical_deps_ok:
health_status["status"] = "degraded"
return health_status
@app.get("/")
async def root() -> dict:
"""Root endpoint with service information"""
return {
"service": "agent-work-orders",
"version": "0.1.0",
"description": "Independent agent work order service",
"docs": "/docs",
"health": "/health",
"api": "/api/agent-work-orders",
}
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("AGENT_WORK_ORDERS_PORT", "8053"))
uvicorn.run(
"src.agent_work_orders.server:app",
host="0.0.0.0",
port=port,
reload=True,
)

View File

@@ -0,0 +1,15 @@
"""State Manager Module
Manages agent work order state with pluggable storage backends.
Supports both in-memory (development) and file-based (production) storage.
"""
from .file_state_repository import FileStateRepository
from .repository_factory import create_repository
from .work_order_repository import WorkOrderRepository
__all__ = [
"WorkOrderRepository",
"FileStateRepository",
"create_repository",
]

View File

@@ -0,0 +1,343 @@
"""File-based Work Order Repository
Provides persistent JSON-based storage for agent work orders.
Enables state persistence across service restarts and debugging.
"""
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from ..models import AgentWorkOrderState, AgentWorkOrderStatus, StepHistory
from ..utils.structured_logger import get_logger
if TYPE_CHECKING:
import structlog
logger = get_logger(__name__)
class FileStateRepository:
"""File-based repository for work order state
Stores state as JSON files in <state_directory>/<work_order_id>.json
Each file contains: state, metadata, and step_history
"""
def __init__(self, state_directory: str):
self.state_directory = Path(state_directory)
self.state_directory.mkdir(parents=True, exist_ok=True)
self._lock = asyncio.Lock()
self._logger: structlog.stdlib.BoundLogger = logger.bind(
state_directory=str(self.state_directory)
)
self._logger.info("file_state_repository_initialized")
def _get_state_file_path(self, agent_work_order_id: str) -> Path:
"""Get path to state file for work order
Args:
agent_work_order_id: Work order ID
Returns:
Path to state file
"""
return self.state_directory / f"{agent_work_order_id}.json"
def _serialize_datetime(self, obj):
"""JSON serializer for datetime objects
Args:
obj: Object to serialize
Returns:
ISO format string for datetime objects
"""
if isinstance(obj, datetime):
return obj.isoformat()
raise TypeError(f"Type {type(obj)} not serializable")
async def _read_state_file(self, agent_work_order_id: str) -> dict[str, Any] | None:
"""Read state file
Args:
agent_work_order_id: Work order ID
Returns:
State dictionary or None if file doesn't exist
"""
state_file = self._get_state_file_path(agent_work_order_id)
if not state_file.exists():
return None
try:
with state_file.open("r") as f:
data = json.load(f)
return cast(dict[str, Any], data)
except Exception as e:
self._logger.error(
"state_file_read_failed",
agent_work_order_id=agent_work_order_id,
error=str(e),
exc_info=True
)
return None
async def _write_state_file(self, agent_work_order_id: str, data: dict[str, Any]) -> None:
"""Write state file
Args:
agent_work_order_id: Work order ID
data: State dictionary to write
"""
state_file = self._get_state_file_path(agent_work_order_id)
try:
with state_file.open("w") as f:
json.dump(data, f, indent=2, default=self._serialize_datetime)
except Exception as e:
self._logger.error(
"state_file_write_failed",
agent_work_order_id=agent_work_order_id,
error=str(e),
exc_info=True
)
raise
async def create(self, work_order: AgentWorkOrderState, metadata: dict[str, Any]) -> None:
"""Create a new work order
Args:
work_order: Core work order state
metadata: Additional metadata (status, workflow_type, etc.)
"""
async with self._lock:
data = {
"state": work_order.model_dump(mode="json"),
"metadata": metadata,
"step_history": None
}
await self._write_state_file(work_order.agent_work_order_id, data)
self._logger.info(
"work_order_created",
agent_work_order_id=work_order.agent_work_order_id,
)
async def get(self, agent_work_order_id: str) -> tuple[AgentWorkOrderState, dict[str, Any]] | None:
"""Get a work order by ID
Args:
agent_work_order_id: Work order ID
Returns:
Tuple of (state, metadata) or None if not found
"""
async with self._lock:
data = await self._read_state_file(agent_work_order_id)
if not data:
return None
state = AgentWorkOrderState(**data["state"])
metadata = data["metadata"]
return (state, metadata)
async def list(self, status_filter: AgentWorkOrderStatus | None = None) -> list[tuple[AgentWorkOrderState, dict[str, Any]]]:
"""List all work orders
Args:
status_filter: Optional status to filter by
Returns:
List of (state, metadata) tuples
"""
async with self._lock:
results = []
# Iterate over all JSON files in state directory
for state_file in self.state_directory.glob("*.json"):
try:
with state_file.open("r") as f:
data = json.load(f)
state = AgentWorkOrderState(**data["state"])
metadata = data["metadata"]
if status_filter is None or metadata.get("status") == status_filter:
results.append((state, metadata))
except Exception as e:
self._logger.error(
"state_file_load_failed",
file=str(state_file),
error=str(e)
)
continue
return results
async def update_status(
self,
agent_work_order_id: str,
status: AgentWorkOrderStatus,
**kwargs,
) -> None:
"""Update work order status and other fields
Args:
agent_work_order_id: Work order ID
status: New status
**kwargs: Additional fields to update
"""
async with self._lock:
data = await self._read_state_file(agent_work_order_id)
if not data:
self._logger.warning(
"work_order_not_found_for_update",
agent_work_order_id=agent_work_order_id
)
return
data["metadata"]["status"] = status
data["metadata"]["updated_at"] = datetime.now().isoformat()
for key, value in kwargs.items():
data["metadata"][key] = value
await self._write_state_file(agent_work_order_id, data)
self._logger.info(
"work_order_status_updated",
agent_work_order_id=agent_work_order_id,
status=status.value,
)
async def update_git_branch(
self, agent_work_order_id: str, git_branch_name: str
) -> None:
"""Update git branch name in state
Args:
agent_work_order_id: Work order ID
git_branch_name: Git branch name
"""
async with self._lock:
data = await self._read_state_file(agent_work_order_id)
if not data:
self._logger.warning(
"work_order_not_found_for_update",
agent_work_order_id=agent_work_order_id
)
return
data["state"]["git_branch_name"] = git_branch_name
data["metadata"]["updated_at"] = datetime.now().isoformat()
await self._write_state_file(agent_work_order_id, data)
self._logger.info(
"work_order_git_branch_updated",
agent_work_order_id=agent_work_order_id,
git_branch_name=git_branch_name,
)
async def update_session_id(
self, agent_work_order_id: str, agent_session_id: str
) -> None:
"""Update agent session ID in state
Args:
agent_work_order_id: Work order ID
agent_session_id: Claude CLI session ID
"""
async with self._lock:
data = await self._read_state_file(agent_work_order_id)
if not data:
self._logger.warning(
"work_order_not_found_for_update",
agent_work_order_id=agent_work_order_id
)
return
data["state"]["agent_session_id"] = agent_session_id
data["metadata"]["updated_at"] = datetime.now().isoformat()
await self._write_state_file(agent_work_order_id, data)
self._logger.info(
"work_order_session_id_updated",
agent_work_order_id=agent_work_order_id,
agent_session_id=agent_session_id,
)
async def save_step_history(
self, agent_work_order_id: str, step_history: StepHistory
) -> None:
"""Save step execution history
Args:
agent_work_order_id: Work order ID
step_history: Step execution history
"""
async with self._lock:
data = await self._read_state_file(agent_work_order_id)
if not data:
# Create minimal state if doesn't exist
data = {
"state": {"agent_work_order_id": agent_work_order_id},
"metadata": {},
"step_history": None
}
data["step_history"] = step_history.model_dump(mode="json")
await self._write_state_file(agent_work_order_id, data)
self._logger.info(
"step_history_saved",
agent_work_order_id=agent_work_order_id,
step_count=len(step_history.steps),
)
async def get_step_history(self, agent_work_order_id: str) -> StepHistory | None:
"""Get step execution history
Args:
agent_work_order_id: Work order ID
Returns:
Step history or None if not found
"""
async with self._lock:
data = await self._read_state_file(agent_work_order_id)
if not data or not data.get("step_history"):
return None
return StepHistory(**data["step_history"])
async def delete(self, agent_work_order_id: str) -> None:
"""Delete a work order state file
Args:
agent_work_order_id: Work order ID
"""
async with self._lock:
state_file = self._get_state_file_path(agent_work_order_id)
if state_file.exists():
state_file.unlink()
self._logger.info(
"work_order_deleted",
agent_work_order_id=agent_work_order_id
)
def list_state_ids(self) -> "list[str]": # type: ignore[valid-type]
"""List all work order IDs with state files
Returns:
List of work order IDs
"""
return [f.stem for f in self.state_directory.glob("*.json")]

View File

@@ -0,0 +1,351 @@
"""Repository Configuration Repository
Provides database operations for managing configured GitHub repositories.
Stores repository metadata, verification status, and per-repository preferences.
"""
import os
from datetime import datetime
from typing import Any
from supabase import Client, create_client
from ..models import ConfiguredRepository, SandboxType, WorkflowStep
from ..utils.structured_logger import get_logger
logger = get_logger(__name__)
def get_supabase_client() -> Client:
"""Get a Supabase client instance for agent work orders.
Returns:
Supabase client instance
Raises:
ValueError: If environment variables are not set
"""
url = os.getenv("SUPABASE_URL")
key = os.getenv("SUPABASE_SERVICE_KEY")
if not url or not key:
raise ValueError(
"SUPABASE_URL and SUPABASE_SERVICE_KEY must be set in environment variables"
)
return create_client(url, key)
class RepositoryConfigRepository:
"""Repository for managing configured repositories in Supabase
Provides CRUD operations for the archon_configured_repositories table.
Uses the same Supabase client as the main Archon server for consistency.
Architecture Note - async/await Pattern:
All repository methods are declared as `async def` for interface consistency
with other repository implementations (FileStateRepository, WorkOrderRepository),
even though the Supabase Python client's operations are synchronous.
This design choice maintains a consistent async API contract across all
repository implementations, allowing them to be used interchangeably without
caller code changes. The async signature enables future migration to truly
async database clients (e.g., asyncpg) without breaking the interface.
Current behavior: Methods don't await Supabase operations (which are sync),
but callers should still await repository method calls for forward compatibility.
"""
def __init__(self) -> None:
"""Initialize repository with Supabase client"""
self.client: Client = get_supabase_client()
self.table_name: str = "archon_configured_repositories"
self._logger = logger.bind(table=self.table_name)
self._logger.info("repository_config_repository_initialized")
def _row_to_model(self, row: dict[str, Any]) -> ConfiguredRepository:
"""Convert database row to ConfiguredRepository model
Args:
row: Database row dictionary
Returns:
ConfiguredRepository model instance
Raises:
ValueError: If row contains invalid enum values that cannot be converted
"""
repository_id = row.get("id", "unknown")
# Convert default_commands from list of strings to list of WorkflowStep enums
default_commands_raw = row.get("default_commands", [])
try:
default_commands = [WorkflowStep(cmd) for cmd in default_commands_raw]
except ValueError as e:
self._logger.error(
"invalid_workflow_step_in_database",
repository_id=repository_id,
invalid_commands=default_commands_raw,
error=str(e),
exc_info=True
)
raise ValueError(
f"Database contains invalid workflow steps for repository {repository_id}: {default_commands_raw}"
) from e
# Convert default_sandbox_type from string to SandboxType enum
sandbox_type_raw = row.get("default_sandbox_type", "git_worktree")
try:
sandbox_type = SandboxType(sandbox_type_raw)
except ValueError as e:
self._logger.error(
"invalid_sandbox_type_in_database",
repository_id=repository_id,
invalid_type=sandbox_type_raw,
error=str(e),
exc_info=True
)
raise ValueError(
f"Database contains invalid sandbox type for repository {repository_id}: {sandbox_type_raw}"
) from e
return ConfiguredRepository(
id=row["id"],
repository_url=row["repository_url"],
display_name=row.get("display_name"),
owner=row.get("owner"),
default_branch=row.get("default_branch"),
is_verified=row.get("is_verified", False),
last_verified_at=row.get("last_verified_at"),
default_sandbox_type=sandbox_type,
default_commands=default_commands,
created_at=row["created_at"],
updated_at=row["updated_at"],
)
async def list_repositories(self) -> list[ConfiguredRepository]:
"""List all configured repositories
Returns:
List of ConfiguredRepository models ordered by created_at DESC
Raises:
Exception: If database query fails
"""
try:
response = self.client.table(self.table_name).select("*").order("created_at", desc=True).execute()
repositories = [self._row_to_model(row) for row in response.data]
self._logger.info(
"repositories_listed",
count=len(repositories)
)
return repositories
except Exception as e:
self._logger.exception(
"list_repositories_failed",
error=str(e)
)
raise
async def get_repository(self, repository_id: str) -> ConfiguredRepository | None:
"""Get a single repository by ID
Args:
repository_id: UUID of the repository
Returns:
ConfiguredRepository model or None if not found
Raises:
Exception: If database query fails
"""
try:
response = self.client.table(self.table_name).select("*").eq("id", repository_id).execute()
if not response.data:
self._logger.info(
"repository_not_found",
repository_id=repository_id
)
return None
repository = self._row_to_model(response.data[0])
self._logger.info(
"repository_retrieved",
repository_id=repository_id,
repository_url=repository.repository_url
)
return repository
except Exception as e:
self._logger.exception(
"get_repository_failed",
repository_id=repository_id,
error=str(e)
)
raise
async def create_repository(
self,
repository_url: str,
display_name: str | None = None,
owner: str | None = None,
default_branch: str | None = None,
is_verified: bool = False,
) -> ConfiguredRepository:
"""Create a new configured repository
Args:
repository_url: GitHub repository URL
display_name: Human-readable repository name (e.g., "owner/repo")
owner: Repository owner/organization
default_branch: Default branch name (e.g., "main")
is_verified: Whether repository access has been verified
Returns:
Created ConfiguredRepository model
Raises:
Exception: If database insert fails (e.g., unique constraint violation)
"""
try:
# Prepare data for insertion
data: dict[str, Any] = {
"repository_url": repository_url,
"display_name": display_name,
"owner": owner,
"default_branch": default_branch,
"is_verified": is_verified,
}
# Set last_verified_at if verified
if is_verified:
data["last_verified_at"] = datetime.now().isoformat()
response = self.client.table(self.table_name).insert(data).execute()
repository = self._row_to_model(response.data[0])
self._logger.info(
"repository_created",
repository_id=repository.id,
repository_url=repository_url,
is_verified=is_verified
)
return repository
except Exception as e:
self._logger.exception(
"create_repository_failed",
repository_url=repository_url,
error=str(e)
)
raise
async def update_repository(
self,
repository_id: str,
**updates: Any
) -> ConfiguredRepository | None:
"""Update an existing repository
Args:
repository_id: UUID of the repository
**updates: Fields to update (any valid column name)
Returns:
Updated ConfiguredRepository model or None if not found
Raises:
Exception: If database update fails
"""
try:
# Convert enum values to strings for database storage
prepared_updates: dict[str, Any] = {}
for key, value in updates.items():
if isinstance(value, SandboxType):
prepared_updates[key] = value.value
elif isinstance(value, list) and value and isinstance(value[0], WorkflowStep):
prepared_updates[key] = [step.value for step in value]
else:
prepared_updates[key] = value
# Always update updated_at timestamp
prepared_updates["updated_at"] = datetime.now().isoformat()
response = (
self.client.table(self.table_name)
.update(prepared_updates)
.eq("id", repository_id)
.execute()
)
if not response.data:
self._logger.info(
"repository_not_found_for_update",
repository_id=repository_id
)
return None
repository = self._row_to_model(response.data[0])
self._logger.info(
"repository_updated",
repository_id=repository_id,
updated_fields=list(updates.keys())
)
return repository
except Exception as e:
self._logger.exception(
"update_repository_failed",
repository_id=repository_id,
error=str(e)
)
raise
async def delete_repository(self, repository_id: str) -> bool:
"""Delete a repository by ID
Args:
repository_id: UUID of the repository
Returns:
True if deleted, False if not found
Raises:
Exception: If database delete fails
"""
try:
response = self.client.table(self.table_name).delete().eq("id", repository_id).execute()
deleted = len(response.data) > 0
if deleted:
self._logger.info(
"repository_deleted",
repository_id=repository_id
)
else:
self._logger.info(
"repository_not_found_for_delete",
repository_id=repository_id
)
return deleted
except Exception as e:
self._logger.exception(
"delete_repository_failed",
repository_id=repository_id,
error=str(e)
)
raise

View File

@@ -0,0 +1,50 @@
"""Repository Factory
Creates appropriate repository instances based on configuration.
Supports in-memory (dev/testing), file-based (legacy), and Supabase (production) storage.
"""
from ..config import config
from ..utils.structured_logger import get_logger
from .file_state_repository import FileStateRepository
from .supabase_repository import SupabaseWorkOrderRepository
from .work_order_repository import WorkOrderRepository
logger = get_logger(__name__)
def create_repository() -> WorkOrderRepository | FileStateRepository | SupabaseWorkOrderRepository:
"""Create a work order repository based on configuration
Returns:
Repository instance (in-memory, file-based, or Supabase)
Raises:
ValueError: If Supabase is configured but credentials are missing
"""
storage_type = config.STATE_STORAGE_TYPE.lower()
if storage_type == "supabase":
logger.info("repository_created", storage_type="supabase")
return SupabaseWorkOrderRepository()
elif storage_type == "file":
state_dir = config.FILE_STATE_DIRECTORY
logger.info(
"repository_created",
storage_type="file",
state_directory=state_dir
)
return FileStateRepository(state_dir)
elif storage_type == "memory":
logger.info(
"repository_created",
storage_type="memory"
)
return WorkOrderRepository()
else:
logger.warning(
"unknown_storage_type",
storage_type=storage_type,
fallback="memory"
)
return WorkOrderRepository()

Some files were not shown because too many files have changed in this diff Show More