mirror of
https://github.com/coleam00/Archon.git
synced 2025-12-24 02:39:17 -05:00
Merge pull request #831 from coleam00/ui/agent-work-order
UI/agent work order
This commit is contained in:
55
.claude/commands/agent-work-orders/commit.md
Normal file
55
.claude/commands/agent-work-orders/commit.md
Normal 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
|
||||||
27
.claude/commands/agent-work-orders/execute.md
Normal file
27
.claude/commands/agent-work-orders/execute.md
Normal 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)
|
||||||
176
.claude/commands/agent-work-orders/noqa.md
Normal file
176
.claude/commands/agent-work-orders/noqa.md
Normal 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)
|
||||||
|
```
|
||||||
176
.claude/commands/agent-work-orders/planning.md
Normal file
176
.claude/commands/agent-work-orders/planning.md
Normal 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`)
|
||||||
28
.claude/commands/agent-work-orders/prime.md
Normal file
28
.claude/commands/agent-work-orders/prime.md
Normal 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.
|
||||||
89
.claude/commands/agent-work-orders/prp-review.md
Normal file
89
.claude/commands/agent-work-orders/prp-review.md
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
33
.claude/commands/agent-work-orders/start-server.md
Normal file
33
.claude/commands/agent-work-orders/start-server.md
Normal 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"
|
||||||
44
.env.example
44
.env.example
@@ -27,15 +27,59 @@ SUPABASE_SERVICE_KEY=
|
|||||||
LOGFIRE_TOKEN=
|
LOGFIRE_TOKEN=
|
||||||
LOG_LEVEL=INFO
|
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=
|
||||||
|
# Generate an OAUTH token in terminal and it will use your Claude OAUTH token from your subscription.
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
# Service Ports Configuration
|
||||||
# These ports are used for external access to the services
|
# These ports are used for external access to the services
|
||||||
HOST=localhost
|
HOST=localhost
|
||||||
ARCHON_SERVER_PORT=8181
|
ARCHON_SERVER_PORT=8181
|
||||||
ARCHON_MCP_PORT=8051
|
ARCHON_MCP_PORT=8051
|
||||||
ARCHON_AGENTS_PORT=8052
|
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_UI_PORT=3737
|
||||||
ARCHON_DOCS_PORT=3838
|
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=true
|
||||||
|
|
||||||
|
# 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
|
# Frontend Configuration
|
||||||
# VITE_ALLOWED_HOSTS: Comma-separated list of additional hosts allowed for Vite dev server
|
# 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
|
# Example: VITE_ALLOWED_HOSTS=192.168.1.100,myhost.local,example.com
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -5,6 +5,9 @@ __pycache__
|
|||||||
PRPs/local
|
PRPs/local
|
||||||
PRPs/completed/
|
PRPs/completed/
|
||||||
PRPs/stories/
|
PRPs/stories/
|
||||||
|
PRPs/examples
|
||||||
|
PRPs/features
|
||||||
|
PRPs/specs
|
||||||
PRPs/reviews/
|
PRPs/reviews/
|
||||||
/logs/
|
/logs/
|
||||||
.zed
|
.zed
|
||||||
@@ -12,6 +15,15 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
UAT/
|
UAT/
|
||||||
|
|
||||||
|
# Temporary validation/report markdown files
|
||||||
|
/*_RESULTS.md
|
||||||
|
/*_SUMMARY.md
|
||||||
|
/*_REPORT.md
|
||||||
|
/*_SUCCESS.md
|
||||||
|
/*_COMPLETION*.md
|
||||||
|
/ACTUAL_*.md
|
||||||
|
/VALIDATION_*.md
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Local release notes testing
|
# Local release notes testing
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -104,12 +104,19 @@ uv run ruff check # Run linter
|
|||||||
uv run ruff check --fix # Auto-fix linting issues
|
uv run ruff check --fix # Auto-fix linting issues
|
||||||
uv run mypy src/ # Type check
|
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 operations
|
||||||
docker compose up --build -d # Start all services
|
docker compose up --build -d # Start all services
|
||||||
docker compose --profile backend up -d # Backend only (for hybrid dev)
|
docker compose --profile backend up -d # Backend only (for hybrid dev)
|
||||||
docker compose logs -f archon-server # View server logs
|
docker compose --profile work-orders up -d # Include agent work orders service
|
||||||
docker compose logs -f archon-mcp # View MCP server logs
|
docker compose logs -f archon-server # View server logs
|
||||||
docker compose restart archon-server # Restart after code changes
|
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 # Stop all services
|
||||||
docker compose down -v # Stop and remove volumes
|
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
|
# 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
|
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
|
# Full Docker mode
|
||||||
make dev-docker # Or: docker compose up --build -d
|
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
|
# Run linters before committing
|
||||||
make lint # Runs both frontend and backend linters
|
make lint # Runs both frontend and backend linters
|
||||||
|
|||||||
93
Makefile
93
Makefile
@@ -5,23 +5,27 @@ SHELL := /bin/bash
|
|||||||
# Docker compose command - prefer newer 'docker compose' plugin over standalone 'docker-compose'
|
# 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")
|
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:
|
help:
|
||||||
@echo "Archon Development Commands"
|
@echo "Archon Development Commands"
|
||||||
@echo "==========================="
|
@echo "==========================="
|
||||||
@echo " make dev - Backend in Docker, frontend local (recommended)"
|
@echo " make dev - Backend in Docker, frontend local (recommended)"
|
||||||
@echo " make dev-docker - Everything in Docker"
|
@echo " make dev-docker - Backend + frontend in Docker"
|
||||||
@echo " make stop - Stop all services"
|
@echo " make dev-docker-full - Everything in Docker (server + mcp + ui + work orders)"
|
||||||
@echo " make test - Run all tests"
|
@echo " make dev-hybrid-work-orders - Server + MCP in Docker, UI + work orders local (2 terminals)"
|
||||||
@echo " make test-fe - Run frontend tests only"
|
@echo " make dev-work-orders - Backend in Docker, agent work orders local, frontend local"
|
||||||
@echo " make test-be - Run backend tests only"
|
@echo " make agent-work-orders - Run agent work orders service locally"
|
||||||
@echo " make lint - Run all linters"
|
@echo " make stop - Stop all services"
|
||||||
@echo " make lint-fe - Run frontend linter only"
|
@echo " make test - Run all tests"
|
||||||
@echo " make lint-be - Run backend linter only"
|
@echo " make test-fe - Run frontend tests only"
|
||||||
@echo " make clean - Remove containers and volumes"
|
@echo " make test-be - Run backend tests only"
|
||||||
@echo " make install - Install dependencies"
|
@echo " make lint - Run all linters"
|
||||||
@echo " make check - Check environment setup"
|
@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 dependencies
|
||||||
install:
|
install:
|
||||||
@@ -54,18 +58,73 @@ dev: check
|
|||||||
VITE_ARCHON_SERVER_HOST=$${HOST:-} \
|
VITE_ARCHON_SERVER_HOST=$${HOST:-} \
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Full Docker development
|
# Full Docker development (backend + frontend, no work orders)
|
||||||
dev-docker: check
|
dev-docker: check
|
||||||
@echo "Starting full Docker environment..."
|
@echo "Starting Docker environment (backend + frontend)..."
|
||||||
@$(COMPOSE) --profile full up -d --build
|
@$(COMPOSE) --profile full up -d --build
|
||||||
@echo "✓ All services running"
|
@echo "✓ Services running"
|
||||||
@echo "Frontend: http://localhost:3737"
|
@echo "Frontend: http://localhost:3737"
|
||||||
@echo "API: http://localhost:8181"
|
@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 all services
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping all services..."
|
@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"
|
@echo "✓ Services stopped"
|
||||||
|
|
||||||
# Run all tests
|
# Run all tests
|
||||||
|
|||||||
1327
PRPs/ai_docs/AGENT_WORK_ORDERS_SSE_AND_ZUSTAND.md
Normal file
1327
PRPs/ai_docs/AGENT_WORK_ORDERS_SSE_AND_ZUSTAND.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,11 @@ components/ # Legacy components (migrating)
|
|||||||
**Purpose**: Document processing, code analysis, project generation
|
**Purpose**: Document processing, code analysis, project generation
|
||||||
**Port**: 8052
|
**Port**: 8052
|
||||||
|
|
||||||
|
### Agent Work Orders (Optional)
|
||||||
|
**Location**: `python/src/agent_work_orders/`
|
||||||
|
**Purpose**: Workflow execution engine using Claude Code CLI
|
||||||
|
**Port**: 8053
|
||||||
|
|
||||||
## API Structure
|
## API Structure
|
||||||
|
|
||||||
### RESTful Endpoints
|
### RESTful Endpoints
|
||||||
|
|||||||
269
PRPs/ai_docs/ZUSTAND_STATE_MANAGEMENT.md
Normal file
269
PRPs/ai_docs/ZUSTAND_STATE_MANAGEMENT.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
Zustand v4 AI Coding Assistant Standards
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
|
||||||
|
These guidelines define how an AI coding assistant should generate, refactor, and reason about Zustand (v4) state management code. They serve as enforceable standards to ensure clarity, consistency, maintainability, and performance across all code suggestions.
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
1. General Rules
|
||||||
|
• Use TypeScript for all Zustand stores.
|
||||||
|
• All stores must be defined with the create() function from Zustand v4.
|
||||||
|
• State must be immutable; never mutate arrays or objects directly.
|
||||||
|
• Use functional updates with set((state) => ...) whenever referencing existing state.
|
||||||
|
• Never use useStore.getState() inside React render logic.
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
2. Store Creation Rules
|
||||||
|
|
||||||
|
Do:
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type CounterStore = {
|
||||||
|
count: number;
|
||||||
|
increment: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCounterStore = create<CounterStore>((set) => ({
|
||||||
|
count: 0,
|
||||||
|
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||||
|
reset: () => set({ count: 0 })
|
||||||
|
}));
|
||||||
|
|
||||||
|
Don’t:
|
||||||
|
• Define stores inline within components.
|
||||||
|
• Create multiple stores for related state when a single one suffices.
|
||||||
|
• Nest stores inside hooks or conditional logic.
|
||||||
|
|
||||||
|
Naming conventions:
|
||||||
|
• Hook: use<Entity>Store (e.g., useUserStore, useThemeStore).
|
||||||
|
• File: same as hook (e.g., useUserStore.ts).
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
3. Store Organization Rules
|
||||||
|
• Each feature (e.g., agent-work-orders, knowledge, settings, etc..) should have its own store file.
|
||||||
|
• Combine complex stores using slices, not nested state.
|
||||||
|
• Use middleware (persist, devtools, immer) only when necessary.
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
|
||||||
|
src/features/knowledge/state/
|
||||||
|
├── knowledgeStore.ts
|
||||||
|
└── slices/ #If necessary
|
||||||
|
├── nameSlice.ts #a name that represents the slice if needed
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
4. Selector and Subscription Rules
|
||||||
|
|
||||||
|
Core Principle: Components should subscribe only to the exact slice of state they need.
|
||||||
|
|
||||||
|
Do:
|
||||||
|
|
||||||
|
const count = useCounterStore((s) => s.count);
|
||||||
|
const increment = useCounterStore((s) => s.increment);
|
||||||
|
|
||||||
|
Don’t:
|
||||||
|
|
||||||
|
const { count, increment } = useCounterStore(); // ❌ Causes unnecessary re-renders
|
||||||
|
|
||||||
|
Additional rules:
|
||||||
|
• Use shallow comparison (shallow) if selecting multiple fields.
|
||||||
|
• Avoid subscribing to derived values that can be computed locally.
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
5. Middleware and Side Effects
|
||||||
|
|
||||||
|
Allowed middleware: persist, devtools, immer, subscribeWithSelector.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
• Never persist volatile or sensitive data (e.g., tokens, temp state).
|
||||||
|
• Configure partialize to persist only essential state.
|
||||||
|
• Guard devtools with environment checks.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, devtools } from 'zustand/middleware';
|
||||||
|
|
||||||
|
export const useSettingsStore = create(
|
||||||
|
devtools(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: 'light',
|
||||||
|
toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' }))
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'settings-store',
|
||||||
|
partialize: (state) => ({ theme: state.theme })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
6. Async Logic Rules
|
||||||
|
• Async actions should be defined inside the store.
|
||||||
|
• Avoid direct useEffect calls that depend on store state.
|
||||||
|
|
||||||
|
Do:
|
||||||
|
|
||||||
|
fetchData: async () => {
|
||||||
|
const data = await api.getData();
|
||||||
|
set({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
Don’t:
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useStore.getState().fetchData(); // ❌ Side effect in React hook
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
7. Anti-Patterns
|
||||||
|
|
||||||
|
❌ Anti-Pattern 🚫 Reason
|
||||||
|
Subscribing to full store Causes unnecessary re-renders
|
||||||
|
Inline store creation in component Breaks referential integrity
|
||||||
|
Mutating state directly Zustand expects immutability
|
||||||
|
Business logic inside components Should live in store actions
|
||||||
|
Using store for local-only UI state Clutters global state
|
||||||
|
Multiple independent stores for one domain Increases complexity
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
8. Testing Rules
|
||||||
|
• Each store must be testable as a pure function.
|
||||||
|
• Tests should verify: initial state, action side effects, and immutability.
|
||||||
|
|
||||||
|
Example Jest test:
|
||||||
|
|
||||||
|
import { useCounterStore } from '../state/useCounterStore';
|
||||||
|
|
||||||
|
test('increment increases count', () => {
|
||||||
|
const { increment, count } = useCounterStore.getState();
|
||||||
|
increment();
|
||||||
|
expect(useCounterStore.getState().count).toBe(count + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
9. Documentation Rules
|
||||||
|
• Every store file must include:
|
||||||
|
• Top-level JSDoc summarizing store purpose.
|
||||||
|
• Type definitions for state and actions.
|
||||||
|
• Examples for consumption patterns.
|
||||||
|
• Maintain a STATE_GUIDELINES.md index in the repo root linking all store docs.
|
||||||
|
|
||||||
|
⸻
|
||||||
|
|
||||||
|
10. Enforcement Summary (AI Assistant Logic)
|
||||||
|
|
||||||
|
When generating Zustand code:
|
||||||
|
• ALWAYS define stores with create() at module scope.
|
||||||
|
• NEVER create stores inside React components.
|
||||||
|
• ALWAYS use selectors in components.
|
||||||
|
• AVOID getState() in render logic.
|
||||||
|
• PREFER shallow comparison for multiple subscriptions.
|
||||||
|
• LIMIT middleware to proven cases (persist, devtools, immer).
|
||||||
|
• TEST every action in isolation.
|
||||||
|
• DOCUMENT store purpose, shape, and actions.
|
||||||
|
|
||||||
|
⸻
|
||||||
|
# Zustand v3 → v4 Summary (for AI Coding Assistants)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Zustand v4 introduced a few key syntax and type changes focused on improving TypeScript inference, middleware chaining, and internal consistency.
|
||||||
|
All existing concepts (store creation, selectors, middleware, subscriptions) remain — only the *patterns* and *type structure* changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concept Changes
|
||||||
|
- **Curried Store Creation:**
|
||||||
|
`create()` now expects a *curried call* form when using generics or middleware.
|
||||||
|
The previous single-call pattern is deprecated.
|
||||||
|
|
||||||
|
- **TypeScript Inference Improvements:**
|
||||||
|
v4’s curried syntax provides stronger type inference for complex stores and middleware combinations.
|
||||||
|
|
||||||
|
- **Stricter Generic Typing:**
|
||||||
|
Functions like `set`, `get`, and the store API have tighter TypeScript types.
|
||||||
|
Any implicit `any` usage or loosely typed middleware will now error until corrected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Middleware Updates
|
||||||
|
- Middleware is still supported but must be imported from subpaths (e.g., `zustand/middleware/immer`).
|
||||||
|
- The structure of most built-in middlewares (persist, devtools, immer, subscribeWithSelector) remains identical.
|
||||||
|
- Chaining multiple middlewares now depends on the curried `create` syntax for correct type inference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persistence and Migration
|
||||||
|
- `persist` behavior is unchanged functionally, but TypeScript typing for the `migrate` function now defines the input state as `unknown`.
|
||||||
|
You must assert or narrow this type when using TypeScript.
|
||||||
|
- The `name`, `version`, and other options are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Adjustments
|
||||||
|
- The `set` function now includes a `replace` parameter for full state replacement.
|
||||||
|
- `get` and `api` generics are explicitly typed and must align with the store definition.
|
||||||
|
- Custom middleware and typed stores may need to specify generic parameters to avoid inference gaps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior and API Consistency
|
||||||
|
- Core APIs like `getState()`, `setState()`, and `subscribe()` are still valid.
|
||||||
|
- Hook usage (`useStore(state => state.value)`) is identical.
|
||||||
|
- Differences are primarily at compile time (typing), not runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration/Usage Implications
|
||||||
|
For AI agents generating Zustand code:
|
||||||
|
- Always use the **curried `create<Type>()(…)`** pattern when defining stores.
|
||||||
|
- Always import middleware from `zustand/middleware/...`.
|
||||||
|
- Expect `set`, `get`, and `api` to have stricter typings.
|
||||||
|
- Assume `migrate` in persistence returns `unknown` and must be asserted.
|
||||||
|
- Avoid any v3-style `create<Type>(fn)` calls.
|
||||||
|
- Middleware chaining depends on the curried syntax — never use nested functions without it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Behavior
|
||||||
|
- Functional concepts are unchanged: stores, actions, and reactivity all behave the same.
|
||||||
|
- Only the declaration pattern and TypeScript inference system differ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
| Area | Zustand v3 | Zustand v4 |
|
||||||
|
|------|-------------|------------|
|
||||||
|
| Store creation | Single function call | Curried two-step syntax |
|
||||||
|
| TypeScript inference | Looser | Stronger, middleware-aware |
|
||||||
|
| Middleware imports | Flat path | Sub-path imports |
|
||||||
|
| Migrate typing | `any` | `unknown` |
|
||||||
|
| API methods | Same | Same, stricter typing |
|
||||||
|
| Runtime behavior | Same | Same |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Principle for Code Generation
|
||||||
|
> “If defining a store, always use the curried `create()` syntax, import middleware from subpaths, and respect stricter generics. All functional behavior remains identical to v3.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Recommended Source:** [Zustand v4 Migration Guide – Official Docs](https://zustand.docs.pmnd.rs/migrations/migrating-to-v4)
|
||||||
89
PRPs/ai_docs/cc_cli_ref.md
Normal file
89
PRPs/ai_docs/cc_cli_ref.md
Normal 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
|
||||||
30
README.md
30
README.md
@@ -204,12 +204,13 @@ The reset script safely removes all tables, functions, triggers, and policies wi
|
|||||||
|
|
||||||
### Core Services
|
### Core Services
|
||||||
|
|
||||||
| Service | Container Name | Default URL | Purpose |
|
| Service | Container Name | Default URL | Purpose |
|
||||||
| ------------------ | -------------- | --------------------- | --------------------------------- |
|
| -------------------------- | -------------------------- | --------------------- | ------------------------------------------ |
|
||||||
| **Web Interface** | archon-ui | http://localhost:3737 | Main dashboard and controls |
|
| **Web Interface** | archon-ui | http://localhost:3737 | Main dashboard and controls |
|
||||||
| **API Service** | archon-server | http://localhost:8181 | Web crawling, document processing |
|
| **API Service** | archon-server | http://localhost:8181 | Web crawling, document processing |
|
||||||
| **MCP Server** | archon-mcp | http://localhost:8051 | Model Context Protocol interface |
|
| **MCP Server** | archon-mcp | http://localhost:8051 | Model Context Protocol interface |
|
||||||
| **Agents Service** | archon-agents | http://localhost:8052 | AI/ML operations, reranking |
|
| **Agents Service** | archon-agents | http://localhost:8052 | AI/ML operations, reranking |
|
||||||
|
| **Agent Work Orders** *(optional)* | archon-agent-work-orders | http://localhost:8053 | Workflow execution with Claude Code CLI |
|
||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
@@ -293,12 +294,13 @@ Archon uses true microservices architecture with clear separation of concerns:
|
|||||||
|
|
||||||
### Service Responsibilities
|
### Service Responsibilities
|
||||||
|
|
||||||
| Service | Location | Purpose | Key Features |
|
| Service | Location | Purpose | Key Features |
|
||||||
| -------------- | -------------------- | ---------------------------- | ------------------------------------------------------------------ |
|
| ------------------------ | ------------------------------ | -------------------------------- | ------------------------------------------------------------------ |
|
||||||
| **Frontend** | `archon-ui-main/` | Web interface and dashboard | React, TypeScript, TailwindCSS, Socket.IO client |
|
| **Frontend** | `archon-ui-main/` | Web interface and dashboard | React, TypeScript, TailwindCSS, Socket.IO client |
|
||||||
| **Server** | `python/src/server/` | Core business logic and APIs | FastAPI, service layer, Socket.IO broadcasts, all ML/AI operations |
|
| **Server** | `python/src/server/` | Core business logic and APIs | FastAPI, service layer, Socket.IO broadcasts, all ML/AI operations |
|
||||||
| **MCP Server** | `python/src/mcp/` | MCP protocol interface | Lightweight HTTP wrapper, MCP tools, session management |
|
| **MCP Server** | `python/src/mcp/` | MCP protocol interface | Lightweight HTTP wrapper, MCP tools, session management |
|
||||||
| **Agents** | `python/src/agents/` | PydanticAI agent hosting | Document and RAG agents, streaming responses |
|
| **Agents** | `python/src/agents/` | PydanticAI agent hosting | Document and RAG agents, streaming responses |
|
||||||
|
| **Agent Work Orders** *(optional)* | `python/src/agent_work_orders/` | Workflow execution engine | Claude Code CLI automation, repository management, SSE updates |
|
||||||
|
|
||||||
### Communication Patterns
|
### Communication Patterns
|
||||||
|
|
||||||
@@ -321,7 +323,8 @@ By default, Archon services run on the following ports:
|
|||||||
- **archon-ui**: 3737
|
- **archon-ui**: 3737
|
||||||
- **archon-server**: 8181
|
- **archon-server**: 8181
|
||||||
- **archon-mcp**: 8051
|
- **archon-mcp**: 8051
|
||||||
- **archon-agents**: 8052
|
- **archon-agents**: 8052 (optional)
|
||||||
|
- **archon-agent-work-orders**: 8053 (optional)
|
||||||
- **archon-docs**: 3838 (optional)
|
- **archon-docs**: 3838 (optional)
|
||||||
|
|
||||||
### Changing Ports
|
### Changing Ports
|
||||||
@@ -334,6 +337,7 @@ ARCHON_UI_PORT=3737
|
|||||||
ARCHON_SERVER_PORT=8181
|
ARCHON_SERVER_PORT=8181
|
||||||
ARCHON_MCP_PORT=8051
|
ARCHON_MCP_PORT=8051
|
||||||
ARCHON_AGENTS_PORT=8052
|
ARCHON_AGENTS_PORT=8052
|
||||||
|
AGENT_WORK_ORDERS_PORT=8053
|
||||||
ARCHON_DOCS_PORT=3838
|
ARCHON_DOCS_PORT=3838
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
13
archon-ui-main/.env.example
Normal file
13
archon-ui-main/.env.example
Normal 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
|
||||||
32
archon-ui-main/package-lock.json
generated
32
archon-ui-main/package-lock.json
generated
@@ -38,7 +38,8 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"tailwind-merge": "latest",
|
"tailwind-merge": "latest",
|
||||||
"zod": "^3.25.46"
|
"zod": "^3.25.46",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.2",
|
"@biomejs/biome": "2.2.2",
|
||||||
@@ -11844,6 +11845,35 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||||
|
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zwitch": {
|
"node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||||
|
|||||||
@@ -58,7 +58,8 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.26.2",
|
"react-router-dom": "^6.26.2",
|
||||||
"tailwind-merge": "latest",
|
"tailwind-merge": "latest",
|
||||||
"zod": "^3.25.46"
|
"zod": "^3.25.46",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.2",
|
"@biomejs/biome": "2.2.2",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { SettingsProvider, useSettings } from './contexts/SettingsContext';
|
|||||||
import { TooltipProvider } from './features/ui/primitives/tooltip';
|
import { TooltipProvider } from './features/ui/primitives/tooltip';
|
||||||
import { ProjectPage } from './pages/ProjectPage';
|
import { ProjectPage } from './pages/ProjectPage';
|
||||||
import StyleGuidePage from './pages/StyleGuidePage';
|
import StyleGuidePage from './pages/StyleGuidePage';
|
||||||
|
import { AgentWorkOrdersPage } from './pages/AgentWorkOrdersPage';
|
||||||
|
import { AgentWorkOrderDetailPage } from './pages/AgentWorkOrderDetailPage';
|
||||||
import { DisconnectScreenOverlay } from './components/DisconnectScreenOverlay';
|
import { DisconnectScreenOverlay } from './components/DisconnectScreenOverlay';
|
||||||
import { ErrorBoundaryWithBugReport } from './components/bug-report/ErrorBoundaryWithBugReport';
|
import { ErrorBoundaryWithBugReport } from './components/bug-report/ErrorBoundaryWithBugReport';
|
||||||
import { MigrationBanner } from './components/ui/MigrationBanner';
|
import { MigrationBanner } from './components/ui/MigrationBanner';
|
||||||
@@ -22,7 +24,7 @@ import { useMigrationStatus } from './hooks/useMigrationStatus';
|
|||||||
|
|
||||||
|
|
||||||
const AppRoutes = () => {
|
const AppRoutes = () => {
|
||||||
const { projectsEnabled, styleGuideEnabled } = useSettings();
|
const { projectsEnabled, styleGuideEnabled, agentWorkOrdersEnabled } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -43,6 +45,14 @@ const AppRoutes = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Route path="/projects" element={<Navigate to="/" replace />} />
|
<Route path="/projects" element={<Navigate to="/" replace />} />
|
||||||
)}
|
)}
|
||||||
|
{agentWorkOrdersEnabled ? (
|
||||||
|
<>
|
||||||
|
<Route path="/agent-work-orders" element={<AgentWorkOrdersPage />} />
|
||||||
|
<Route path="/agent-work-orders/:id" element={<AgentWorkOrderDetailPage />} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Route path="/agent-work-orders" element={<Navigate to="/" replace />} />
|
||||||
|
)}
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 type React from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
// TEMPORARY: Use old SettingsContext until settings are migrated
|
// TEMPORARY: Use old SettingsContext until settings are migrated
|
||||||
@@ -24,7 +24,7 @@ interface NavigationProps {
|
|||||||
*/
|
*/
|
||||||
export function Navigation({ className }: NavigationProps) {
|
export function Navigation({ className }: NavigationProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { projectsEnabled, styleGuideEnabled } = useSettings();
|
const { projectsEnabled, styleGuideEnabled, agentWorkOrdersEnabled } = useSettings();
|
||||||
|
|
||||||
// Navigation items configuration
|
// Navigation items configuration
|
||||||
const navigationItems: NavigationItem[] = [
|
const navigationItems: NavigationItem[] = [
|
||||||
@@ -34,6 +34,12 @@ export function Navigation({ className }: NavigationProps) {
|
|||||||
label: "Knowledge Base",
|
label: "Knowledge Base",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/agent-work-orders",
|
||||||
|
icon: <Bot className="h-5 w-5" />,
|
||||||
|
label: "Agent Work Orders",
|
||||||
|
enabled: agentWorkOrdersEnabled,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/mcp",
|
path: "/mcp",
|
||||||
icon: (
|
icon: (
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ export const FeaturesSection = () => {
|
|||||||
setTheme
|
setTheme
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { styleGuideEnabled, setStyleGuideEnabled: setStyleGuideContext } = useSettings();
|
const {
|
||||||
|
styleGuideEnabled,
|
||||||
|
setStyleGuideEnabled: setStyleGuideContext,
|
||||||
|
agentWorkOrdersEnabled,
|
||||||
|
setAgentWorkOrdersEnabled: setAgentWorkOrdersContext
|
||||||
|
} = useSettings();
|
||||||
const isDarkMode = theme === 'dark';
|
const isDarkMode = theme === 'dark';
|
||||||
const [projectsEnabled, setProjectsEnabled] = useState(true);
|
const [projectsEnabled, setProjectsEnabled] = useState(true);
|
||||||
const [styleGuideEnabledLocal, setStyleGuideEnabledLocal] = useState(styleGuideEnabled);
|
const [styleGuideEnabledLocal, setStyleGuideEnabledLocal] = useState(styleGuideEnabled);
|
||||||
|
const [agentWorkOrdersEnabledLocal, setAgentWorkOrdersEnabledLocal] = useState(agentWorkOrdersEnabled);
|
||||||
|
|
||||||
// Commented out for future release
|
// Commented out for future release
|
||||||
const [agUILibraryEnabled, setAgUILibraryEnabled] = useState(false);
|
const [agUILibraryEnabled, setAgUILibraryEnabled] = useState(false);
|
||||||
@@ -38,6 +44,10 @@ export const FeaturesSection = () => {
|
|||||||
setStyleGuideEnabledLocal(styleGuideEnabled);
|
setStyleGuideEnabledLocal(styleGuideEnabled);
|
||||||
}, [styleGuideEnabled]);
|
}, [styleGuideEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAgentWorkOrdersEnabledLocal(agentWorkOrdersEnabled);
|
||||||
|
}, [agentWorkOrdersEnabled]);
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -224,6 +234,29 @@ export const FeaturesSection = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAgentWorkOrdersToggle = async (checked: boolean) => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setAgentWorkOrdersEnabledLocal(checked);
|
||||||
|
|
||||||
|
// Update context which will save to backend
|
||||||
|
await setAgentWorkOrdersContext(checked);
|
||||||
|
|
||||||
|
showToast(
|
||||||
|
checked ? 'Agent Work Orders Enabled' : 'Agent Work Orders Disabled',
|
||||||
|
checked ? 'success' : 'warning'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update agent work orders setting:', error);
|
||||||
|
setAgentWorkOrdersEnabledLocal(!checked);
|
||||||
|
showToast('Failed to update agent work orders setting', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -298,6 +331,28 @@ export const FeaturesSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Work Orders Toggle */}
|
||||||
|
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-green-500/10 to-green-600/5 backdrop-blur-sm border border-green-500/20 shadow-lg">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-gray-800 dark:text-white">
|
||||||
|
Agent Work Orders
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Enable automated development workflows with Claude Code CLI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Switch
|
||||||
|
size="lg"
|
||||||
|
checked={agentWorkOrdersEnabledLocal}
|
||||||
|
onCheckedChange={handleAgentWorkOrdersToggle}
|
||||||
|
color="green"
|
||||||
|
icon={<Bot className="w-5 h-5" />}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* COMMENTED OUT FOR FUTURE RELEASE - AG-UI Library Toggle */}
|
{/* COMMENTED OUT FOR FUTURE RELEASE - AG-UI Library Toggle */}
|
||||||
{/*
|
{/*
|
||||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-pink-500/10 to-pink-600/5 backdrop-blur-sm border border-pink-500/20 shadow-lg">
|
<div className="flex items-center gap-4 p-4 rounded-xl bg-gradient-to-br from-pink-500/10 to-pink-600/5 backdrop-blur-sm border border-pink-500/20 shadow-lg">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ interface SettingsContextType {
|
|||||||
setProjectsEnabled: (enabled: boolean) => Promise<void>;
|
setProjectsEnabled: (enabled: boolean) => Promise<void>;
|
||||||
styleGuideEnabled: boolean;
|
styleGuideEnabled: boolean;
|
||||||
setStyleGuideEnabled: (enabled: boolean) => Promise<void>;
|
setStyleGuideEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
agentWorkOrdersEnabled: boolean;
|
||||||
|
setAgentWorkOrdersEnabled: (enabled: boolean) => Promise<void>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
refreshSettings: () => Promise<void>;
|
refreshSettings: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -27,16 +29,18 @@ interface SettingsProviderProps {
|
|||||||
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
|
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
|
||||||
const [projectsEnabled, setProjectsEnabledState] = useState(true);
|
const [projectsEnabled, setProjectsEnabledState] = useState(true);
|
||||||
const [styleGuideEnabled, setStyleGuideEnabledState] = useState(false);
|
const [styleGuideEnabled, setStyleGuideEnabledState] = useState(false);
|
||||||
|
const [agentWorkOrdersEnabled, setAgentWorkOrdersEnabledState] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// Load Projects and Style Guide settings
|
// Load Projects, Style Guide, and Agent Work Orders settings
|
||||||
const [projectsResponse, styleGuideResponse] = await Promise.all([
|
const [projectsResponse, styleGuideResponse, agentWorkOrdersResponse] = await Promise.all([
|
||||||
credentialsService.getCredential('PROJECTS_ENABLED').catch(() => ({ value: undefined })),
|
credentialsService.getCredential('PROJECTS_ENABLED').catch(() => ({ value: undefined })),
|
||||||
credentialsService.getCredential('STYLE_GUIDE_ENABLED').catch(() => ({ value: undefined }))
|
credentialsService.getCredential('STYLE_GUIDE_ENABLED').catch(() => ({ value: undefined })),
|
||||||
|
credentialsService.getCredential('AGENT_WORK_ORDERS_ENABLED').catch(() => ({ value: undefined }))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (projectsResponse.value !== undefined) {
|
if (projectsResponse.value !== undefined) {
|
||||||
@@ -51,10 +55,17 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
|||||||
setStyleGuideEnabledState(false); // Default to false
|
setStyleGuideEnabledState(false); // Default to false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (agentWorkOrdersResponse.value !== undefined) {
|
||||||
|
setAgentWorkOrdersEnabledState(agentWorkOrdersResponse.value === 'true');
|
||||||
|
} else {
|
||||||
|
setAgentWorkOrdersEnabledState(false); // Default to false
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
setProjectsEnabledState(true);
|
setProjectsEnabledState(true);
|
||||||
setStyleGuideEnabledState(false);
|
setStyleGuideEnabledState(false);
|
||||||
|
setAgentWorkOrdersEnabledState(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -106,6 +117,27 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setAgentWorkOrdersEnabled = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
// Update local state immediately
|
||||||
|
setAgentWorkOrdersEnabledState(enabled);
|
||||||
|
|
||||||
|
// Save to backend
|
||||||
|
await credentialsService.createCredential({
|
||||||
|
key: 'AGENT_WORK_ORDERS_ENABLED',
|
||||||
|
value: enabled.toString(),
|
||||||
|
is_encrypted: false,
|
||||||
|
category: 'features',
|
||||||
|
description: 'Enable Agent Work Orders feature for automated development workflows'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update agent work orders setting:', error);
|
||||||
|
// Revert on error
|
||||||
|
setAgentWorkOrdersEnabledState(!enabled);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshSettings = async () => {
|
const refreshSettings = async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
};
|
};
|
||||||
@@ -115,6 +147,8 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
|||||||
setProjectsEnabled,
|
setProjectsEnabled,
|
||||||
styleGuideEnabled,
|
styleGuideEnabled,
|
||||||
setStyleGuideEnabled,
|
setStyleGuideEnabled,
|
||||||
|
agentWorkOrdersEnabled,
|
||||||
|
setAgentWorkOrdersEnabled,
|
||||||
loading,
|
loading,
|
||||||
refreshSettings
|
refreshSettings
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* Add Repository Modal Component
|
||||||
|
*
|
||||||
|
* Modal for adding new configured repositories with GitHub verification.
|
||||||
|
* Two-column layout: Left (2/3) for form fields, Right (1/3) for workflow steps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Checkbox } from "@/features/ui/primitives/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
|
||||||
|
import { Input } from "@/features/ui/primitives/input";
|
||||||
|
import { Label } from "@/features/ui/primitives/label";
|
||||||
|
import { useCreateRepository } from "../hooks/useRepositoryQueries";
|
||||||
|
import type { WorkflowStep } from "../types";
|
||||||
|
|
||||||
|
export interface AddRepositoryModalProps {
|
||||||
|
/** Whether modal is open */
|
||||||
|
open: boolean;
|
||||||
|
|
||||||
|
/** Callback to change open state */
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available workflow steps
|
||||||
|
*/
|
||||||
|
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; description: string; dependsOn?: WorkflowStep[] }[] = [
|
||||||
|
{ value: "create-branch", label: "Create Branch", description: "Create a new git branch for isolated work" },
|
||||||
|
{ value: "planning", label: "Planning", description: "Generate implementation plan" },
|
||||||
|
{ value: "execute", label: "Execute", description: "Implement the planned changes" },
|
||||||
|
{ value: "prp-review", label: "Review/Fix", description: "Review implementation and fix issues", dependsOn: ["execute"] },
|
||||||
|
{ value: "commit", label: "Commit", description: "Commit changes to git", dependsOn: ["execute"] },
|
||||||
|
{ value: "create-pr", label: "Create PR", description: "Create pull request", dependsOn: ["commit"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default selected steps for new repositories
|
||||||
|
*/
|
||||||
|
const DEFAULT_STEPS: WorkflowStep[] = ["create-branch", "planning", "execute"];
|
||||||
|
|
||||||
|
export function AddRepositoryModal({ open, onOpenChange }: AddRepositoryModalProps) {
|
||||||
|
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||||
|
const [selectedSteps, setSelectedSteps] = useState<WorkflowStep[]>(DEFAULT_STEPS);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const createRepository = useCreateRepository();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset form state
|
||||||
|
*/
|
||||||
|
const resetForm = () => {
|
||||||
|
setRepositoryUrl("");
|
||||||
|
setSelectedSteps(DEFAULT_STEPS);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle workflow step selection
|
||||||
|
* When unchecking a step, also uncheck steps that depend on it (cascade removal)
|
||||||
|
*/
|
||||||
|
const toggleStep = (step: WorkflowStep) => {
|
||||||
|
setSelectedSteps((prev) => {
|
||||||
|
if (prev.includes(step)) {
|
||||||
|
// Removing a step - also remove steps that depend on it
|
||||||
|
const stepsToRemove = new Set([step]);
|
||||||
|
|
||||||
|
// Find all steps that transitively depend on the one being removed (cascade)
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
WORKFLOW_STEPS.forEach((s) => {
|
||||||
|
if (!stepsToRemove.has(s.value) && s.dependsOn?.some((dep) => stepsToRemove.has(dep))) {
|
||||||
|
stepsToRemove.add(s.value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.filter((s) => !stepsToRemove.has(s));
|
||||||
|
}
|
||||||
|
return [...prev, step];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a step is disabled based on dependencies
|
||||||
|
*/
|
||||||
|
const isStepDisabled = (step: (typeof WORKFLOW_STEPS)[number]): boolean => {
|
||||||
|
if (!step.dependsOn) return false;
|
||||||
|
return step.dependsOn.some((dep) => !selectedSteps.includes(dep));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission
|
||||||
|
*/
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!repositoryUrl.trim()) {
|
||||||
|
setError("Repository URL is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!repositoryUrl.includes("github.com")) {
|
||||||
|
setError("Must be a GitHub repository URL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedSteps.length === 0) {
|
||||||
|
setError("At least one workflow step must be selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
await createRepository.mutateAsync({
|
||||||
|
repository_url: repositoryUrl,
|
||||||
|
verify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success - close modal and reset form
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create repository");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Repository</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column (2/3 width) - Form Fields */}
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
{/* Repository URL */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repository-url">Repository URL *</Label>
|
||||||
|
<Input
|
||||||
|
id="repository-url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://github.com/owner/repository"
|
||||||
|
value={repositoryUrl}
|
||||||
|
onChange={(e) => setRepositoryUrl(e.target.value)}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
GitHub repository URL. We'll verify access and extract metadata automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info about auto-filled fields */}
|
||||||
|
<div className="p-3 bg-blue-500/10 dark:bg-blue-400/10 border border-blue-500/20 dark:border-blue-400/20 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<strong>Auto-filled from GitHub:</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-gray-600 dark:text-gray-400 mt-1 space-y-0.5 ml-4 list-disc">
|
||||||
|
<li>Display Name (can be customized later via Edit)</li>
|
||||||
|
<li>Owner/Organization</li>
|
||||||
|
<li>Default Branch</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (1/3 width) - Workflow Steps */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Default Workflow Steps</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{WORKFLOW_STEPS.map((step) => {
|
||||||
|
const isSelected = selectedSteps.includes(step.value);
|
||||||
|
const isDisabled = isStepDisabled(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.value} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`step-${step.value}`}
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label={step.label}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
|
||||||
|
{step.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting} variant="cyan">
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||||
|
Adding...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Add Repository"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* Create Work Order Modal Component
|
||||||
|
*
|
||||||
|
* Two-column modal for creating work orders with improved layout.
|
||||||
|
* Left column (2/3): Form fields for repository, request, issue
|
||||||
|
* Right column (1/3): Workflow steps selection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Checkbox } from "@/features/ui/primitives/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
|
||||||
|
import { Input, TextArea } from "@/features/ui/primitives/input";
|
||||||
|
import { Label } from "@/features/ui/primitives/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
|
||||||
|
import { useCreateWorkOrder } from "../hooks/useAgentWorkOrderQueries";
|
||||||
|
import { useRepositories } from "../hooks/useRepositoryQueries";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { SandboxType, WorkflowStep } from "../types";
|
||||||
|
|
||||||
|
export interface CreateWorkOrderModalProps {
|
||||||
|
/** Whether modal is open */
|
||||||
|
open: boolean;
|
||||||
|
|
||||||
|
/** Callback to change open state */
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available workflow steps with dependency info
|
||||||
|
*/
|
||||||
|
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; dependsOn?: WorkflowStep[] }[] = [
|
||||||
|
{ value: "create-branch", label: "Create Branch" },
|
||||||
|
{ value: "planning", label: "Planning" },
|
||||||
|
{ value: "execute", label: "Execute" },
|
||||||
|
{ value: "prp-review", label: "Review/Fix", dependsOn: ["execute"] },
|
||||||
|
{ value: "commit", label: "Commit Changes", dependsOn: ["execute"] },
|
||||||
|
{ value: "create-pr", label: "Create Pull Request", dependsOn: ["commit"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CreateWorkOrderModal({ open, onOpenChange }: CreateWorkOrderModalProps) {
|
||||||
|
// Read preselected repository from Zustand store
|
||||||
|
const preselectedRepositoryId = useAgentWorkOrdersStore((s) => s.preselectedRepositoryId);
|
||||||
|
|
||||||
|
const { data: repositories = [] } = useRepositories();
|
||||||
|
const createWorkOrder = useCreateWorkOrder();
|
||||||
|
|
||||||
|
const [repositoryId, setRepositoryId] = useState(preselectedRepositoryId || "");
|
||||||
|
const [repositoryUrl, setRepositoryUrl] = useState("");
|
||||||
|
const [sandboxType, setSandboxType] = useState<SandboxType>("git_worktree");
|
||||||
|
const [userRequest, setUserRequest] = useState("");
|
||||||
|
const [githubIssueNumber, setGithubIssueNumber] = useState("");
|
||||||
|
const [selectedCommands, setSelectedCommands] = useState<WorkflowStep[]>(["create-branch", "planning", "execute", "prp-review", "commit", "create-pr"]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-populate form when repository is selected
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (preselectedRepositoryId) {
|
||||||
|
setRepositoryId(preselectedRepositoryId);
|
||||||
|
const repo = repositories.find((r) => r.id === preselectedRepositoryId);
|
||||||
|
if (repo) {
|
||||||
|
setRepositoryUrl(repo.repository_url);
|
||||||
|
setSandboxType(repo.default_sandbox_type);
|
||||||
|
setSelectedCommands(repo.default_commands as WorkflowStep[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [preselectedRepositoryId, repositories]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle repository selection change
|
||||||
|
*/
|
||||||
|
const handleRepositoryChange = (newRepositoryId: string) => {
|
||||||
|
setRepositoryId(newRepositoryId);
|
||||||
|
const repo = repositories.find((r) => r.id === newRepositoryId);
|
||||||
|
if (repo) {
|
||||||
|
setRepositoryUrl(repo.repository_url);
|
||||||
|
setSandboxType(repo.default_sandbox_type);
|
||||||
|
setSelectedCommands(repo.default_commands as WorkflowStep[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle workflow step selection
|
||||||
|
* When unchecking a step, also uncheck steps that depend on it (cascade removal)
|
||||||
|
*/
|
||||||
|
const toggleStep = (step: WorkflowStep) => {
|
||||||
|
setSelectedCommands((prev) => {
|
||||||
|
if (prev.includes(step)) {
|
||||||
|
// Removing a step - also remove steps that depend on it
|
||||||
|
const stepsToRemove = new Set([step]);
|
||||||
|
|
||||||
|
// Find all steps that transitively depend on the one being removed (cascade)
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
WORKFLOW_STEPS.forEach((s) => {
|
||||||
|
if (!stepsToRemove.has(s.value) && s.dependsOn?.some((dep) => stepsToRemove.has(dep))) {
|
||||||
|
stepsToRemove.add(s.value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.filter((s) => !stepsToRemove.has(s));
|
||||||
|
}
|
||||||
|
return [...prev, step];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a step is disabled based on dependencies
|
||||||
|
*/
|
||||||
|
const isStepDisabled = (step: (typeof WORKFLOW_STEPS)[number]): boolean => {
|
||||||
|
if (!step.dependsOn) return false;
|
||||||
|
return step.dependsOn.some((dep) => !selectedCommands.includes(dep));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset form state
|
||||||
|
*/
|
||||||
|
const resetForm = () => {
|
||||||
|
setRepositoryId(preselectedRepositoryId || "");
|
||||||
|
setRepositoryUrl("");
|
||||||
|
setSandboxType("git_worktree");
|
||||||
|
setUserRequest("");
|
||||||
|
setGithubIssueNumber("");
|
||||||
|
setSelectedCommands(["create-branch", "planning", "execute"]);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission
|
||||||
|
*/
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!repositoryUrl.trim()) {
|
||||||
|
setError("Repository URL is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userRequest.trim().length < 10) {
|
||||||
|
setError("Request must be at least 10 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCommands.length === 0) {
|
||||||
|
setError("At least one workflow step must be selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Sort selected commands by WORKFLOW_STEPS order before sending to backend
|
||||||
|
// This ensures correct execution order regardless of checkbox click order
|
||||||
|
const sortedCommands = WORKFLOW_STEPS
|
||||||
|
.filter(step => selectedCommands.includes(step.value))
|
||||||
|
.map(step => step.value);
|
||||||
|
|
||||||
|
await createWorkOrder.mutateAsync({
|
||||||
|
repository_url: repositoryUrl,
|
||||||
|
sandbox_type: sandboxType,
|
||||||
|
user_request: userRequest,
|
||||||
|
github_issue_number: githubIssueNumber || undefined,
|
||||||
|
selected_commands: sortedCommands,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success - close modal and reset
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
// Preserve error details by truncating long messages instead of hiding them
|
||||||
|
// Show up to 500 characters to capture important debugging information
|
||||||
|
// while keeping the UI readable
|
||||||
|
const maxLength = 500;
|
||||||
|
let userMessage = "Failed to create work order. Please try again.";
|
||||||
|
|
||||||
|
if (err instanceof Error && err.message) {
|
||||||
|
if (err.message.length <= maxLength) {
|
||||||
|
userMessage = err.message;
|
||||||
|
} else {
|
||||||
|
// Truncate but preserve the start which often contains the most important details
|
||||||
|
userMessage = `${err.message.slice(0, maxLength)}... (truncated, ${err.message.length - maxLength} more characters)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(userMessage);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Work Order</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column (2/3 width) - Form Fields */}
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
{/* Repository Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="repository">Repository</Label>
|
||||||
|
<Select value={repositoryId} onValueChange={handleRepositoryChange}>
|
||||||
|
<SelectTrigger id="repository" aria-label="Select repository">
|
||||||
|
<SelectValue placeholder="Select a repository..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<SelectItem key={repo.id} value={repo.id}>
|
||||||
|
{repo.display_name || repo.repository_url}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Request */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="user-request">Work Request</Label>
|
||||||
|
<TextArea
|
||||||
|
id="user-request"
|
||||||
|
placeholder="Describe the work you want the agent to perform..."
|
||||||
|
rows={4}
|
||||||
|
value={userRequest}
|
||||||
|
onChange={(e) => setUserRequest(e.target.value)}
|
||||||
|
aria-invalid={!!error && userRequest.length < 10}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Minimum 10 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GitHub Issue Number (optional) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="github-issue">GitHub Issue Number (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="github-issue"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., 42"
|
||||||
|
value={githubIssueNumber}
|
||||||
|
onChange={(e) => setGithubIssueNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sandbox Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sandbox-type">Sandbox Type</Label>
|
||||||
|
<Select value={sandboxType} onValueChange={(value) => setSandboxType(value as SandboxType)}>
|
||||||
|
<SelectTrigger id="sandbox-type" aria-label="Select sandbox type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="git_worktree">Git Worktree (Recommended)</SelectItem>
|
||||||
|
<SelectItem value="git_branch">Git Branch</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (1/3 width) - Workflow Steps */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Workflow Steps</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{WORKFLOW_STEPS.map((step) => {
|
||||||
|
const isSelected = selectedCommands.includes(step.value);
|
||||||
|
const isDisabled = isStepDisabled(step);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.value} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`step-${step.value}`}
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => !isDisabled && toggleStep(step.value)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
aria-label={step.label}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`step-${step.value}`} className={isDisabled ? "text-gray-400" : ""}>
|
||||||
|
{step.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting} variant="cyan">
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create Work Order"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Edit Repository Modal Component
|
||||||
|
*
|
||||||
|
* Modal for editing configured repository settings.
|
||||||
|
* Two-column layout: Left (2/3) for form fields, Right (1/3) for workflow steps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Checkbox } from "@/features/ui/primitives/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/features/ui/primitives/dialog";
|
||||||
|
import { Label } from "@/features/ui/primitives/label";
|
||||||
|
import { SimpleTooltip, TooltipProvider } from "@/features/ui/primitives/tooltip";
|
||||||
|
import { useUpdateRepository } from "../hooks/useRepositoryQueries";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { WorkflowStep } from "../types";
|
||||||
|
|
||||||
|
export interface EditRepositoryModalProps {
|
||||||
|
/** Whether modal is open */
|
||||||
|
open: boolean;
|
||||||
|
|
||||||
|
/** Callback to change open state */
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available workflow steps
|
||||||
|
*/
|
||||||
|
const WORKFLOW_STEPS: { value: WorkflowStep; label: string; description: string; dependsOn?: WorkflowStep[] }[] = [
|
||||||
|
{ value: "create-branch", label: "Create Branch", description: "Create a new git branch for isolated work" },
|
||||||
|
{ value: "planning", label: "Planning", description: "Generate implementation plan" },
|
||||||
|
{ value: "execute", label: "Execute", description: "Implement the planned changes" },
|
||||||
|
{ value: "prp-review", label: "Review/Fix", description: "Review implementation and fix issues", dependsOn: ["execute"] },
|
||||||
|
{ value: "commit", label: "Commit", description: "Commit changes to git", dependsOn: ["execute"] },
|
||||||
|
{ value: "create-pr", label: "Create PR", description: "Create pull request", dependsOn: ["commit"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EditRepositoryModal({ open, onOpenChange }: EditRepositoryModalProps) {
|
||||||
|
// Read editing repository from Zustand store
|
||||||
|
const repository = useAgentWorkOrdersStore((s) => s.editingRepository);
|
||||||
|
|
||||||
|
const [selectedSteps, setSelectedSteps] = useState<WorkflowStep[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const updateRepository = useUpdateRepository();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-populate form when repository changes
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (repository) {
|
||||||
|
setSelectedSteps(repository.default_commands);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
}, [repository]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle workflow step selection
|
||||||
|
* When unchecking a step, also uncheck steps that depend on it (cascade removal)
|
||||||
|
*/
|
||||||
|
const toggleStep = (step: WorkflowStep) => {
|
||||||
|
setSelectedSteps((prev) => {
|
||||||
|
if (prev.includes(step)) {
|
||||||
|
// Removing a step - also remove steps that depend on it
|
||||||
|
const stepsToRemove = new Set([step]);
|
||||||
|
|
||||||
|
// Find all steps that transitively depend on the one being removed (cascade)
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
WORKFLOW_STEPS.forEach((s) => {
|
||||||
|
if (!stepsToRemove.has(s.value) && s.dependsOn?.some((dep) => stepsToRemove.has(dep))) {
|
||||||
|
stepsToRemove.add(s.value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.filter((s) => !stepsToRemove.has(s));
|
||||||
|
}
|
||||||
|
return [...prev, step];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a step is disabled based on dependencies
|
||||||
|
*/
|
||||||
|
const isStepDisabled = (step: (typeof WORKFLOW_STEPS)[number]): boolean => {
|
||||||
|
if (!step.dependsOn) return false;
|
||||||
|
return step.dependsOn.some((dep) => !selectedSteps.includes(dep));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle form submission
|
||||||
|
*/
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!repository) return;
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (selectedSteps.length === 0) {
|
||||||
|
setError("At least one workflow step must be selected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Sort selected steps by WORKFLOW_STEPS order before sending to backend
|
||||||
|
const sortedSteps = WORKFLOW_STEPS
|
||||||
|
.filter(step => selectedSteps.includes(step.value))
|
||||||
|
.map(step => step.value);
|
||||||
|
|
||||||
|
await updateRepository.mutateAsync({
|
||||||
|
id: repository.id,
|
||||||
|
request: {
|
||||||
|
default_sandbox_type: repository.default_sandbox_type,
|
||||||
|
default_commands: sortedSteps,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Success - close modal
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to update repository");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!repository) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Repository</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="pt-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column (2/3 width) - Repository Info */}
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
{/* Repository Info Card */}
|
||||||
|
<div className="p-4 bg-gray-500/10 dark:bg-gray-400/10 border border-gray-500/20 dark:border-gray-400/20 rounded-lg space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Repository Information</h4>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">URL: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-mono text-xs">{repository.repository_url}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{repository.display_name && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Name: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{repository.display_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repository.owner && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Owner: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{repository.owner}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{repository.default_branch && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Branch: </span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-mono text-xs">
|
||||||
|
{repository.default_branch}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
Repository metadata is auto-filled from GitHub and cannot be edited directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (1/3 width) - Workflow Steps */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label>Default Workflow Steps</Label>
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{WORKFLOW_STEPS.map((step) => {
|
||||||
|
const isSelected = selectedSteps.includes(step.value);
|
||||||
|
const isDisabledForEnable = isStepDisabled(step);
|
||||||
|
|
||||||
|
const tooltipMessage = isDisabledForEnable && step.dependsOn
|
||||||
|
? `Requires: ${step.dependsOn.map((dep) => WORKFLOW_STEPS.find((s) => s.value === dep)?.label ?? dep).join(", ")}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const checkbox = (
|
||||||
|
<Checkbox
|
||||||
|
id={`edit-step-${step.value}`}
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (!isDisabledForEnable) {
|
||||||
|
toggleStep(step.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDisabledForEnable}
|
||||||
|
aria-label={step.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.value} className="flex items-center gap-2">
|
||||||
|
{tooltipMessage ? (
|
||||||
|
<SimpleTooltip content={tooltipMessage} side="right">
|
||||||
|
{checkbox}
|
||||||
|
</SimpleTooltip>
|
||||||
|
) : (
|
||||||
|
checkbox
|
||||||
|
)}
|
||||||
|
<Label
|
||||||
|
htmlFor={`edit-step-${step.value}`}
|
||||||
|
className={
|
||||||
|
isDisabledForEnable
|
||||||
|
? "text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Commit and PR require Execute</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 text-sm text-red-600 dark:text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-6 mt-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting} variant="cyan">
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" aria-hidden="true" />
|
||||||
|
Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Changes"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { Switch } from "@/features/ui/primitives/switch";
|
||||||
|
import type { LogEntry } from "../types";
|
||||||
|
|
||||||
|
interface ExecutionLogsProps {
|
||||||
|
/** Log entries to display (from SSE stream or historical data) */
|
||||||
|
logs: LogEntry[];
|
||||||
|
|
||||||
|
/** Whether logs are from live SSE stream (shows "Live" indicator) */
|
||||||
|
isLive?: boolean;
|
||||||
|
|
||||||
|
/** Callback to clear logs (optional, defaults to no-op) */
|
||||||
|
onClearLogs?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color class for log level badge - STATIC lookup
|
||||||
|
*/
|
||||||
|
const logLevelColors: Record<string, string> = {
|
||||||
|
info: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-400/30",
|
||||||
|
warning: "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-400/30",
|
||||||
|
error: "bg-red-500/20 text-red-600 dark:text-red-400 border-red-400/30",
|
||||||
|
debug: "bg-gray-500/20 text-gray-600 dark: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 < 0) 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual log entry component
|
||||||
|
*/
|
||||||
|
function LogEntryRow({ log }: { log: LogEntry }) {
|
||||||
|
const colorClass = logLevelColors[log.level] || logLevelColors.debug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 dark:hover:bg-black/20 rounded font-mono text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">
|
||||||
|
{formatRelativeTime(log.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className={cn("px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap", colorClass)}>
|
||||||
|
{log.level}
|
||||||
|
</span>
|
||||||
|
{log.step && <span className="text-cyan-600 dark:text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
|
||||||
|
<span className="text-gray-900 dark:text-gray-300 flex-1 min-w-0">{log.event}</span>
|
||||||
|
{log.progress && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">{log.progress}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionLogs({ logs, isLive = false, onClearLogs = () => {} }: ExecutionLogsProps) {
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
const [localLogs, setLocalLogs] = useState<LogEntry[]>(logs);
|
||||||
|
const [isCleared, setIsCleared] = useState(false);
|
||||||
|
const previousLogsLengthRef = useRef<number>(logs.length);
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Update local logs when props change
|
||||||
|
useEffect(() => {
|
||||||
|
const currentLogsLength = logs.length;
|
||||||
|
const previousLogsLength = previousLogsLengthRef.current;
|
||||||
|
|
||||||
|
// If we cleared logs, only update if new logs arrive (length increases)
|
||||||
|
if (isCleared) {
|
||||||
|
if (currentLogsLength > previousLogsLength) {
|
||||||
|
// New logs arrived after clear - reset cleared state and show new logs
|
||||||
|
setLocalLogs(logs);
|
||||||
|
setIsCleared(false);
|
||||||
|
}
|
||||||
|
// Otherwise, keep local logs empty (user's cleared view)
|
||||||
|
} else {
|
||||||
|
// Normal case: update local logs with prop changes
|
||||||
|
setLocalLogs(logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousLogsLengthRef.current = currentLogsLength;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
// Filter logs by level
|
||||||
|
const filteredLogs = levelFilter === "all" ? localLogs : localLogs.filter((log) => log.level === levelFilter);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle clear logs button click
|
||||||
|
*/
|
||||||
|
const handleClearLogs = () => {
|
||||||
|
setLocalLogs([]);
|
||||||
|
setIsCleared(true);
|
||||||
|
onClearLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-scroll to bottom when new logs arrive (if enabled)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [localLogs.length, autoScroll]); // Trigger on new logs, not filtered logs
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg overflow-hidden bg-black/20 dark:bg-white/5 backdrop-blur">
|
||||||
|
{/* Header with controls */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 dark:border-gray-700/30 bg-gray-900/50 dark:bg-gray-800/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-300">Execution Logs</span>
|
||||||
|
|
||||||
|
{/* Live/Historical indicator */}
|
||||||
|
{isLive ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full animate-pulse" />
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400">Live</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-2 h-2 bg-gray-500 dark:bg-gray-400 rounded-full" />
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Historical</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">({filteredLogs.length} entries)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Level filter using proper Select primitive */}
|
||||||
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
|
<SelectTrigger className="w-32 h-8 text-xs" aria-label="Filter log level">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Levels</SelectItem>
|
||||||
|
<SelectItem value="info">Info</SelectItem>
|
||||||
|
<SelectItem value="warning">Warning</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
<SelectItem value="debug">Debug</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Auto-scroll toggle using Switch primitive */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="auto-scroll-toggle" className="text-xs text-gray-700 dark:text-gray-300">
|
||||||
|
Auto-scroll:
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id="auto-scroll-toggle"
|
||||||
|
checked={autoScroll}
|
||||||
|
onCheckedChange={setAutoScroll}
|
||||||
|
aria-label="Toggle auto-scroll"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
autoScroll ? "text-cyan-600 dark:text-cyan-400" : "text-gray-500 dark:text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{autoScroll ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear logs button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearLogs}
|
||||||
|
className="h-8 text-xs text-gray-600 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
|
||||||
|
aria-label="Clear logs"
|
||||||
|
disabled={localLogs.length === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-1.5" aria-hidden="true" />
|
||||||
|
Clear logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log content - scrollable area */}
|
||||||
|
<div ref={scrollContainerRef} className="max-h-96 overflow-y-auto bg-black/40 dark:bg-black/20">
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No logs match the current filter</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2">
|
||||||
|
{filteredLogs.map((log, index) => (
|
||||||
|
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { Activity, ChevronDown, ChevronUp, Clock, TrendingUp } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { useStepHistory, useWorkOrderLogs } from "../hooks/useAgentWorkOrderQueries";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { LiveProgress } from "../state/slices/sseSlice";
|
||||||
|
import type { LogEntry } from "../types";
|
||||||
|
import { ExecutionLogs } from "./ExecutionLogs";
|
||||||
|
|
||||||
|
interface RealTimeStatsProps {
|
||||||
|
/** Work order ID to stream logs for */
|
||||||
|
workOrderId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable empty array reference to prevent infinite re-renders
|
||||||
|
* CRITICAL: Never use `|| []` in Zustand selectors - creates new reference each render
|
||||||
|
*/
|
||||||
|
const EMPTY_LOGS: never[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to narrow LogEntry to one with required step_number and total_steps
|
||||||
|
*/
|
||||||
|
type LogEntryWithSteps = LogEntry & {
|
||||||
|
step_number: number;
|
||||||
|
total_steps: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasStepInfo(log: LogEntry): log is LogEntryWithSteps {
|
||||||
|
return log.step_number !== undefined && log.total_steps !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate progress metrics from log entries
|
||||||
|
* Used as fallback when no SSE progress data exists (e.g., after refresh)
|
||||||
|
*/
|
||||||
|
function useCalculateProgressFromLogs(logs: LogEntry[]): LiveProgress | null {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (logs.length === 0) return null;
|
||||||
|
|
||||||
|
// Find latest progress-related logs using type guard for proper narrowing
|
||||||
|
const stepLogs = logs.filter(hasStepInfo);
|
||||||
|
const latestStepLog = stepLogs[stepLogs.length - 1];
|
||||||
|
|
||||||
|
const workflowCompleted = logs.some((log) => log.event === "workflow_completed");
|
||||||
|
const workflowFailed = logs.some((log) => log.event === "workflow_failed" || log.level === "error");
|
||||||
|
|
||||||
|
const latestElapsed = logs.reduce((max, log) => {
|
||||||
|
return log.elapsed_seconds !== undefined && log.elapsed_seconds > max ? log.elapsed_seconds : max;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
if (!latestStepLog && logs.length > 0) {
|
||||||
|
// Have logs but no step info - show minimal progress
|
||||||
|
return {
|
||||||
|
currentStep: "initializing",
|
||||||
|
progressPct: workflowCompleted ? 100 : workflowFailed ? 0 : 10,
|
||||||
|
elapsedSeconds: latestElapsed,
|
||||||
|
status: workflowCompleted ? "completed" : workflowFailed ? "failed" : "running",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestStepLog) {
|
||||||
|
// Type guard ensures step_number and total_steps are defined, so safe to access
|
||||||
|
const stepNumber = latestStepLog.step_number;
|
||||||
|
const totalSteps = latestStepLog.total_steps;
|
||||||
|
const completedSteps = stepNumber - 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep: latestStepLog.step || "unknown",
|
||||||
|
stepNumber: stepNumber,
|
||||||
|
totalSteps: totalSteps,
|
||||||
|
progressPct: workflowCompleted ? 100 : Math.round((completedSteps / totalSteps) * 100),
|
||||||
|
elapsedSeconds: latestElapsed,
|
||||||
|
status: workflowCompleted ? "completed" : workflowFailed ? "failed" : "running",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [logs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate progress from step history (persistent database data)
|
||||||
|
* Used when logs are not available (completed work orders, server restart)
|
||||||
|
*/
|
||||||
|
function useCalculateProgressFromSteps(stepHistory: any): LiveProgress | null {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!stepHistory?.steps || stepHistory.steps.length === 0) return null;
|
||||||
|
|
||||||
|
const steps = stepHistory.steps;
|
||||||
|
const totalSteps = steps.length;
|
||||||
|
const completedSteps = steps.filter((s: any) => s.success).length;
|
||||||
|
const lastStep = steps[steps.length - 1];
|
||||||
|
const hasFailure = steps.some((s: any) => !s.success);
|
||||||
|
|
||||||
|
// Calculate total duration
|
||||||
|
const totalDuration = steps.reduce((sum: number, step: any) => sum + (step.duration_seconds || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep: lastStep.step,
|
||||||
|
stepNumber: totalSteps,
|
||||||
|
totalSteps: totalSteps,
|
||||||
|
progressPct: Math.round((completedSteps / totalSteps) * 100),
|
||||||
|
elapsedSeconds: Math.round(totalDuration),
|
||||||
|
status: hasFailure ? "failed" : "completed",
|
||||||
|
};
|
||||||
|
}, [stepHistory]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert step history to log entries for display
|
||||||
|
*/
|
||||||
|
function useConvertStepsToLogs(stepHistory: any): LogEntry[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!stepHistory?.steps) return [];
|
||||||
|
|
||||||
|
return stepHistory.steps.map((step: any, index: number) => ({
|
||||||
|
work_order_id: stepHistory.agent_work_order_id,
|
||||||
|
level: step.success ? ("info" as const) : ("error" as const),
|
||||||
|
event: step.success ? `Step completed: ${step.step}` : `Step failed: ${step.step}`,
|
||||||
|
timestamp: step.timestamp,
|
||||||
|
step: step.step,
|
||||||
|
step_number: index + 1,
|
||||||
|
total_steps: stepHistory.steps.length,
|
||||||
|
elapsed_seconds: Math.round(step.duration_seconds),
|
||||||
|
output: step.output || step.error_message,
|
||||||
|
})) as LogEntry[];
|
||||||
|
}, [stepHistory]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealTimeStats({ workOrderId }: RealTimeStatsProps) {
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
|
// Zustand SSE slice - connection management and live data
|
||||||
|
const connectToLogs = useAgentWorkOrdersStore((s) => s.connectToLogs);
|
||||||
|
const disconnectFromLogs = useAgentWorkOrdersStore((s) => s.disconnectFromLogs);
|
||||||
|
const clearLogs = useAgentWorkOrdersStore((s) => s.clearLogs);
|
||||||
|
const sseProgress = useAgentWorkOrdersStore((s) => s.liveProgress[workOrderId ?? ""]);
|
||||||
|
const sseLogs = useAgentWorkOrdersStore((s) => s.liveLogs[workOrderId ?? ""]);
|
||||||
|
|
||||||
|
// Fetch historical logs from backend as fallback (for refresh/HMR)
|
||||||
|
const { data: historicalLogsData } = useWorkOrderLogs(workOrderId, { limit: 500 });
|
||||||
|
|
||||||
|
// Fetch step history for completed work orders (persistent data)
|
||||||
|
const { data: stepHistoryData } = useStepHistory(workOrderId);
|
||||||
|
|
||||||
|
// Calculate progress from step history (fallback for completed work orders)
|
||||||
|
const stepsProgress = useCalculateProgressFromSteps(stepHistoryData);
|
||||||
|
const stepsLogs = useConvertStepsToLogs(stepHistoryData);
|
||||||
|
|
||||||
|
// Data priority: SSE > Historical Logs API > Step History
|
||||||
|
const logs =
|
||||||
|
sseLogs && sseLogs.length > 0
|
||||||
|
? sseLogs
|
||||||
|
: historicalLogsData?.log_entries && historicalLogsData.log_entries.length > 0
|
||||||
|
? historicalLogsData.log_entries
|
||||||
|
: stepsLogs;
|
||||||
|
|
||||||
|
const progress = sseProgress || stepsProgress;
|
||||||
|
|
||||||
|
// Logs are "live" only if coming from SSE
|
||||||
|
const isLiveData = sseLogs && sseLogs.length > 0;
|
||||||
|
|
||||||
|
// Live elapsed time that updates every second
|
||||||
|
const [currentElapsedSeconds, setCurrentElapsedSeconds] = useState<number | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to SSE on mount for real-time updates
|
||||||
|
* Note: connectToLogs and disconnectFromLogs are stable Zustand actions
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (workOrderId) {
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
return () => disconnectFromLogs(workOrderId);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [workOrderId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update elapsed time every second if work order is running
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const isRunning = progress?.status !== "completed" && progress?.status !== "failed";
|
||||||
|
|
||||||
|
if (!progress || !isRunning) {
|
||||||
|
setCurrentElapsedSeconds(progress?.elapsedSeconds ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start from last known elapsed time or 0
|
||||||
|
const startTime = Date.now();
|
||||||
|
const initialElapsed = progress.elapsedSeconds || 0;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const additionalSeconds = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
setCurrentElapsedSeconds(initialElapsed + additionalSeconds);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [progress?.status, progress?.elapsedSeconds, progress]);
|
||||||
|
|
||||||
|
// Only hide if we have absolutely no data from any source
|
||||||
|
if (!progress && logs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = progress?.currentStep || "initializing";
|
||||||
|
const stepDisplay =
|
||||||
|
progress?.stepNumber !== undefined && progress?.totalSteps !== undefined
|
||||||
|
? `(${progress.stepNumber}/${progress.totalSteps})`
|
||||||
|
: "";
|
||||||
|
const progressPct = progress?.progressPct || 0;
|
||||||
|
const elapsedSeconds = currentElapsedSeconds !== null ? currentElapsedSeconds : progress?.elapsedSeconds || 0;
|
||||||
|
const latestLog = logs[logs.length - 1];
|
||||||
|
const currentActivity = latestLog?.event || "Initializing workflow...";
|
||||||
|
|
||||||
|
// Determine status for display
|
||||||
|
const status = progress?.status || "running";
|
||||||
|
const isRunning = status === "running";
|
||||||
|
const isCompleted = status === "completed";
|
||||||
|
const isFailed = status === "failed";
|
||||||
|
|
||||||
|
// Status display configuration
|
||||||
|
const statusConfig = {
|
||||||
|
running: { label: "Running", color: "text-blue-600 dark:text-blue-400", bgColor: "bg-blue-500 dark:bg-blue-400" },
|
||||||
|
completed: {
|
||||||
|
label: "Completed",
|
||||||
|
color: "text-green-600 dark:text-green-400",
|
||||||
|
bgColor: "bg-green-500 dark:bg-green-400",
|
||||||
|
},
|
||||||
|
failed: { label: "Failed", color: "text-red-600 dark:text-red-400", bgColor: "bg-red-500 dark:bg-red-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStatus = statusConfig[status as keyof typeof statusConfig] || statusConfig.running;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg p-4 bg-black/20 dark:bg-white/5 backdrop-blur">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" aria-hidden="true" />
|
||||||
|
{isRunning ? "Real-Time Execution" : "Execution Summary"}
|
||||||
|
</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 dark:text-gray-400 uppercase tracking-wide">Current Step</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{currentStep}
|
||||||
|
{stepDisplay && <span className="text-gray-500 dark:text-gray-400 ml-2">{stepDisplay}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" aria-hidden="true" />
|
||||||
|
Progress
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0 h-2 bg-gray-700 dark:bg-gray-200/20 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 dark:from-cyan-400 dark:to-blue-400 transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-cyan-600 dark:text-cyan-400">{progressPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Elapsed Time */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||||
|
Elapsed Time
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">{formatDuration(elapsedSeconds)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Activity with Status Indicator - at top */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-white/10 dark:border-gray-700/30">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap">
|
||||||
|
Latest Activity:
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-900 dark:text-gray-300 flex-1 min-w-0 truncate">{currentActivity}</div>
|
||||||
|
</div>
|
||||||
|
{/* Status Indicator - right side of Latest Activity */}
|
||||||
|
<div className={`flex items-center gap-1 text-xs ${currentStatus.color} flex-shrink-0`}>
|
||||||
|
<div className={`w-2 h-2 ${currentStatus.bgColor} rounded-full ${isRunning ? "animate-pulse" : ""}`} />
|
||||||
|
<span>{currentStatus.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show Execution Logs button - at bottom */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-white/10 dark:border-gray-700/30">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
|
className="w-full justify-center text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||||
|
aria-label={showLogs ? "Hide execution logs" : "Show execution logs"}
|
||||||
|
aria-expanded={showLogs}
|
||||||
|
>
|
||||||
|
{showLogs ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
Hide Execution Logs
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
Show Execution Logs
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Execution Logs */}
|
||||||
|
{showLogs && (
|
||||||
|
<ExecutionLogs
|
||||||
|
logs={logs}
|
||||||
|
isLive={isLiveData}
|
||||||
|
onClearLogs={() => {
|
||||||
|
if (workOrderId) {
|
||||||
|
clearLogs(workOrderId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* Repository Card Component
|
||||||
|
*
|
||||||
|
* Displays a configured repository with custom stat pills matching the example layout.
|
||||||
|
* Uses SelectableCard primitive with glassmorphism styling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Activity, CheckCircle2, Clock, Copy, Edit, Trash2 } from "lucide-react";
|
||||||
|
import { SelectableCard } from "@/features/ui/primitives/selectable-card";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { ConfiguredRepository } from "../types/repository";
|
||||||
|
|
||||||
|
export interface RepositoryCardProps {
|
||||||
|
/** Repository data to display */
|
||||||
|
repository: ConfiguredRepository;
|
||||||
|
|
||||||
|
/** Whether this repository is currently selected */
|
||||||
|
isSelected?: boolean;
|
||||||
|
|
||||||
|
/** Whether to show aurora glow effect (when selected) */
|
||||||
|
showAuroraGlow?: boolean;
|
||||||
|
|
||||||
|
/** Callback when repository is selected */
|
||||||
|
onSelect?: () => void;
|
||||||
|
|
||||||
|
/** Callback when delete button is clicked */
|
||||||
|
onDelete?: () => void;
|
||||||
|
|
||||||
|
/** Work order statistics for this repository */
|
||||||
|
stats?: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
done: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get background class based on card state
|
||||||
|
*/
|
||||||
|
function getBackgroundClass(isSelected: boolean): string {
|
||||||
|
if (isSelected) {
|
||||||
|
return "bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20";
|
||||||
|
}
|
||||||
|
return "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard
|
||||||
|
*/
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryCard({
|
||||||
|
repository,
|
||||||
|
isSelected = false,
|
||||||
|
showAuroraGlow = false,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
stats = { total: 0, active: 0, done: 0 },
|
||||||
|
}: RepositoryCardProps) {
|
||||||
|
// Get modal action from Zustand store (no prop drilling)
|
||||||
|
const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
|
||||||
|
|
||||||
|
const backgroundClass = getBackgroundClass(isSelected);
|
||||||
|
|
||||||
|
const handleCopyUrl = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const success = await copyToClipboard(repository.repository_url);
|
||||||
|
if (success) {
|
||||||
|
// Could add toast notification here
|
||||||
|
console.log("Repository URL copied to clipboard");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditRepoModal(repository);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectableCard
|
||||||
|
isSelected={isSelected}
|
||||||
|
isPinned={false}
|
||||||
|
showAuroraGlow={showAuroraGlow}
|
||||||
|
onSelect={onSelect}
|
||||||
|
size="none"
|
||||||
|
blur="xl"
|
||||||
|
className={cn("w-72 min-h-[180px] flex flex-col shrink-0", backgroundClass)}
|
||||||
|
>
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 min-w-0 p-3 pb-2">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="flex flex-col items-center justify-center mb-4 min-h-[48px]">
|
||||||
|
<h3
|
||||||
|
className={cn(
|
||||||
|
"font-medium text-center leading-tight line-clamp-2 transition-all duration-300",
|
||||||
|
isSelected
|
||||||
|
? "text-gray-900 dark:text-white drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]"
|
||||||
|
: "text-gray-500 dark:text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{repository.display_name || repository.repository_url.replace("https://github.com/", "")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Work order count pills - 3 custom pills with icons */}
|
||||||
|
<div className="flex items-stretch gap-2 w-full">
|
||||||
|
{/* Total pill */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 bg-pink-600 dark:bg-pink-400 rounded-full blur-md",
|
||||||
|
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||||
|
isSelected
|
||||||
|
? "bg-white/70 dark:bg-zinc-900/90 border-pink-300 dark:border-pink-500/50 dark:shadow-[0_0_10px_rgba(236,72,153,0.5)]"
|
||||||
|
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||||
|
<Clock
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[8px] font-medium",
|
||||||
|
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center justify-center border-l border-pink-300 dark:border-pink-500/30">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
isSelected ? "text-pink-600 dark:text-pink-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stats.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* In Progress pill */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 bg-blue-600 dark:bg-blue-400 rounded-full blur-md",
|
||||||
|
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||||
|
isSelected
|
||||||
|
? "bg-white/70 dark:bg-zinc-900/90 border-blue-300 dark:border-blue-500/50 dark:shadow-[0_0_10px_rgba(59,130,246,0.5)]"
|
||||||
|
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||||
|
<Activity
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[8px] font-medium",
|
||||||
|
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center justify-center border-l border-blue-300 dark:border-blue-500/30">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stats.active}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed pill */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 bg-green-600 dark:bg-green-400 rounded-full blur-md",
|
||||||
|
isSelected ? "opacity-30 dark:opacity-75" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center h-12 backdrop-blur-sm rounded-full border shadow-sm transition-all duration-300",
|
||||||
|
isSelected
|
||||||
|
? "bg-white/70 dark:bg-zinc-900/90 border-green-300 dark:border-green-500/50 dark:shadow-[0_0_10px_rgba(34,197,94,0.5)]"
|
||||||
|
: "bg-white/30 dark:bg-zinc-900/30 border-gray-300/50 dark:border-gray-700/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center justify-center px-2 min-w-[40px]">
|
||||||
|
<CheckCircle2
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4",
|
||||||
|
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[8px] font-medium",
|
||||||
|
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 flex items-center justify-center border-l border-green-300 dark:border-green-500/30">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-bold",
|
||||||
|
isSelected ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stats.done}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verification status */}
|
||||||
|
{repository.is_verified && (
|
||||||
|
<div className="flex justify-center mt-3">
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400">✓ Verified</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar with action icons */}
|
||||||
|
<div className="flex items-center justify-end gap-2 px-3 py-2 mt-auto border-t border-gray-200/30 dark:border-gray-700/20">
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* Edit button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="p-1.5 rounded-md hover:bg-purple-500/10 dark:hover:bg-purple-500/20 text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
||||||
|
aria-label="Edit repository"
|
||||||
|
>
|
||||||
|
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Edit</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Copy URL button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className="p-1.5 rounded-md hover:bg-cyan-500/10 dark:hover:bg-cyan-500/20 text-gray-500 dark:text-gray-400 hover:text-cyan-500 dark:hover:text-cyan-400 transition-colors"
|
||||||
|
aria-label="Copy repository URL"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy URL</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-1.5 rounded-md hover:bg-red-500/10 dark:hover:bg-red-500/20 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
aria-label="Delete repository"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</SelectableCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar Repository Card Component
|
||||||
|
*
|
||||||
|
* Compact version of RepositoryCard for sidebar layout.
|
||||||
|
* Shows repository name, pin badge, and inline stat pills.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Activity, CheckCircle2, Clock, Copy, Edit, Pin, Trash2 } from "lucide-react";
|
||||||
|
import { StatPill } from "@/features/ui/primitives/pill";
|
||||||
|
import { SelectableCard } from "@/features/ui/primitives/selectable-card";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
|
||||||
|
import { copyToClipboard } from "@/features/shared/utils/clipboard";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { ConfiguredRepository } from "../types/repository";
|
||||||
|
|
||||||
|
export interface SidebarRepositoryCardProps {
|
||||||
|
/** Repository data to display */
|
||||||
|
repository: ConfiguredRepository;
|
||||||
|
|
||||||
|
/** Whether this repository is currently selected */
|
||||||
|
isSelected?: boolean;
|
||||||
|
|
||||||
|
/** Whether this repository is pinned */
|
||||||
|
isPinned?: boolean;
|
||||||
|
|
||||||
|
/** Whether to show aurora glow effect (when selected) */
|
||||||
|
showAuroraGlow?: boolean;
|
||||||
|
|
||||||
|
/** Callback when repository is selected */
|
||||||
|
onSelect?: () => void;
|
||||||
|
|
||||||
|
/** Callback when delete button is clicked */
|
||||||
|
onDelete?: () => void;
|
||||||
|
|
||||||
|
/** Work order statistics for this repository */
|
||||||
|
stats?: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
done: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static lookup map for background gradient classes
|
||||||
|
*/
|
||||||
|
const BACKGROUND_CLASSES = {
|
||||||
|
pinned:
|
||||||
|
"bg-gradient-to-b from-purple-100/80 via-purple-50/30 to-purple-100/50 dark:from-purple-900/30 dark:via-purple-900/20 dark:to-purple-900/10",
|
||||||
|
selected:
|
||||||
|
"bg-gradient-to-b from-white/70 via-purple-50/20 to-white/50 dark:from-white/5 dark:via-purple-900/5 dark:to-black/20",
|
||||||
|
default: "bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static lookup map for title text classes
|
||||||
|
*/
|
||||||
|
const TITLE_CLASSES = {
|
||||||
|
selected: "text-purple-700 dark:text-purple-300",
|
||||||
|
default: "text-gray-700 dark:text-gray-300",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get background class based on card state
|
||||||
|
*/
|
||||||
|
function getBackgroundClass(isPinned: boolean, isSelected: boolean): string {
|
||||||
|
if (isPinned) return BACKGROUND_CLASSES.pinned;
|
||||||
|
if (isSelected) return BACKGROUND_CLASSES.selected;
|
||||||
|
return BACKGROUND_CLASSES.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get title class based on card state
|
||||||
|
*/
|
||||||
|
function getTitleClass(isSelected: boolean): string {
|
||||||
|
return isSelected ? TITLE_CLASSES.selected : TITLE_CLASSES.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarRepositoryCard({
|
||||||
|
repository,
|
||||||
|
isSelected = false,
|
||||||
|
isPinned = false,
|
||||||
|
showAuroraGlow = false,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
stats = { total: 0, active: 0, done: 0 },
|
||||||
|
}: SidebarRepositoryCardProps) {
|
||||||
|
// Get modal action from Zustand store (no prop drilling)
|
||||||
|
const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
|
||||||
|
|
||||||
|
const backgroundClass = getBackgroundClass(isPinned, isSelected);
|
||||||
|
const titleClass = getTitleClass(isSelected);
|
||||||
|
|
||||||
|
const handleCopyUrl = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const result = await copyToClipboard(repository.repository_url);
|
||||||
|
if (result.success) {
|
||||||
|
console.log("Repository URL copied to clipboard");
|
||||||
|
} else {
|
||||||
|
console.error("Failed to copy repository URL:", result.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditRepoModal(repository);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onDelete) {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectableCard
|
||||||
|
isSelected={isSelected}
|
||||||
|
isPinned={isPinned}
|
||||||
|
showAuroraGlow={showAuroraGlow}
|
||||||
|
onSelect={onSelect}
|
||||||
|
size="none"
|
||||||
|
blur="md"
|
||||||
|
className={cn("p-2 w-56 flex flex-col", backgroundClass)}
|
||||||
|
>
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Title with pin badge - centered */}
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<h4 className={cn("font-medium text-sm line-clamp-1 text-center", titleClass)}>
|
||||||
|
{repository.display_name || repository.repository_url}
|
||||||
|
</h4>
|
||||||
|
{isPinned && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 px-1.5 py-0.5 bg-purple-500 dark:bg-purple-400 text-white text-[9px] font-bold rounded-full shrink-0"
|
||||||
|
aria-label="Pinned repository"
|
||||||
|
>
|
||||||
|
<Pin className="w-2.5 h-2.5" fill="currentColor" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Pills - all 3 in one row with icons - centered */}
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<StatPill
|
||||||
|
color="pink"
|
||||||
|
value={stats.total}
|
||||||
|
size="sm"
|
||||||
|
icon={<Clock className="w-3 h-3" aria-hidden="true" />}
|
||||||
|
aria-label={`${stats.total} total work orders`}
|
||||||
|
/>
|
||||||
|
<StatPill
|
||||||
|
color="blue"
|
||||||
|
value={stats.active}
|
||||||
|
size="sm"
|
||||||
|
icon={<Activity className="w-3 h-3" aria-hidden="true" />}
|
||||||
|
aria-label={`${stats.active} active work orders`}
|
||||||
|
/>
|
||||||
|
<StatPill
|
||||||
|
color="green"
|
||||||
|
value={stats.done}
|
||||||
|
size="sm"
|
||||||
|
icon={<CheckCircle2 className="w-3 h-3" aria-hidden="true" />}
|
||||||
|
aria-label={`${stats.done} completed work orders`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons bar */}
|
||||||
|
<div className="flex items-center justify-center gap-2 px-2 py-2 mt-2 border-t border-gray-200/30 dark:border-gray-700/20">
|
||||||
|
<TooltipProvider>
|
||||||
|
{/* Edit button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEdit}
|
||||||
|
className="p-1.5 rounded-md hover:bg-purple-500/10 dark:hover:bg-purple-500/20 text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-400 transition-colors"
|
||||||
|
aria-label="Edit repository"
|
||||||
|
>
|
||||||
|
<Edit className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Edit</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Copy URL button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
className="p-1.5 rounded-md hover:bg-cyan-500/10 dark:hover:bg-cyan-500/20 text-gray-500 dark:text-gray-400 hover:text-cyan-500 dark:hover:text-cyan-400 transition-colors"
|
||||||
|
aria-label="Copy repository URL"
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Copy URL</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-1.5 rounded-md hover:bg-red-500/10 dark:hover:bg-red-500/20 text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||||
|
aria-label="Delete repository"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Delete</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</SelectableCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Edit3, Eye } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Card } from "@/features/ui/primitives/card";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
|
||||||
|
interface StepHistoryCardProps {
|
||||||
|
step: {
|
||||||
|
id: string;
|
||||||
|
stepName: string;
|
||||||
|
timestamp: string;
|
||||||
|
output: string;
|
||||||
|
session: string;
|
||||||
|
collapsible: boolean;
|
||||||
|
isHumanInLoop?: boolean;
|
||||||
|
};
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
document?: {
|
||||||
|
title: string;
|
||||||
|
content: {
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepHistoryCard = ({ step, isExpanded, onToggle, document }: StepHistoryCardProps) => {
|
||||||
|
const [isEditingDocument, setIsEditingDocument] = useState(false);
|
||||||
|
const [editedContent, setEditedContent] = useState("");
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleEdit = () => {
|
||||||
|
// Only initialize editedContent from document when entering edit mode and there's no existing draft
|
||||||
|
if (!isEditingDocument && document && !editedContent) {
|
||||||
|
setEditedContent(document.content.markdown);
|
||||||
|
}
|
||||||
|
setIsEditingDocument(!isEditingDocument);
|
||||||
|
// Don't clear hasChanges when toggling - preserve unsaved drafts
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (value: string) => {
|
||||||
|
setEditedContent(value);
|
||||||
|
setHasChanges(document ? value !== document.content.markdown : false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveAndContinue = () => {
|
||||||
|
console.log("Approved and continuing to next step");
|
||||||
|
setHasChanges(false);
|
||||||
|
setIsEditingDocument(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
blur="md"
|
||||||
|
transparency="light"
|
||||||
|
edgePosition="left"
|
||||||
|
edgeColor={step.isHumanInLoop ? "orange" : "blue"}
|
||||||
|
size="md"
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white">{step.stepName}</h4>
|
||||||
|
{step.isHumanInLoop && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20">
|
||||||
|
<AlertCircle className="w-3 h-3" aria-hidden="true" />
|
||||||
|
Human-in-Loop
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{step.timestamp}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapse toggle - only show if collapsible */}
|
||||||
|
{step.collapsible && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"px-2 transition-colors",
|
||||||
|
step.isHumanInLoop
|
||||||
|
? "text-orange-500 hover:text-orange-600 dark:hover:text-orange-400"
|
||||||
|
: "text-cyan-500 hover:text-cyan-600 dark:hover:text-cyan-400",
|
||||||
|
)}
|
||||||
|
aria-label={isExpanded ? "Collapse step" : "Expand step"}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - collapsible with animation */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{(isExpanded || !step.collapsible) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
height: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: [0.04, 0.62, 0.23, 0.98],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeInOut",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -20 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: -20 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{/* Output content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-lg border",
|
||||||
|
step.isHumanInLoop
|
||||||
|
? "bg-orange-50/50 dark:bg-orange-950/10 border-orange-200/50 dark:border-orange-800/30"
|
||||||
|
: "bg-cyan-50/30 dark:bg-cyan-950/10 border-cyan-200/50 dark:border-cyan-800/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{step.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-mono",
|
||||||
|
step.isHumanInLoop ? "text-orange-600 dark:text-orange-400" : "text-cyan-600 dark:text-cyan-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.session}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Review and Approve Plan - only for human-in-loop steps with documents */}
|
||||||
|
{step.isHumanInLoop && document && (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Review and Approve Plan</h4>
|
||||||
|
|
||||||
|
{/* Document Card */}
|
||||||
|
<Card blur="md" transparency="light" size="md" className="overflow-visible">
|
||||||
|
{/* View/Edit toggle in top right */}
|
||||||
|
<div className="flex items-center justify-end mb-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleToggleEdit}
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:bg-gray-500/10"
|
||||||
|
aria-label={isEditingDocument ? "Switch to preview mode" : "Switch to edit mode"}
|
||||||
|
>
|
||||||
|
{isEditingDocument ? (
|
||||||
|
<Eye className="w-4 h-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Edit3 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingDocument ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<textarea
|
||||||
|
value={editedContent}
|
||||||
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full min-h-[300px] p-4 rounded-lg",
|
||||||
|
"bg-white/50 dark:bg-black/30",
|
||||||
|
"border border-gray-300 dark:border-gray-700",
|
||||||
|
"text-gray-900 dark:text-white font-mono text-sm",
|
||||||
|
"focus:outline-none focus:border-orange-400 focus:ring-2 focus:ring-orange-400/20",
|
||||||
|
"resize-y",
|
||||||
|
)}
|
||||||
|
placeholder="Enter markdown content..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
h1: ({ node, ...props }) => (
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-3 mt-4" {...props} />
|
||||||
|
),
|
||||||
|
h2: ({ node, ...props }) => (
|
||||||
|
<h2
|
||||||
|
className="text-lg font-semibold text-gray-900 dark:text-white mb-2 mt-3"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h3: ({ node, ...props }) => (
|
||||||
|
<h3
|
||||||
|
className="text-base font-semibold text-gray-900 dark:text-white mb-2 mt-3"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
p: ({ node, ...props }) => (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2 leading-relaxed" {...props} />
|
||||||
|
),
|
||||||
|
ul: ({ node, ...props }) => (
|
||||||
|
<ul
|
||||||
|
className="list-disc list-inside text-sm text-gray-700 dark:text-gray-300 mb-2 space-y-1"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
li: ({ node, ...props }) => <li className="ml-4" {...props} />,
|
||||||
|
code: ({ node, ...props }) => (
|
||||||
|
<code
|
||||||
|
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono text-orange-600 dark:text-orange-400"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Prefer displaying live draft (editedContent) when non-empty/hasChanges over original document content */}
|
||||||
|
{editedContent && hasChanges ? editedContent : document.content.markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Approve button - always visible with glass styling */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200/50 dark:border-gray-700/30">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{hasChanges ? "Unsaved changes" : "No changes"}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleApproveAndContinue}
|
||||||
|
className={cn(
|
||||||
|
"backdrop-blur-md",
|
||||||
|
"bg-gradient-to-b from-green-100/80 to-white/60",
|
||||||
|
"dark:from-green-500/20 dark:to-green-500/10",
|
||||||
|
"text-green-700 dark:text-green-100",
|
||||||
|
"border border-green-300/50 dark:border-green-500/50",
|
||||||
|
"hover:from-green-200/90 hover:to-green-100/70",
|
||||||
|
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
|
||||||
|
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
|
||||||
|
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
|
||||||
|
"shadow-lg shadow-green-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Approve and Move to Next Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Work Order Row Component
|
||||||
|
*
|
||||||
|
* Individual table row for a work order with status indicator, start/details buttons,
|
||||||
|
* and expandable real-time stats section.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronUp, Eye, Play } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { type PillColor, StatPill } from "@/features/ui/primitives/pill";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { AgentWorkOrder } from "../types";
|
||||||
|
import { RealTimeStats } from "./RealTimeStats";
|
||||||
|
|
||||||
|
export interface WorkOrderRowProps {
|
||||||
|
/** Work order data */
|
||||||
|
workOrder: AgentWorkOrder;
|
||||||
|
|
||||||
|
/** Repository display name (from configured repository) */
|
||||||
|
repositoryDisplayName?: string;
|
||||||
|
|
||||||
|
/** Row index for alternating backgrounds */
|
||||||
|
index: number;
|
||||||
|
|
||||||
|
/** Callback when start button is clicked */
|
||||||
|
onStart: (id: string) => void;
|
||||||
|
|
||||||
|
/** Whether this row was just started (auto-expand) */
|
||||||
|
wasJustStarted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status color configuration
|
||||||
|
* Static lookup to avoid dynamic class construction
|
||||||
|
*/
|
||||||
|
interface StatusConfig {
|
||||||
|
color: PillColor;
|
||||||
|
edge: string;
|
||||||
|
glow: string;
|
||||||
|
label: string;
|
||||||
|
stepNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, StatusConfig> = {
|
||||||
|
pending: {
|
||||||
|
color: "pink",
|
||||||
|
edge: "bg-pink-500 dark:bg-pink-400",
|
||||||
|
glow: "rgba(236,72,153,0.5)",
|
||||||
|
label: "Pending",
|
||||||
|
stepNumber: 0,
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
color: "cyan",
|
||||||
|
edge: "bg-cyan-500 dark:bg-cyan-400",
|
||||||
|
glow: "rgba(34,211,238,0.5)",
|
||||||
|
label: "Running",
|
||||||
|
stepNumber: 1,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
color: "green",
|
||||||
|
edge: "bg-green-500 dark:bg-green-400",
|
||||||
|
glow: "rgba(34,197,94,0.5)",
|
||||||
|
label: "Completed",
|
||||||
|
stepNumber: 5,
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
color: "orange",
|
||||||
|
edge: "bg-orange-500 dark:bg-orange-400",
|
||||||
|
glow: "rgba(249,115,22,0.5)",
|
||||||
|
label: "Failed",
|
||||||
|
stepNumber: 0,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status configuration with fallback
|
||||||
|
*/
|
||||||
|
function getStatusConfig(status: string): StatusConfig {
|
||||||
|
return STATUS_COLORS[status] || STATUS_COLORS.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkOrderRow({
|
||||||
|
workOrder: cachedWorkOrder,
|
||||||
|
repositoryDisplayName,
|
||||||
|
index,
|
||||||
|
onStart,
|
||||||
|
wasJustStarted = false,
|
||||||
|
}: WorkOrderRowProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(wasJustStarted);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Subscribe to live progress from Zustand SSE slice
|
||||||
|
const liveProgress = useAgentWorkOrdersStore((s) => s.liveProgress[cachedWorkOrder.agent_work_order_id]);
|
||||||
|
|
||||||
|
// Merge: SSE data overrides cached data
|
||||||
|
const workOrder = {
|
||||||
|
...cachedWorkOrder,
|
||||||
|
...(liveProgress?.status && { status: liveProgress.status as AgentWorkOrder["status"] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = getStatusConfig(workOrder.status);
|
||||||
|
|
||||||
|
const handleStartClick = () => {
|
||||||
|
setIsExpanded(true); // Auto-expand when started
|
||||||
|
onStart(workOrder.agent_work_order_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDetailsClick = () => {
|
||||||
|
navigate(`/agent-work-orders/${workOrder.agent_work_order_id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = workOrder.status === "pending";
|
||||||
|
const canExpand = !isPending; // Only non-pending rows can be expanded
|
||||||
|
|
||||||
|
// Use display name if available, otherwise extract from URL
|
||||||
|
const displayRepo = repositoryDisplayName || workOrder.repository_url.split("/").slice(-2).join("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Main row */}
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"group transition-all duration-200",
|
||||||
|
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
|
||||||
|
"hover:bg-gradient-to-r hover:from-cyan-50/70 hover:to-purple-50/70 dark:hover:from-cyan-900/20 dark:hover:to-purple-900/20",
|
||||||
|
"border-b border-gray-200 dark:border-gray-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Status indicator - glowing circle with optional collapse button */}
|
||||||
|
<td className="px-3 py-2 w-12">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{canExpand && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-3 h-3 text-gray-600 dark:text-gray-400" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-gray-600 dark:text-gray-400" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn("w-3 h-3 rounded-full", statusConfig.edge)}
|
||||||
|
style={{ boxShadow: `0 0 8px ${statusConfig.glow}` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Work Order ID */}
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{workOrder.agent_work_order_id}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Repository */}
|
||||||
|
<td className="px-4 py-2 w-40">
|
||||||
|
<span className="text-sm text-gray-900 dark:text-white">{displayRepo}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Branch */}
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white line-clamp-2">
|
||||||
|
{workOrder.git_branch_name || <span className="text-gray-400 dark:text-gray-500">-</span>}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status Badge - using StatPill */}
|
||||||
|
<td className="px-4 py-2 w-32">
|
||||||
|
<StatPill color={statusConfig.color} value={statusConfig.label} size="sm" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-4 py-2 w-32">
|
||||||
|
{isPending ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleStartClick}
|
||||||
|
size="xs"
|
||||||
|
variant="green"
|
||||||
|
className="w-full text-xs"
|
||||||
|
aria-label="Start work order"
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
|
Start
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleDetailsClick}
|
||||||
|
size="xs"
|
||||||
|
variant="blue"
|
||||||
|
className="w-full text-xs"
|
||||||
|
aria-label="View work order details"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded row with real-time stats - shows live or historical data */}
|
||||||
|
{isExpanded && canExpand && (
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
index % 2 === 0 ? "bg-white/50 dark:bg-black/50" : "bg-gray-50/80 dark:bg-gray-900/30",
|
||||||
|
"border-b border-gray-200 dark:border-gray-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td colSpan={6} className="px-4 py-4">
|
||||||
|
<RealTimeStats workOrderId={workOrder.agent_work_order_id} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Work Order Table Component
|
||||||
|
*
|
||||||
|
* Displays work orders in a table with start buttons, status indicators,
|
||||||
|
* and expandable real-time stats.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRepositories } from "../hooks/useRepositoryQueries";
|
||||||
|
import type { AgentWorkOrder } from "../types";
|
||||||
|
import { WorkOrderRow } from "./WorkOrderRow";
|
||||||
|
|
||||||
|
export interface WorkOrderTableProps {
|
||||||
|
/** Array of work orders to display */
|
||||||
|
workOrders: AgentWorkOrder[];
|
||||||
|
|
||||||
|
/** Optional repository ID to filter work orders */
|
||||||
|
selectedRepositoryId?: string;
|
||||||
|
|
||||||
|
/** Callback when start button is clicked */
|
||||||
|
onStartWorkOrder: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced work order with repository display name
|
||||||
|
*/
|
||||||
|
interface EnhancedWorkOrder extends AgentWorkOrder {
|
||||||
|
repositoryDisplayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkOrderTable({ workOrders, selectedRepositoryId, onStartWorkOrder }: WorkOrderTableProps) {
|
||||||
|
const [justStartedId, setJustStartedId] = useState<string | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const { data: repositories = [] } = useRepositories();
|
||||||
|
|
||||||
|
// Create a map of repository URL to display name for quick lookup
|
||||||
|
const repoUrlToDisplayName = repositories.reduce(
|
||||||
|
(acc, repo) => {
|
||||||
|
acc[repo.repository_url] = repo.display_name || repo.repository_url.split("/").slice(-2).join("/");
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter work orders based on selected repository
|
||||||
|
// Find the repository URL from the selected repository ID, then filter work orders by that URL
|
||||||
|
const filteredWorkOrders = selectedRepositoryId
|
||||||
|
? (() => {
|
||||||
|
const selectedRepo = repositories.find((r) => r.id === selectedRepositoryId);
|
||||||
|
return selectedRepo ? workOrders.filter((wo) => wo.repository_url === selectedRepo.repository_url) : workOrders;
|
||||||
|
})()
|
||||||
|
: workOrders;
|
||||||
|
|
||||||
|
// Enhance work orders with display names
|
||||||
|
const enhancedWorkOrders: EnhancedWorkOrder[] = filteredWorkOrders.map((wo) => ({
|
||||||
|
...wo,
|
||||||
|
repositoryDisplayName: repoUrlToDisplayName[wo.repository_url],
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle start button click with auto-expand tracking
|
||||||
|
*/
|
||||||
|
const handleStart = (id: string) => {
|
||||||
|
setJustStartedId(id);
|
||||||
|
onStartWorkOrder(id);
|
||||||
|
|
||||||
|
// Clear any existing timeout before scheduling a new one
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the tracking after animation
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setJustStartedId(null);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount to prevent setState after unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Show empty state if no work orders
|
||||||
|
if (filteredWorkOrders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-2">No work orders found</p>
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{selectedRepositoryId
|
||||||
|
? "Create a work order for this repository to get started"
|
||||||
|
: "Create a work order to get started"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto scrollbar-hide">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-b-2 border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="w-12" aria-label="Status indicator" />
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">WO ID</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-40">
|
||||||
|
Repository
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Branch
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300 w-32">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{enhancedWorkOrders.map((workOrder, index) => (
|
||||||
|
<WorkOrderRow
|
||||||
|
key={workOrder.agent_work_order_id}
|
||||||
|
workOrder={workOrder}
|
||||||
|
repositoryDisplayName={workOrder.repositoryDisplayName}
|
||||||
|
index={index}
|
||||||
|
onStart={handleStart}
|
||||||
|
wasJustStarted={workOrder.agent_work_order_id === justStartedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type React from "react";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
|
||||||
|
interface WorkflowStepButtonProps {
|
||||||
|
isCompleted: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
stepName: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
color?: "cyan" | "green" | "blue" | "purple";
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get color hex values for animations
|
||||||
|
const getColorValue = (color: string) => {
|
||||||
|
const colorValues = {
|
||||||
|
purple: "rgb(168,85,247)",
|
||||||
|
green: "rgb(34,197,94)",
|
||||||
|
blue: "rgb(59,130,246)",
|
||||||
|
cyan: "rgb(34,211,238)",
|
||||||
|
};
|
||||||
|
return colorValues[color as keyof typeof colorValues] || colorValues.blue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||||
|
isCompleted,
|
||||||
|
isActive,
|
||||||
|
stepName,
|
||||||
|
onClick,
|
||||||
|
color = "cyan",
|
||||||
|
size = 40,
|
||||||
|
}) => {
|
||||||
|
const colorMap = {
|
||||||
|
purple: {
|
||||||
|
border: "border-purple-400 dark:border-purple-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(168,85,247,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(168,85,247,1)]",
|
||||||
|
fill: "bg-purple-400 dark:bg-purple-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(168,85,247,0.8)]",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
border: "border-green-400 dark:border-green-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(34,197,94,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(34,197,94,1)]",
|
||||||
|
fill: "bg-green-400 dark:bg-green-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(34,197,94,0.8)]",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
border: "border-blue-400 dark:border-blue-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(59,130,246,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(59,130,246,1)]",
|
||||||
|
fill: "bg-blue-400 dark:bg-blue-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(59,130,246,0.8)]",
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
border: "border-cyan-400 dark:border-cyan-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(34,211,238,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(34,211,238,1)]",
|
||||||
|
fill: "bg-cyan-400 dark:bg-cyan-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(34,211,238,0.8)]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Label colors matching the color prop
|
||||||
|
const labelColorMap = {
|
||||||
|
purple: "text-purple-400 dark:text-purple-300",
|
||||||
|
green: "text-green-400 dark:text-green-300",
|
||||||
|
blue: "text-blue-400 dark:text-blue-300",
|
||||||
|
cyan: "text-cyan-400 dark:text-cyan-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = colorMap[color] || colorMap.cyan;
|
||||||
|
const labelColor = labelColorMap[color] || labelColorMap.cyan;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<motion.button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-full border-2 transition-all duration-300",
|
||||||
|
styles.border,
|
||||||
|
isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]",
|
||||||
|
styles.glowHover,
|
||||||
|
"bg-gradient-to-b from-gray-900 to-black dark:from-gray-800 dark:to-gray-900",
|
||||||
|
"hover:scale-110 active:scale-95",
|
||||||
|
)}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${stepName} - ${isCompleted ? "completed" : isActive ? "in progress" : "pending"}`}
|
||||||
|
>
|
||||||
|
{/* Outer ring glow effect */}
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-[-4px] rounded-full border-2 blur-sm",
|
||||||
|
isCompleted ? styles.border : "border-transparent",
|
||||||
|
)}
|
||||||
|
animate={{
|
||||||
|
opacity: isCompleted ? [0.3, 0.6, 0.3] : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inner glow effect */}
|
||||||
|
<motion.div
|
||||||
|
className={cn("absolute inset-[2px] rounded-full blur-md opacity-20", isCompleted && styles.fill)}
|
||||||
|
animate={{
|
||||||
|
opacity: isCompleted ? [0.1, 0.3, 0.1] : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Checkmark icon container */}
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
|
<motion.svg
|
||||||
|
width={size * 0.5}
|
||||||
|
height={size * 0.5}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="relative z-10"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${stepName} status indicator`}
|
||||||
|
animate={{
|
||||||
|
filter: isCompleted
|
||||||
|
? [
|
||||||
|
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
|
||||||
|
`drop-shadow(0 0 12px ${getColorValue(color)}) drop-shadow(0 0 16px ${getColorValue(color)})`,
|
||||||
|
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
|
||||||
|
]
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Checkmark path */}
|
||||||
|
<path
|
||||||
|
d="M20 6L9 17l-5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={isCompleted ? "text-white" : "text-gray-600"}
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* Step name label */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium transition-colors",
|
||||||
|
isCompleted
|
||||||
|
? labelColor
|
||||||
|
: isActive
|
||||||
|
? labelColor
|
||||||
|
: "text-gray-500 dark:text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stepName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* CreateWorkOrderModal Component Tests
|
||||||
|
*
|
||||||
|
* Tests for create work order modal form validation and submission.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CreateWorkOrderModal } from "../CreateWorkOrderModal";
|
||||||
|
|
||||||
|
// Mock the hooks
|
||||||
|
vi.mock("../../hooks/useAgentWorkOrderQueries", () => ({
|
||||||
|
useCreateWorkOrder: () => ({
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({
|
||||||
|
agent_work_order_id: "wo-new",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useRepositoryQueries", () => ({
|
||||||
|
useRepositories: () => ({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/ui/hooks/useToast", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CreateWorkOrderModal", () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
it("should render when open", () => {
|
||||||
|
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||||
|
|
||||||
|
expect(screen.getByText("Create Work Order")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not render when closed", () => {
|
||||||
|
render(<CreateWorkOrderModal open={false} onOpenChange={vi.fn()} />, { wrapper });
|
||||||
|
|
||||||
|
expect(screen.queryByText("Create Work Order")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pre-populate fields from selected repository", async () => {
|
||||||
|
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} selectedRepositoryId="repo-1" />, {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for repository data to be populated
|
||||||
|
await waitFor(() => {
|
||||||
|
const urlInput = screen.getByLabelText("Repository URL") as HTMLInputElement;
|
||||||
|
expect(urlInput.value).toBe("https://github.com/test/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show validation error for empty request", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||||
|
|
||||||
|
// Try to submit without filling required fields
|
||||||
|
const submitButton = screen.getByRole("button", { name: "Create Work Order" });
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Should show validation error
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/Request must be at least 10 characters/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable commit and PR steps when execute is not selected", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||||
|
|
||||||
|
// Uncheck execute step
|
||||||
|
const executeCheckbox = screen.getByLabelText("Execute");
|
||||||
|
await user.click(executeCheckbox);
|
||||||
|
|
||||||
|
// Commit and PR should be disabled
|
||||||
|
const commitCheckbox = screen.getByLabelText("Commit Changes") as HTMLInputElement;
|
||||||
|
const prCheckbox = screen.getByLabelText("Create Pull Request") as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(commitCheckbox).toBeDisabled();
|
||||||
|
expect(prCheckbox).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have accessible form labels", () => {
|
||||||
|
render(<CreateWorkOrderModal open={true} onOpenChange={vi.fn()} />, { wrapper });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Repository")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Repository URL")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Work Request")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* RepositoryCard Component Tests
|
||||||
|
*
|
||||||
|
* Tests for repository card rendering and interactions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConfiguredRepository } from "../../types/repository";
|
||||||
|
import { RepositoryCard } from "../RepositoryCard";
|
||||||
|
|
||||||
|
const mockRepository: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repository",
|
||||||
|
display_name: "test/repository",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("RepositoryCard", () => {
|
||||||
|
it("should render repository name and URL", () => {
|
||||||
|
render(<RepositoryCard repository={mockRepository} stats={{ total: 5, active: 2, done: 3 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("test/repository")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/test\/repository/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display work order stats", () => {
|
||||||
|
render(<RepositoryCard repository={mockRepository} stats={{ total: 5, active: 2, done: 3 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("5 total work orders")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("2 active work orders")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("3 completed work orders")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show verified status when repository is verified", () => {
|
||||||
|
render(<RepositoryCard repository={mockRepository} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("✓ Verified")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSelect when clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
render(<RepositoryCard repository={mockRepository} onSelect={onSelect} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole("button", { name: /test\/repository/i });
|
||||||
|
await user.click(card);
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show pin indicator when isPinned is true", () => {
|
||||||
|
render(<RepositoryCard repository={mockRepository} isPinned={true} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Pinned")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onPin when pin button clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onPin = vi.fn();
|
||||||
|
|
||||||
|
render(<RepositoryCard repository={mockRepository} onPin={onPin} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
const pinButton = screen.getByLabelText("Pin repository");
|
||||||
|
await user.click(pinButton);
|
||||||
|
|
||||||
|
expect(onPin).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onDelete when delete button clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
|
||||||
|
render(<RepositoryCard repository={mockRepository} onDelete={onDelete} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByLabelText("Delete repository");
|
||||||
|
await user.click(deleteButton);
|
||||||
|
|
||||||
|
expect(onDelete).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support keyboard navigation (Enter key)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
render(<RepositoryCard repository={mockRepository} onSelect={onSelect} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole("button", { name: /test\/repository/i });
|
||||||
|
card.focus();
|
||||||
|
await user.keyboard("{Enter}");
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper ARIA attributes", () => {
|
||||||
|
render(<RepositoryCard repository={mockRepository} isSelected={true} stats={{ total: 0, active: 0, done: 0 }} />);
|
||||||
|
|
||||||
|
const card = screen.getByRole("button");
|
||||||
|
expect(card).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* 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(),
|
||||||
|
startWorkOrder: 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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useStartWorkOrder", () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should start a pending work order with optimistic update", async () => {
|
||||||
|
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
|
||||||
|
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
|
||||||
|
|
||||||
|
const mockPendingWorkOrder = {
|
||||||
|
agent_work_order_id: "wo-123",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
sandbox_identifier: "sandbox-123",
|
||||||
|
git_branch_name: null,
|
||||||
|
agent_session_id: null,
|
||||||
|
sandbox_type: "git_worktree" as const,
|
||||||
|
github_issue_number: null,
|
||||||
|
status: "pending" as const,
|
||||||
|
current_phase: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
github_pull_request_url: null,
|
||||||
|
git_commit_count: 0,
|
||||||
|
git_files_changed: 0,
|
||||||
|
error_message: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRunningWorkOrder = {
|
||||||
|
...mockPendingWorkOrder,
|
||||||
|
status: "running" as const,
|
||||||
|
updated_at: "2024-01-01T00:01:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data in cache
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
|
||||||
|
|
||||||
|
vi.mocked(agentWorkOrdersService.startWorkOrder).mockResolvedValue(mockRunningWorkOrder);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate("wo-123");
|
||||||
|
|
||||||
|
// Verify optimistic update happened immediately
|
||||||
|
await waitFor(() => {
|
||||||
|
const data = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
|
||||||
|
expect((data as any)?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for mutation to complete
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(agentWorkOrdersService.startWorkOrder).toHaveBeenCalledWith("wo-123");
|
||||||
|
expect(result.current.data).toEqual(mockRunningWorkOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rollback on error", async () => {
|
||||||
|
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
|
||||||
|
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
|
||||||
|
|
||||||
|
const mockPendingWorkOrder = {
|
||||||
|
agent_work_order_id: "wo-123",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
sandbox_identifier: "sandbox-123",
|
||||||
|
git_branch_name: null,
|
||||||
|
agent_session_id: null,
|
||||||
|
sandbox_type: "git_worktree" as const,
|
||||||
|
github_issue_number: null,
|
||||||
|
status: "pending" as const,
|
||||||
|
current_phase: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
github_pull_request_url: null,
|
||||||
|
git_commit_count: 0,
|
||||||
|
git_files_changed: 0,
|
||||||
|
error_message: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data in cache
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
|
||||||
|
|
||||||
|
const error = new Error("Failed to start work order");
|
||||||
|
vi.mocked(agentWorkOrdersService.startWorkOrder).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate("wo-123");
|
||||||
|
|
||||||
|
// Wait for mutation to fail
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Verify data was rolled back to pending status
|
||||||
|
const data = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
|
||||||
|
expect((data as any)?.status).toBe("pending");
|
||||||
|
|
||||||
|
const listData = queryClient.getQueryData(agentWorkOrderKeys.lists()) as any[];
|
||||||
|
expect(listData[0]?.status).toBe("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update both detail and list caches on success", async () => {
|
||||||
|
const { agentWorkOrdersService } = await import("../../services/agentWorkOrdersService");
|
||||||
|
const { useStartWorkOrder } = await import("../useAgentWorkOrderQueries");
|
||||||
|
|
||||||
|
const mockPendingWorkOrder = {
|
||||||
|
agent_work_order_id: "wo-123",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
sandbox_identifier: "sandbox-123",
|
||||||
|
git_branch_name: null,
|
||||||
|
agent_session_id: null,
|
||||||
|
sandbox_type: "git_worktree" as const,
|
||||||
|
github_issue_number: null,
|
||||||
|
status: "pending" as const,
|
||||||
|
current_phase: null,
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
github_pull_request_url: null,
|
||||||
|
git_commit_count: 0,
|
||||||
|
git_files_changed: 0,
|
||||||
|
error_message: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRunningWorkOrder = {
|
||||||
|
...mockPendingWorkOrder,
|
||||||
|
status: "running" as const,
|
||||||
|
updated_at: "2024-01-01T00:01:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data in cache
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.detail("wo-123"), mockPendingWorkOrder);
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.lists(), [mockPendingWorkOrder]);
|
||||||
|
|
||||||
|
vi.mocked(agentWorkOrdersService.startWorkOrder).mockResolvedValue(mockRunningWorkOrder);
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStartWorkOrder(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate("wo-123");
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// Verify both detail and list caches updated
|
||||||
|
const detailData = queryClient.getQueryData(agentWorkOrderKeys.detail("wo-123"));
|
||||||
|
expect((detailData as any)?.status).toBe("running");
|
||||||
|
|
||||||
|
const listData = queryClient.getQueryData(agentWorkOrderKeys.lists()) as any[];
|
||||||
|
expect(listData[0]?.status).toBe("running");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
/**
|
||||||
|
* Repository Query Hooks Tests
|
||||||
|
*
|
||||||
|
* Unit tests for repository query hooks.
|
||||||
|
* Mocks repositoryService and query patterns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type React from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../../types/repository";
|
||||||
|
import {
|
||||||
|
repositoryKeys,
|
||||||
|
useCreateRepository,
|
||||||
|
useDeleteRepository,
|
||||||
|
useRepositories,
|
||||||
|
useUpdateRepository,
|
||||||
|
useVerifyRepository,
|
||||||
|
} from "../useRepositoryQueries";
|
||||||
|
|
||||||
|
// Mock the repository service
|
||||||
|
vi.mock("../../services/repositoryService", () => ({
|
||||||
|
repositoryService: {
|
||||||
|
listRepositories: vi.fn(),
|
||||||
|
createRepository: vi.fn(),
|
||||||
|
updateRepository: vi.fn(),
|
||||||
|
deleteRepository: vi.fn(),
|
||||||
|
verifyRepositoryAccess: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock shared patterns
|
||||||
|
vi.mock("@/features/shared/config/queryPatterns", () => ({
|
||||||
|
DISABLED_QUERY_KEY: ["disabled"] as const,
|
||||||
|
STALE_TIMES: {
|
||||||
|
instant: 0,
|
||||||
|
realtime: 3000,
|
||||||
|
frequent: 5000,
|
||||||
|
normal: 30000,
|
||||||
|
rare: 300000,
|
||||||
|
static: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock toast hook
|
||||||
|
vi.mock("@/features/ui/hooks/useToast", () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { repositoryService } from "../../services/repositoryService";
|
||||||
|
|
||||||
|
describe("useRepositoryQueries", () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create fresh query client for each test
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("repositoryKeys", () => {
|
||||||
|
it("should generate correct query keys", () => {
|
||||||
|
expect(repositoryKeys.all).toEqual(["repositories"]);
|
||||||
|
expect(repositoryKeys.lists()).toEqual(["repositories", "list"]);
|
||||||
|
expect(repositoryKeys.detail("repo-1")).toEqual(["repositories", "detail", "repo-1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useRepositories", () => {
|
||||||
|
it("should fetch repositories list", async () => {
|
||||||
|
const mockRepositories: ConfiguredRepository[] = [
|
||||||
|
{
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(repositoryService.listRepositories).mockResolvedValue(mockRepositories);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockRepositories);
|
||||||
|
expect(repositoryService.listRepositories).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty repository list", async () => {
|
||||||
|
vi.mocked(repositoryService.listRepositories).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle errors", async () => {
|
||||||
|
const error = new Error("Network error");
|
||||||
|
vi.mocked(repositoryService.listRepositories).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRepositories(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.error).toEqual(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useCreateRepository", () => {
|
||||||
|
it("should create repository with optimistic update", async () => {
|
||||||
|
const request: CreateRepositoryRequest = {
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
verify: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(repositoryService.createRepository).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCreateRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repositoryService.createRepository).toHaveBeenCalledWith(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rollback on error", async () => {
|
||||||
|
const request: CreateRepositoryRequest = {
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
};
|
||||||
|
|
||||||
|
const error = new Error("Creation failed");
|
||||||
|
vi.mocked(repositoryService.createRepository).mockRejectedValue(error);
|
||||||
|
|
||||||
|
// Set initial data
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), []);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useCreateRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.mutateAsync(request);
|
||||||
|
} catch {
|
||||||
|
// Expected error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should rollback to empty array
|
||||||
|
const data = queryClient.getQueryData(repositoryKeys.lists());
|
||||||
|
expect(data).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useUpdateRepository", () => {
|
||||||
|
it("should update repository with optimistic update", async () => {
|
||||||
|
const id = "repo-1";
|
||||||
|
const request: UpdateRepositoryRequest = {
|
||||||
|
default_sandbox_type: "git_branch",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_branch",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(repositoryService.updateRepository).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync({ id, request });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repositoryService.updateRepository).toHaveBeenCalledWith(id, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rollback on error", async () => {
|
||||||
|
const initialRepo: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
|
||||||
|
|
||||||
|
const error = new Error("Update failed");
|
||||||
|
vi.mocked(repositoryService.updateRepository).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
id: "repo-1",
|
||||||
|
request: { default_sandbox_type: "git_branch" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Expected error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should rollback to initial data
|
||||||
|
const data = queryClient.getQueryData(repositoryKeys.lists());
|
||||||
|
expect(data).toEqual([initialRepo]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDeleteRepository", () => {
|
||||||
|
it("should delete repository with optimistic removal", async () => {
|
||||||
|
const initialRepo: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
|
||||||
|
|
||||||
|
vi.mocked(repositoryService.deleteRepository).mockResolvedValue();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeleteRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync("repo-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repositoryService.deleteRepository).toHaveBeenCalledWith("repo-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should rollback on error", async () => {
|
||||||
|
const initialRepo: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial data
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), [initialRepo]);
|
||||||
|
|
||||||
|
const error = new Error("Delete failed");
|
||||||
|
vi.mocked(repositoryService.deleteRepository).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDeleteRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.mutateAsync("repo-1");
|
||||||
|
} catch {
|
||||||
|
// Expected error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should rollback to initial data
|
||||||
|
const data = queryClient.getQueryData(repositoryKeys.lists());
|
||||||
|
expect(data).toEqual([initialRepo]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useVerifyRepository", () => {
|
||||||
|
it("should verify repository and invalidate queries", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
is_accessible: true,
|
||||||
|
repository_id: "repo-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(repositoryService.verifyRepositoryAccess).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync("repo-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repositoryService.verifyRepositoryAccess).toHaveBeenCalledWith("repo-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle inaccessible repository", async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
is_accessible: false,
|
||||||
|
repository_id: "repo-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(repositoryService.verifyRepositoryAccess).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.mutateAsync("repo-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle verification errors", async () => {
|
||||||
|
const error = new Error("GitHub API error");
|
||||||
|
vi.mocked(repositoryService.verifyRepositoryAccess).mockRejectedValue(error);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useVerifyRepository(), { wrapper: createWrapper });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
try {
|
||||||
|
await result.current.mutateAsync("repo-1");
|
||||||
|
} catch {
|
||||||
|
// Expected error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isError).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
|
||||||
|
import { agentWorkOrdersService } from "../services/agentWorkOrdersService";
|
||||||
|
import type {
|
||||||
|
AgentWorkOrder,
|
||||||
|
AgentWorkOrderStatus,
|
||||||
|
CreateAgentWorkOrderRequest,
|
||||||
|
StepHistory,
|
||||||
|
WorkOrderLogsResponse,
|
||||||
|
} 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,
|
||||||
|
logs: (id: string) => [...agentWorkOrderKeys.detail(id), "logs"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch list of agent work orders
|
||||||
|
* Real-time updates provided by SSE (no polling needed)
|
||||||
|
*
|
||||||
|
* @param statusFilter - Optional status to filter work orders
|
||||||
|
* @returns Query result with work orders array
|
||||||
|
*/
|
||||||
|
export function useWorkOrders(statusFilter?: AgentWorkOrderStatus) {
|
||||||
|
return useQuery<AgentWorkOrder[], Error>({
|
||||||
|
queryKey: agentWorkOrderKeys.list(statusFilter),
|
||||||
|
queryFn: () => agentWorkOrdersService.listWorkOrders(statusFilter),
|
||||||
|
staleTime: STALE_TIMES.instant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch a single agent work order
|
||||||
|
* Real-time updates provided by SSE (no polling needed)
|
||||||
|
*
|
||||||
|
* @param id - Work order ID (undefined disables query)
|
||||||
|
* @returns Query result with work order data
|
||||||
|
*/
|
||||||
|
export function useWorkOrder(id: string | undefined) {
|
||||||
|
return useQuery<AgentWorkOrder, Error>({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch step execution history for a work order
|
||||||
|
* Real-time updates provided by SSE (no polling needed)
|
||||||
|
*
|
||||||
|
* @param workOrderId - Work order ID (undefined disables query)
|
||||||
|
* @returns Query result with step history
|
||||||
|
*/
|
||||||
|
export function useStepHistory(workOrderId: string | undefined) {
|
||||||
|
return useQuery<StepHistory, Error>({
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch historical logs for a work order
|
||||||
|
* Fetches buffered logs from backend (complementary to live SSE streaming)
|
||||||
|
*
|
||||||
|
* @param workOrderId - Work order ID (undefined disables query)
|
||||||
|
* @param options - Optional filters (limit, offset, level, step)
|
||||||
|
* @returns Query result with logs response
|
||||||
|
*/
|
||||||
|
export function useWorkOrderLogs(
|
||||||
|
workOrderId: string | undefined,
|
||||||
|
options?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
level?: "info" | "warning" | "error" | "debug";
|
||||||
|
step?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return useQuery<WorkOrderLogsResponse, Error>({
|
||||||
|
queryKey: workOrderId ? [...agentWorkOrderKeys.logs(workOrderId), options] : DISABLED_QUERY_KEY,
|
||||||
|
queryFn: () =>
|
||||||
|
workOrderId
|
||||||
|
? agentWorkOrdersService.getWorkOrderLogs(workOrderId, options)
|
||||||
|
: Promise.reject(new Error("No ID provided")),
|
||||||
|
enabled: !!workOrderId,
|
||||||
|
staleTime: STALE_TIMES.normal, // 30 seconds cache for historical logs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to start a pending work order (transition from pending to running)
|
||||||
|
* Implements optimistic update to immediately show running state in UI
|
||||||
|
* Triggers backend execution by updating status to "running"
|
||||||
|
*
|
||||||
|
* @returns Mutation object with mutate function
|
||||||
|
*/
|
||||||
|
export function useStartWorkOrder() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
AgentWorkOrder,
|
||||||
|
Error,
|
||||||
|
string,
|
||||||
|
{ previousWorkOrder?: AgentWorkOrder; previousList?: AgentWorkOrder[] }
|
||||||
|
>({
|
||||||
|
mutationFn: (id: string) => agentWorkOrdersService.startWorkOrder(id),
|
||||||
|
|
||||||
|
onMutate: async (id) => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: agentWorkOrderKeys.detail(id) });
|
||||||
|
|
||||||
|
// Snapshot the previous values
|
||||||
|
const previousWorkOrder = queryClient.getQueryData<AgentWorkOrder>(agentWorkOrderKeys.detail(id));
|
||||||
|
|
||||||
|
// Optimistically update the work order status to "running"
|
||||||
|
if (previousWorkOrder) {
|
||||||
|
const optimisticWorkOrder = {
|
||||||
|
...previousWorkOrder,
|
||||||
|
status: "running" as AgentWorkOrderStatus,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.detail(id), optimisticWorkOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { previousWorkOrder };
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error, id, context) => {
|
||||||
|
console.error("Failed to start work order:", error);
|
||||||
|
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousWorkOrder) {
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.detail(id), context.previousWorkOrder);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onSuccess: (data, id) => {
|
||||||
|
// Replace optimistic update with server response
|
||||||
|
queryClient.setQueryData(agentWorkOrderKeys.detail(id), data);
|
||||||
|
// Invalidate all list queries to refetch with server data
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentWorkOrderKeys.lists() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Repository Query Hooks
|
||||||
|
*
|
||||||
|
* TanStack Query hooks for repository management.
|
||||||
|
* Follows patterns from QUERY_PATTERNS.md with query key factories and optimistic updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { DISABLED_QUERY_KEY, STALE_TIMES } from "@/features/shared/config/queryPatterns";
|
||||||
|
import { useToast } from "@/features/shared/hooks/useToast";
|
||||||
|
import { createOptimisticEntity, replaceOptimisticEntity } from "@/features/shared/utils/optimistic";
|
||||||
|
import { repositoryService } from "../services/repositoryService";
|
||||||
|
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../types/repository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query key factory for repositories
|
||||||
|
* Follows the pattern: domain > scope > identifier
|
||||||
|
*/
|
||||||
|
export const repositoryKeys = {
|
||||||
|
all: ["repositories"] as const,
|
||||||
|
lists: () => [...repositoryKeys.all, "list"] as const,
|
||||||
|
detail: (id: string) => [...repositoryKeys.all, "detail", id] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured repositories
|
||||||
|
* @returns Query result with array of repositories
|
||||||
|
*/
|
||||||
|
export function useRepositories() {
|
||||||
|
return useQuery<ConfiguredRepository[]>({
|
||||||
|
queryKey: repositoryKeys.lists(),
|
||||||
|
queryFn: () => repositoryService.listRepositories(),
|
||||||
|
staleTime: STALE_TIMES.normal, // 30 seconds
|
||||||
|
refetchOnWindowFocus: true, // Refetch when tab gains focus (ETag makes this cheap)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single repository by ID
|
||||||
|
* @param id - Repository ID to fetch
|
||||||
|
* @returns Query result with repository detail
|
||||||
|
*/
|
||||||
|
export function useRepository(id: string | undefined) {
|
||||||
|
return useQuery<ConfiguredRepository>({
|
||||||
|
queryKey: id ? repositoryKeys.detail(id) : DISABLED_QUERY_KEY,
|
||||||
|
queryFn: () => {
|
||||||
|
if (!id) return Promise.reject("No repository ID provided");
|
||||||
|
// Note: Backend doesn't have a get-by-id endpoint yet, so we fetch from list
|
||||||
|
return repositoryService.listRepositories().then((repos) => {
|
||||||
|
const repo = repos.find((r) => r.id === id);
|
||||||
|
if (!repo) throw new Error("Repository not found");
|
||||||
|
return repo;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: STALE_TIMES.normal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new configured repository with optimistic updates
|
||||||
|
* @returns Mutation result for creating repository
|
||||||
|
*/
|
||||||
|
export function useCreateRepository() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ConfiguredRepository,
|
||||||
|
Error,
|
||||||
|
CreateRepositoryRequest,
|
||||||
|
{ previousRepositories?: ConfiguredRepository[]; optimisticId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: (request: CreateRepositoryRequest) => repositoryService.createRepository(request),
|
||||||
|
onMutate: async (newRepositoryData) => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||||
|
|
||||||
|
// Create optimistic repository with stable ID
|
||||||
|
const optimisticRepository = createOptimisticEntity<ConfiguredRepository>({
|
||||||
|
repository_url: newRepositoryData.repository_url,
|
||||||
|
display_name: null,
|
||||||
|
owner: null,
|
||||||
|
default_branch: null,
|
||||||
|
is_verified: false,
|
||||||
|
last_verified_at: null,
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optimistically add the new repository
|
||||||
|
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||||
|
if (!old) return [optimisticRepository];
|
||||||
|
// Add new repository at the beginning of the list
|
||||||
|
return [optimisticRepository, ...old];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousRepositories, optimisticId: optimisticRepository._localId };
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Failed to create repository:", error, { variables });
|
||||||
|
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousRepositories) {
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Failed to create repository: ${errorMessage}`, "error");
|
||||||
|
},
|
||||||
|
onSuccess: (response, _variables, context) => {
|
||||||
|
// Replace optimistic entity with real response
|
||||||
|
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||||
|
if (!old) return [response];
|
||||||
|
return replaceOptimisticEntity(old, context?.optimisticId, response);
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast("Repository created successfully", "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing repository with optimistic updates
|
||||||
|
* @returns Mutation result for updating repository
|
||||||
|
*/
|
||||||
|
export function useUpdateRepository() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ConfiguredRepository,
|
||||||
|
Error,
|
||||||
|
{ id: string; request: UpdateRepositoryRequest },
|
||||||
|
{ previousRepositories?: ConfiguredRepository[] }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ id, request }) => repositoryService.updateRepository(id, request),
|
||||||
|
onMutate: async ({ id, request }) => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||||
|
|
||||||
|
// Optimistically update the repository
|
||||||
|
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return old.map((repo) =>
|
||||||
|
repo.id === id
|
||||||
|
? {
|
||||||
|
...repo,
|
||||||
|
...request,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: repo,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousRepositories };
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Failed to update repository:", error, { variables });
|
||||||
|
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousRepositories) {
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Failed to update repository: ${errorMessage}`, "error");
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
// Replace with server response
|
||||||
|
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||||
|
if (!old) return [response];
|
||||||
|
return old.map((repo) => (repo.id === response.id ? response : repo));
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast("Repository updated successfully", "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a repository with optimistic removal
|
||||||
|
* @returns Mutation result for deleting repository
|
||||||
|
*/
|
||||||
|
export function useDeleteRepository() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
return useMutation<void, Error, string, { previousRepositories?: ConfiguredRepository[] }>({
|
||||||
|
mutationFn: (id: string) => repositoryService.deleteRepository(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||||
|
|
||||||
|
// Optimistically remove the repository
|
||||||
|
queryClient.setQueryData<ConfiguredRepository[]>(repositoryKeys.lists(), (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return old.filter((repo) => repo.id !== id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousRepositories };
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Failed to delete repository:", error, { variables });
|
||||||
|
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousRepositories) {
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Failed to delete repository: ${errorMessage}`, "error");
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast("Repository deleted successfully", "success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify repository access and update metadata
|
||||||
|
* @returns Mutation result for verifying repository
|
||||||
|
*/
|
||||||
|
export function useVerifyRepository() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
{ is_accessible: boolean; repository_id: string },
|
||||||
|
Error,
|
||||||
|
string,
|
||||||
|
{ previousRepositories?: ConfiguredRepository[] }
|
||||||
|
>({
|
||||||
|
mutationFn: (id: string) => repositoryService.verifyRepositoryAccess(id),
|
||||||
|
onMutate: async (_id) => {
|
||||||
|
// Cancel any outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: repositoryKeys.lists() });
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousRepositories = queryClient.getQueryData<ConfiguredRepository[]>(repositoryKeys.lists());
|
||||||
|
|
||||||
|
return { previousRepositories };
|
||||||
|
},
|
||||||
|
onError: (error, variables, context) => {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error("Failed to verify repository:", error, { variables });
|
||||||
|
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previousRepositories) {
|
||||||
|
queryClient.setQueryData(repositoryKeys.lists(), context.previousRepositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Failed to verify repository: ${errorMessage}`, "error");
|
||||||
|
},
|
||||||
|
onSuccess: (response) => {
|
||||||
|
// Invalidate queries to refetch updated metadata from server
|
||||||
|
queryClient.invalidateQueries({ queryKey: repositoryKeys.lists() });
|
||||||
|
|
||||||
|
if (response.is_accessible) {
|
||||||
|
showToast("Repository verified successfully", "success");
|
||||||
|
} else {
|
||||||
|
showToast("Repository is not accessible", "warning");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Repository Service Tests
|
||||||
|
*
|
||||||
|
* Unit tests for repository service methods.
|
||||||
|
* Mocks callAPIWithETag to test request structure and response handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../../types/repository";
|
||||||
|
import { repositoryService } from "../repositoryService";
|
||||||
|
|
||||||
|
// Mock the API client
|
||||||
|
vi.mock("@/features/shared/api/apiClient", () => ({
|
||||||
|
callAPIWithETag: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { callAPIWithETag } from "@/features/shared/api/apiClient";
|
||||||
|
|
||||||
|
describe("repositoryService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listRepositories", () => {
|
||||||
|
it("should call GET /api/agent-work-orders/repositories", async () => {
|
||||||
|
const mockRepositories: ConfiguredRepository[] = [
|
||||||
|
{
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockRepositories);
|
||||||
|
|
||||||
|
const result = await repositoryService.listRepositories();
|
||||||
|
|
||||||
|
expect(callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/repositories", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockRepositories);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty repository list", async () => {
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await repositoryService.listRepositories();
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate API errors", async () => {
|
||||||
|
const error = new Error("Network error");
|
||||||
|
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(repositoryService.listRepositories()).rejects.toThrow("Network error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRepository", () => {
|
||||||
|
it("should call POST /api/agent-work-orders/repositories with request body", async () => {
|
||||||
|
const request: CreateRepositoryRequest = {
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
verify: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await repositoryService.createRepository(request);
|
||||||
|
|
||||||
|
expect(callAPIWithETag).toHaveBeenCalledWith("/api/agent-work-orders/repositories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle creation without verification", async () => {
|
||||||
|
const request: CreateRepositoryRequest = {
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
verify: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: null,
|
||||||
|
owner: null,
|
||||||
|
default_branch: null,
|
||||||
|
is_verified: false,
|
||||||
|
last_verified_at: null,
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-01T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await repositoryService.createRepository(request);
|
||||||
|
|
||||||
|
expect(result.is_verified).toBe(false);
|
||||||
|
expect(result.display_name).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate validation errors", async () => {
|
||||||
|
const error = new Error("Invalid repository URL");
|
||||||
|
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repositoryService.createRepository({
|
||||||
|
repository_url: "invalid-url",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Invalid repository URL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateRepository", () => {
|
||||||
|
it("should call PATCH /api/agent-work-orders/repositories/:id with update request", async () => {
|
||||||
|
const id = "repo-1";
|
||||||
|
const request: UpdateRepositoryRequest = {
|
||||||
|
default_sandbox_type: "git_branch",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_branch",
|
||||||
|
default_commands: ["create-branch", "planning", "execute"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await repositoryService.updateRepository(id, request);
|
||||||
|
|
||||||
|
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle partial updates", async () => {
|
||||||
|
const id = "repo-1";
|
||||||
|
const request: UpdateRepositoryRequest = {
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: ConfiguredRepository = {
|
||||||
|
id: "repo-1",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: "2024-01-01T00:00:00Z",
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning", "execute", "commit", "create-pr"],
|
||||||
|
created_at: "2024-01-01T00:00:00Z",
|
||||||
|
updated_at: "2024-01-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await repositoryService.updateRepository(id, request);
|
||||||
|
|
||||||
|
expect(result.default_sandbox_type).toBe("git_worktree");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle not found errors", async () => {
|
||||||
|
const error = new Error("Repository not found");
|
||||||
|
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
repositoryService.updateRepository("non-existent", {
|
||||||
|
default_sandbox_type: "git_branch",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Repository not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteRepository", () => {
|
||||||
|
it("should call DELETE /api/agent-work-orders/repositories/:id", async () => {
|
||||||
|
const id = "repo-1";
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await repositoryService.deleteRepository(id);
|
||||||
|
|
||||||
|
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle not found errors", async () => {
|
||||||
|
const error = new Error("Repository not found");
|
||||||
|
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(repositoryService.deleteRepository("non-existent")).rejects.toThrow("Repository not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyRepositoryAccess", () => {
|
||||||
|
it("should call POST /api/agent-work-orders/repositories/:id/verify", async () => {
|
||||||
|
const id = "repo-1";
|
||||||
|
const mockResponse = {
|
||||||
|
is_accessible: true,
|
||||||
|
repository_id: "repo-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await repositoryService.verifyRepositoryAccess(id);
|
||||||
|
|
||||||
|
expect(callAPIWithETag).toHaveBeenCalledWith(`/api/agent-work-orders/repositories/${id}/verify`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle inaccessible repositories", async () => {
|
||||||
|
const id = "repo-1";
|
||||||
|
const mockResponse = {
|
||||||
|
is_accessible: false,
|
||||||
|
repository_id: "repo-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(callAPIWithETag).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await repositoryService.verifyRepositoryAccess(id);
|
||||||
|
|
||||||
|
expect(result.is_accessible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle verification errors", async () => {
|
||||||
|
const error = new Error("GitHub API error");
|
||||||
|
vi.mocked(callAPIWithETag).mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(repositoryService.verifyRepositoryAccess("repo-1")).rejects.toThrow("GitHub API error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
WorkflowStep,
|
||||||
|
WorkOrderLogsResponse,
|
||||||
|
} 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`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a pending work order (transition from pending to running)
|
||||||
|
* This triggers backend execution by updating the status to "running"
|
||||||
|
*
|
||||||
|
* @param id - The work order ID to start
|
||||||
|
* @returns Promise resolving to the updated work order
|
||||||
|
* @throws Error if work order not found, already running, or request fails
|
||||||
|
*/
|
||||||
|
async startWorkOrder(id: string): Promise<AgentWorkOrder> {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
// Note: Backend automatically starts execution when status transitions to "running"
|
||||||
|
// This is a conceptual API - actual implementation may vary based on backend
|
||||||
|
return await callAPIWithETag<AgentWorkOrder>(`${baseUrl}/${id}/start`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical logs for a work order
|
||||||
|
* Fetches buffered logs from backend (not live streaming)
|
||||||
|
*
|
||||||
|
* @param id - The work order ID
|
||||||
|
* @param options - Optional filters (limit, offset, level, step)
|
||||||
|
* @returns Promise resolving to logs response
|
||||||
|
* @throws Error if work order not found or request fails
|
||||||
|
*/
|
||||||
|
async getWorkOrderLogs(
|
||||||
|
id: string,
|
||||||
|
options?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
level?: "info" | "warning" | "error" | "debug";
|
||||||
|
step?: WorkflowStep;
|
||||||
|
},
|
||||||
|
): Promise<WorkOrderLogsResponse> {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options?.limit) params.append("limit", options.limit.toString());
|
||||||
|
if (options?.offset) params.append("offset", options.offset.toString());
|
||||||
|
if (options?.level) params.append("level", options.level);
|
||||||
|
if (options?.step) params.append("step", options.step);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const url = queryString ? `${baseUrl}/${id}/logs?${queryString}` : `${baseUrl}/${id}/logs`;
|
||||||
|
|
||||||
|
return await callAPIWithETag<WorkOrderLogsResponse>(url);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Repository Service
|
||||||
|
*
|
||||||
|
* Service layer for repository CRUD operations.
|
||||||
|
* All methods use callAPIWithETag for automatic ETag caching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callAPIWithETag } from "@/features/shared/api/apiClient";
|
||||||
|
import type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "../types/repository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all configured repositories
|
||||||
|
* @returns Array of configured repositories ordered by created_at DESC
|
||||||
|
*/
|
||||||
|
export async function listRepositories(): Promise<ConfiguredRepository[]> {
|
||||||
|
return callAPIWithETag<ConfiguredRepository[]>("/api/agent-work-orders/repositories", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new configured repository
|
||||||
|
* @param request - Repository creation request with URL and optional verification
|
||||||
|
* @returns The created repository with metadata
|
||||||
|
*/
|
||||||
|
export async function createRepository(request: CreateRepositoryRequest): Promise<ConfiguredRepository> {
|
||||||
|
return callAPIWithETag<ConfiguredRepository>("/api/agent-work-orders/repositories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing configured repository
|
||||||
|
* @param id - Repository ID
|
||||||
|
* @param request - Partial update request with fields to modify
|
||||||
|
* @returns The updated repository
|
||||||
|
*/
|
||||||
|
export async function updateRepository(id: string, request: UpdateRepositoryRequest): Promise<ConfiguredRepository> {
|
||||||
|
return callAPIWithETag<ConfiguredRepository>(`/api/agent-work-orders/repositories/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a configured repository
|
||||||
|
* @param id - Repository ID to delete
|
||||||
|
*/
|
||||||
|
export async function deleteRepository(id: string): Promise<void> {
|
||||||
|
await callAPIWithETag<void>(`/api/agent-work-orders/repositories/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify repository access and update metadata
|
||||||
|
* Re-verifies GitHub repository access and updates display_name, owner, default_branch
|
||||||
|
* @param id - Repository ID to verify
|
||||||
|
* @returns Verification result with is_accessible boolean
|
||||||
|
*/
|
||||||
|
export async function verifyRepositoryAccess(id: string): Promise<{ is_accessible: boolean; repository_id: string }> {
|
||||||
|
return callAPIWithETag<{ is_accessible: boolean; repository_id: string }>(
|
||||||
|
`/api/agent-work-orders/repositories/${id}/verify`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export all methods as named exports and default object
|
||||||
|
export const repositoryService = {
|
||||||
|
listRepositories,
|
||||||
|
createRepository,
|
||||||
|
updateRepository,
|
||||||
|
deleteRepository,
|
||||||
|
verifyRepositoryAccess,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default repositoryService;
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for Agent Work Orders Zustand Store
|
||||||
|
*
|
||||||
|
* Tests all slices: UI Preferences, Modals, Filters, and SSE
|
||||||
|
* Verifies state management (persist middleware handles localStorage automatically)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { LogEntry } from "../../types";
|
||||||
|
import type { ConfiguredRepository } from "../../types/repository";
|
||||||
|
import { useAgentWorkOrdersStore } from "../agentWorkOrdersStore";
|
||||||
|
|
||||||
|
describe("AgentWorkOrdersStore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store to initial state
|
||||||
|
useAgentWorkOrdersStore.setState({
|
||||||
|
// UI Preferences
|
||||||
|
layoutMode: "sidebar",
|
||||||
|
sidebarExpanded: true,
|
||||||
|
// Modals
|
||||||
|
showAddRepoModal: false,
|
||||||
|
showEditRepoModal: false,
|
||||||
|
showCreateWorkOrderModal: false,
|
||||||
|
editingRepository: null,
|
||||||
|
preselectedRepositoryId: undefined,
|
||||||
|
// Filters
|
||||||
|
searchQuery: "",
|
||||||
|
selectedRepositoryId: undefined,
|
||||||
|
// SSE
|
||||||
|
logConnections: new Map(),
|
||||||
|
connectionStates: {},
|
||||||
|
liveLogs: {},
|
||||||
|
liveProgress: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear localStorage
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Disconnect all SSE connections
|
||||||
|
const { disconnectAll } = useAgentWorkOrdersStore.getState();
|
||||||
|
disconnectAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UI Preferences Slice", () => {
|
||||||
|
it("should set layout mode", () => {
|
||||||
|
const { setLayoutMode } = useAgentWorkOrdersStore.getState();
|
||||||
|
setLayoutMode("horizontal");
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().layoutMode).toBe("horizontal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should toggle sidebar expansion", () => {
|
||||||
|
const { toggleSidebar } = useAgentWorkOrdersStore.getState();
|
||||||
|
toggleSidebar();
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().sidebarExpanded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set sidebar expanded directly", () => {
|
||||||
|
const { setSidebarExpanded } = useAgentWorkOrdersStore.getState();
|
||||||
|
setSidebarExpanded(false);
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().sidebarExpanded).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reset UI preferences to defaults", () => {
|
||||||
|
const { setLayoutMode, setSidebarExpanded, resetUIPreferences } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Change values
|
||||||
|
setLayoutMode("horizontal");
|
||||||
|
setSidebarExpanded(false);
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
resetUIPreferences();
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.layoutMode).toBe("sidebar");
|
||||||
|
expect(state.sidebarExpanded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Modals Slice", () => {
|
||||||
|
it("should open and close add repository modal", () => {
|
||||||
|
const { openAddRepoModal, closeAddRepoModal } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
openAddRepoModal();
|
||||||
|
expect(useAgentWorkOrdersStore.getState().showAddRepoModal).toBe(true);
|
||||||
|
|
||||||
|
closeAddRepoModal();
|
||||||
|
expect(useAgentWorkOrdersStore.getState().showAddRepoModal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open edit modal with repository context", () => {
|
||||||
|
const mockRepo: ConfiguredRepository = {
|
||||||
|
id: "repo-123",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: new Date().toISOString(),
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: ["create-branch", "planning"],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { openEditRepoModal, closeEditRepoModal } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
openEditRepoModal(mockRepo);
|
||||||
|
expect(useAgentWorkOrdersStore.getState().showEditRepoModal).toBe(true);
|
||||||
|
expect(useAgentWorkOrdersStore.getState().editingRepository).toBe(mockRepo);
|
||||||
|
|
||||||
|
closeEditRepoModal();
|
||||||
|
expect(useAgentWorkOrdersStore.getState().showEditRepoModal).toBe(false);
|
||||||
|
expect(useAgentWorkOrdersStore.getState().editingRepository).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open create work order modal with preselected repository", () => {
|
||||||
|
const { openCreateWorkOrderModal, closeCreateWorkOrderModal } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
openCreateWorkOrderModal("repo-456");
|
||||||
|
expect(useAgentWorkOrdersStore.getState().showCreateWorkOrderModal).toBe(true);
|
||||||
|
expect(useAgentWorkOrdersStore.getState().preselectedRepositoryId).toBe("repo-456");
|
||||||
|
|
||||||
|
closeCreateWorkOrderModal();
|
||||||
|
expect(useAgentWorkOrdersStore.getState().showCreateWorkOrderModal).toBe(false);
|
||||||
|
expect(useAgentWorkOrdersStore.getState().preselectedRepositoryId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close all modals and clear context", () => {
|
||||||
|
const mockRepo: ConfiguredRepository = {
|
||||||
|
id: "repo-123",
|
||||||
|
repository_url: "https://github.com/test/repo",
|
||||||
|
display_name: "test/repo",
|
||||||
|
owner: "test",
|
||||||
|
default_branch: "main",
|
||||||
|
is_verified: true,
|
||||||
|
last_verified_at: new Date().toISOString(),
|
||||||
|
default_sandbox_type: "git_worktree",
|
||||||
|
default_commands: [],
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { openAddRepoModal, openEditRepoModal, openCreateWorkOrderModal, closeAllModals } =
|
||||||
|
useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Open all modals
|
||||||
|
openAddRepoModal();
|
||||||
|
openEditRepoModal(mockRepo);
|
||||||
|
openCreateWorkOrderModal("repo-789");
|
||||||
|
|
||||||
|
// Close all
|
||||||
|
closeAllModals();
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.showAddRepoModal).toBe(false);
|
||||||
|
expect(state.showEditRepoModal).toBe(false);
|
||||||
|
expect(state.showCreateWorkOrderModal).toBe(false);
|
||||||
|
expect(state.editingRepository).toBe(null);
|
||||||
|
expect(state.preselectedRepositoryId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Filters Slice", () => {
|
||||||
|
it("should set search query", () => {
|
||||||
|
const { setSearchQuery } = useAgentWorkOrdersStore.getState();
|
||||||
|
setSearchQuery("my-repo");
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().searchQuery).toBe("my-repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should select repository with URL sync callback", () => {
|
||||||
|
const mockSyncUrl = vi.fn();
|
||||||
|
const { selectRepository } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
selectRepository("repo-123", mockSyncUrl);
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().selectedRepositoryId).toBe("repo-123");
|
||||||
|
expect(mockSyncUrl).toHaveBeenCalledWith("repo-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear all filters", () => {
|
||||||
|
const { setSearchQuery, selectRepository, clearFilters } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Set some filters
|
||||||
|
setSearchQuery("test");
|
||||||
|
selectRepository("repo-456");
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
clearFilters();
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.searchQuery).toBe("");
|
||||||
|
expect(state.selectedRepositoryId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SSE Slice", () => {
|
||||||
|
it("should parse step_started log and calculate correct progress", () => {
|
||||||
|
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-123";
|
||||||
|
|
||||||
|
const stepStartedLog: LogEntry = {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: "step_started",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
step: "planning",
|
||||||
|
step_number: 2,
|
||||||
|
total_steps: 5,
|
||||||
|
elapsed_seconds: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogEvent(workOrderId, stepStartedLog);
|
||||||
|
|
||||||
|
const progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
|
||||||
|
expect(progress?.currentStep).toBe("planning");
|
||||||
|
expect(progress?.stepNumber).toBe(2);
|
||||||
|
expect(progress?.totalSteps).toBe(5);
|
||||||
|
// Progress based on completed steps: (2-1)/5 = 20%
|
||||||
|
expect(progress?.progressPct).toBe(20);
|
||||||
|
expect(progress?.elapsedSeconds).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse workflow_completed log and update status", () => {
|
||||||
|
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-456";
|
||||||
|
|
||||||
|
const completedLog: LogEntry = {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: "workflow_completed",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogEvent(workOrderId, completedLog);
|
||||||
|
|
||||||
|
const progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
|
||||||
|
expect(progress?.status).toBe("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse workflow_failed log and update status", () => {
|
||||||
|
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-789";
|
||||||
|
|
||||||
|
const failedLog: LogEntry = {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "error",
|
||||||
|
event: "workflow_failed",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: "Something went wrong",
|
||||||
|
};
|
||||||
|
|
||||||
|
handleLogEvent(workOrderId, failedLog);
|
||||||
|
|
||||||
|
const progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
|
||||||
|
expect(progress?.status).toBe("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain max 500 log entries", () => {
|
||||||
|
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-overflow";
|
||||||
|
|
||||||
|
// Add 600 logs
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const log: LogEntry = {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: `event_${i}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
handleLogEvent(workOrderId, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = useAgentWorkOrdersStore.getState().liveLogs[workOrderId];
|
||||||
|
expect(logs.length).toBe(500);
|
||||||
|
// Should keep most recent logs
|
||||||
|
expect(logs[logs.length - 1].event).toBe("event_599");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear logs for specific work order", () => {
|
||||||
|
const { handleLogEvent, clearLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-clear";
|
||||||
|
|
||||||
|
// Add some logs
|
||||||
|
const log: LogEntry = {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: "test_event",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
handleLogEvent(workOrderId, log);
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().liveLogs[workOrderId]?.length).toBe(1);
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
clearLogs(workOrderId);
|
||||||
|
|
||||||
|
expect(useAgentWorkOrdersStore.getState().liveLogs[workOrderId]?.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accumulate progress metadata correctly", () => {
|
||||||
|
const { handleLogEvent } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-progress";
|
||||||
|
|
||||||
|
// First log with step info - step 1 starting
|
||||||
|
handleLogEvent(workOrderId, {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: "step_started",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
step: "planning",
|
||||||
|
step_number: 1,
|
||||||
|
total_steps: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
let progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
|
||||||
|
expect(progress?.currentStep).toBe("planning");
|
||||||
|
expect(progress?.stepNumber).toBe(1);
|
||||||
|
expect(progress?.totalSteps).toBe(3);
|
||||||
|
// Step 1 of 3 starting: (1-1)/3 = 0%
|
||||||
|
expect(progress?.progressPct).toBe(0);
|
||||||
|
|
||||||
|
// Step completed
|
||||||
|
handleLogEvent(workOrderId, {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: "step_completed",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
elapsed_seconds: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
progress = useAgentWorkOrdersStore.getState().liveProgress[workOrderId];
|
||||||
|
// Step 1 complete: 1/3 = 33%
|
||||||
|
expect(progress?.progressPct).toBe(33);
|
||||||
|
expect(progress?.elapsedSeconds).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("State Management", () => {
|
||||||
|
it("should manage all state types correctly", () => {
|
||||||
|
const { setLayoutMode, setSearchQuery, openAddRepoModal, handleLogEvent } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Set UI preferences
|
||||||
|
setLayoutMode("horizontal");
|
||||||
|
|
||||||
|
// Set filters
|
||||||
|
setSearchQuery("test-query");
|
||||||
|
|
||||||
|
// Set modals
|
||||||
|
openAddRepoModal();
|
||||||
|
|
||||||
|
// Add SSE data
|
||||||
|
handleLogEvent("wo-test", {
|
||||||
|
work_order_id: "wo-test",
|
||||||
|
level: "info",
|
||||||
|
event: "test",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Verify all state is correct (persist middleware handles localStorage)
|
||||||
|
expect(state.layoutMode).toBe("horizontal");
|
||||||
|
expect(state.searchQuery).toBe("test-query");
|
||||||
|
expect(state.showAddRepoModal).toBe(true);
|
||||||
|
expect(state.liveLogs["wo-test"]?.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Selective Subscriptions", () => {
|
||||||
|
it("should only trigger updates when subscribed field changes", () => {
|
||||||
|
const layoutModeCallback = vi.fn();
|
||||||
|
const searchQueryCallback = vi.fn();
|
||||||
|
|
||||||
|
// Subscribe to specific fields
|
||||||
|
const unsubLayoutMode = useAgentWorkOrdersStore.subscribe((state) => state.layoutMode, layoutModeCallback);
|
||||||
|
|
||||||
|
const unsubSearchQuery = useAgentWorkOrdersStore.subscribe((state) => state.searchQuery, searchQueryCallback);
|
||||||
|
|
||||||
|
// Change layoutMode - should trigger layoutMode callback only
|
||||||
|
const { setLayoutMode } = useAgentWorkOrdersStore.getState();
|
||||||
|
setLayoutMode("horizontal");
|
||||||
|
|
||||||
|
expect(layoutModeCallback).toHaveBeenCalledWith("horizontal", "sidebar");
|
||||||
|
expect(searchQueryCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Clear mock calls
|
||||||
|
layoutModeCallback.mockClear();
|
||||||
|
searchQueryCallback.mockClear();
|
||||||
|
|
||||||
|
// Change searchQuery - should trigger searchQuery callback only
|
||||||
|
const { setSearchQuery } = useAgentWorkOrdersStore.getState();
|
||||||
|
setSearchQuery("new-query");
|
||||||
|
|
||||||
|
expect(searchQueryCallback).toHaveBeenCalledWith("new-query", "");
|
||||||
|
expect(layoutModeCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
unsubLayoutMode();
|
||||||
|
unsubSearchQuery();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for SSE Connection Lifecycle
|
||||||
|
*
|
||||||
|
* Tests EventSource connection management, event handling, and cleanup
|
||||||
|
* Mocks EventSource API to simulate connection states
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { LogEntry } from "../../types";
|
||||||
|
import { useAgentWorkOrdersStore } from "../agentWorkOrdersStore";
|
||||||
|
|
||||||
|
// Mock EventSource
|
||||||
|
class MockEventSource {
|
||||||
|
url: string;
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
readyState: number = 0;
|
||||||
|
private listeners: Map<string, ((event: Event) => void)[]> = new Map();
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = 0; // CONNECTING
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, listener: (event: Event) => void): void {
|
||||||
|
if (!this.listeners.has(type)) {
|
||||||
|
this.listeners.set(type, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(type)?.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: string, listener: (event: Event) => void): void {
|
||||||
|
const listeners = this.listeners.get(type);
|
||||||
|
if (listeners) {
|
||||||
|
const index = listeners.indexOf(listener);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.readyState = 2; // CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for testing
|
||||||
|
simulateOpen(): void {
|
||||||
|
this.readyState = 1; // OPEN
|
||||||
|
if (this.onopen) {
|
||||||
|
this.onopen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateMessage(data: string): void {
|
||||||
|
if (this.onmessage) {
|
||||||
|
const event = new MessageEvent("message", { data });
|
||||||
|
this.onmessage(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateError(): void {
|
||||||
|
if (this.onerror) {
|
||||||
|
this.onerror();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SSE Integration Tests", () => {
|
||||||
|
let mockEventSourceInstances: MockEventSource[] = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store
|
||||||
|
useAgentWorkOrdersStore.setState({
|
||||||
|
layoutMode: "sidebar",
|
||||||
|
sidebarExpanded: true,
|
||||||
|
showAddRepoModal: false,
|
||||||
|
showEditRepoModal: false,
|
||||||
|
showCreateWorkOrderModal: false,
|
||||||
|
editingRepository: null,
|
||||||
|
preselectedRepositoryId: undefined,
|
||||||
|
searchQuery: "",
|
||||||
|
selectedRepositoryId: undefined,
|
||||||
|
logConnections: new Map(),
|
||||||
|
connectionStates: {},
|
||||||
|
liveLogs: {},
|
||||||
|
liveProgress: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear mock instances
|
||||||
|
mockEventSourceInstances = [];
|
||||||
|
|
||||||
|
// Mock EventSource globally
|
||||||
|
global.EventSource = vi.fn((url: string) => {
|
||||||
|
const instance = new MockEventSource(url);
|
||||||
|
mockEventSourceInstances.push(instance);
|
||||||
|
return instance as unknown as EventSource;
|
||||||
|
}) as unknown as typeof EventSource;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Disconnect all connections
|
||||||
|
const { disconnectAll } = useAgentWorkOrdersStore.getState();
|
||||||
|
disconnectAll();
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("connectToLogs", () => {
|
||||||
|
it("should create EventSource connection with correct URL", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-123";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
|
||||||
|
expect(global.EventSource).toHaveBeenCalledWith(`/api/agent-work-orders/${workOrderId}/logs/stream`);
|
||||||
|
expect(mockEventSourceInstances.length).toBe(1);
|
||||||
|
expect(mockEventSourceInstances[0].url).toBe(`/api/agent-work-orders/${workOrderId}/logs/stream`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set connectionState to connecting initially", () => {
|
||||||
|
const { connectToLogs, connectionStates } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-456";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.connectionStates[workOrderId]).toBe("connecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent duplicate connections", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-duplicate";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
connectToLogs(workOrderId); // Second call
|
||||||
|
|
||||||
|
// Should only create one connection
|
||||||
|
expect(mockEventSourceInstances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should store connection in logConnections Map", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-789";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.logConnections.has(workOrderId)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onopen event", () => {
|
||||||
|
it("should set connectionState to connected", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-open";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
|
||||||
|
// Simulate open event
|
||||||
|
mockEventSourceInstances[0].simulateOpen();
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.connectionStates[workOrderId]).toBe("connected");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onmessage event", () => {
|
||||||
|
it("should parse JSON and call handleLogEvent", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-message";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
mockEventSourceInstances[0].simulateOpen();
|
||||||
|
|
||||||
|
const logEntry: LogEntry = {
|
||||||
|
work_order_id: workOrderId,
|
||||||
|
level: "info",
|
||||||
|
event: "step_started",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
step: "planning",
|
||||||
|
step_number: 1,
|
||||||
|
total_steps: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate message
|
||||||
|
mockEventSourceInstances[0].simulateMessage(JSON.stringify(logEntry));
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.liveLogs[workOrderId]?.length).toBe(1);
|
||||||
|
expect(state.liveLogs[workOrderId]?.[0].event).toBe("step_started");
|
||||||
|
expect(state.liveProgress[workOrderId]?.currentStep).toBe("planning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle malformed JSON gracefully", () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-malformed";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
mockEventSourceInstances[0].simulateOpen();
|
||||||
|
|
||||||
|
// Simulate malformed JSON
|
||||||
|
mockEventSourceInstances[0].simulateMessage("invalid json {");
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to parse"), expect.anything());
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onerror event", () => {
|
||||||
|
it("should set connectionState to error", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-error";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
mockEventSourceInstances[0].simulateOpen();
|
||||||
|
|
||||||
|
// Simulate error
|
||||||
|
mockEventSourceInstances[0].simulateError();
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.connectionStates[workOrderId]).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trigger auto-reconnect after error", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-reconnect";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
const firstConnection = mockEventSourceInstances[0];
|
||||||
|
firstConnection.simulateOpen();
|
||||||
|
|
||||||
|
// Simulate error
|
||||||
|
firstConnection.simulateError();
|
||||||
|
|
||||||
|
expect(firstConnection.close).toBeDefined();
|
||||||
|
|
||||||
|
// Fast-forward 5 seconds (auto-reconnect delay)
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
// Should create new connection
|
||||||
|
expect(mockEventSourceInstances.length).toBe(2);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disconnectFromLogs", () => {
|
||||||
|
it("should close connection and remove from Map", () => {
|
||||||
|
const { connectToLogs, disconnectFromLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-disconnect";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
const connection = mockEventSourceInstances[0];
|
||||||
|
|
||||||
|
disconnectFromLogs(workOrderId);
|
||||||
|
|
||||||
|
expect(connection.readyState).toBe(2); // CLOSED
|
||||||
|
expect(useAgentWorkOrdersStore.getState().logConnections.has(workOrderId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set connectionState to disconnected", () => {
|
||||||
|
const { connectToLogs, disconnectFromLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-disc-state";
|
||||||
|
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
disconnectFromLogs(workOrderId);
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.connectionStates[workOrderId]).toBe("disconnected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle disconnect when no connection exists", () => {
|
||||||
|
const { disconnectFromLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => disconnectFromLogs("non-existent-id")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disconnectAll", () => {
|
||||||
|
it("should close all connections and clear state", () => {
|
||||||
|
const { connectToLogs, disconnectAll } = useAgentWorkOrdersStore.getState();
|
||||||
|
|
||||||
|
// Create multiple connections
|
||||||
|
connectToLogs("wo-1");
|
||||||
|
connectToLogs("wo-2");
|
||||||
|
connectToLogs("wo-3");
|
||||||
|
|
||||||
|
expect(mockEventSourceInstances.length).toBe(3);
|
||||||
|
|
||||||
|
// Disconnect all
|
||||||
|
disconnectAll();
|
||||||
|
|
||||||
|
const state = useAgentWorkOrdersStore.getState();
|
||||||
|
expect(state.logConnections.size).toBe(0);
|
||||||
|
expect(Object.keys(state.connectionStates).length).toBe(0);
|
||||||
|
expect(Object.keys(state.liveLogs).length).toBe(0);
|
||||||
|
expect(Object.keys(state.liveProgress).length).toBe(0);
|
||||||
|
|
||||||
|
// All connections should be closed
|
||||||
|
mockEventSourceInstances.forEach((instance) => {
|
||||||
|
expect(instance.readyState).toBe(2); // CLOSED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple Subscribers Pattern", () => {
|
||||||
|
it("should share same connection across multiple subscribers", () => {
|
||||||
|
const { connectToLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-shared";
|
||||||
|
|
||||||
|
// First subscriber
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
|
||||||
|
// Second subscriber (same work order ID)
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
|
||||||
|
// Should only create one connection
|
||||||
|
expect(mockEventSourceInstances.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should keep connection open until all subscribers disconnect", () => {
|
||||||
|
const { connectToLogs, disconnectFromLogs } = useAgentWorkOrdersStore.getState();
|
||||||
|
const workOrderId = "wo-multi-sub";
|
||||||
|
|
||||||
|
// Simulate 2 components subscribing
|
||||||
|
connectToLogs(workOrderId);
|
||||||
|
const connection = mockEventSourceInstances[0];
|
||||||
|
|
||||||
|
// First component disconnects
|
||||||
|
disconnectFromLogs(workOrderId);
|
||||||
|
|
||||||
|
// Connection should be closed (our current implementation closes immediately)
|
||||||
|
// In a full reference counting implementation, connection would stay open
|
||||||
|
// This test documents current behavior
|
||||||
|
expect(connection.readyState).toBe(2); // CLOSED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { devtools, persist, subscribeWithSelector } from "zustand/middleware";
|
||||||
|
import { createFiltersSlice, type FiltersSlice } from "./slices/filtersSlice";
|
||||||
|
import { createModalsSlice, type ModalsSlice } from "./slices/modalsSlice";
|
||||||
|
import { createSSESlice, type SSESlice } from "./slices/sseSlice";
|
||||||
|
import { createUIPreferencesSlice, type UIPreferencesSlice } from "./slices/uiPreferencesSlice";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined Agent Work Orders store type
|
||||||
|
* Combines all slices into a single store interface
|
||||||
|
*/
|
||||||
|
export type AgentWorkOrdersStore = UIPreferencesSlice & ModalsSlice & FiltersSlice & SSESlice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent Work Orders global state store
|
||||||
|
*
|
||||||
|
* Manages:
|
||||||
|
* - UI preferences (layout mode, sidebar state) - PERSISTED
|
||||||
|
* - Modal state (which modal is open, editing context) - NOT persisted
|
||||||
|
* - Filter state (search query, selected repository) - PERSISTED
|
||||||
|
* - SSE connections (live updates, connection management) - NOT persisted
|
||||||
|
*
|
||||||
|
* Does NOT manage:
|
||||||
|
* - Server data (TanStack Query handles this)
|
||||||
|
* - Ephemeral UI state (local useState for row expansion, etc.)
|
||||||
|
*
|
||||||
|
* Zustand v5 Selector Patterns:
|
||||||
|
* ```typescript
|
||||||
|
* import { useShallow } from 'zustand/shallow';
|
||||||
|
*
|
||||||
|
* // ✅ Single primitive - stable reference
|
||||||
|
* const layoutMode = useAgentWorkOrdersStore((s) => s.layoutMode);
|
||||||
|
*
|
||||||
|
* // ✅ Single action - functions are stable
|
||||||
|
* const setLayoutMode = useAgentWorkOrdersStore((s) => s.setLayoutMode);
|
||||||
|
*
|
||||||
|
* // ✅ Multiple values - use useShallow to prevent infinite loops
|
||||||
|
* const { layoutMode, sidebarExpanded } = useAgentWorkOrdersStore(
|
||||||
|
* useShallow((s) => ({
|
||||||
|
* layoutMode: s.layoutMode,
|
||||||
|
* sidebarExpanded: s.sidebarExpanded
|
||||||
|
* }))
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useAgentWorkOrdersStore = create<AgentWorkOrdersStore>()(
|
||||||
|
devtools(
|
||||||
|
subscribeWithSelector(
|
||||||
|
persist(
|
||||||
|
(...a) => ({
|
||||||
|
...createUIPreferencesSlice(...a),
|
||||||
|
...createModalsSlice(...a),
|
||||||
|
...createFiltersSlice(...a),
|
||||||
|
...createSSESlice(...a),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "agent-work-orders-ui",
|
||||||
|
version: 2,
|
||||||
|
partialize: (state) => ({
|
||||||
|
// Persist UI preferences and search query
|
||||||
|
layoutMode: state.layoutMode,
|
||||||
|
sidebarExpanded: state.sidebarExpanded,
|
||||||
|
searchQuery: state.searchQuery,
|
||||||
|
// Persist SSE data to survive HMR
|
||||||
|
liveLogs: state.liveLogs,
|
||||||
|
liveProgress: state.liveProgress,
|
||||||
|
// Do NOT persist:
|
||||||
|
// - selectedRepositoryId (URL params are source of truth)
|
||||||
|
// - Modal state (ephemeral)
|
||||||
|
// - SSE connections (must be re-established, but data is preserved)
|
||||||
|
// - connectionStates (transient)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ name: "AgentWorkOrders" },
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
|
||||||
|
export type FiltersSlice = {
|
||||||
|
// State
|
||||||
|
searchQuery: string;
|
||||||
|
selectedRepositoryId: string | undefined;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
selectRepository: (id: string | undefined, syncUrl?: (id: string | undefined) => void) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters Slice
|
||||||
|
*
|
||||||
|
* Manages filter and selection state for repositories and work orders.
|
||||||
|
* Includes search query and selected repository ID.
|
||||||
|
*
|
||||||
|
* Persisted: YES (search/selection survives reload)
|
||||||
|
*
|
||||||
|
* URL Sync: selectedRepositoryId should also update URL query params.
|
||||||
|
* Use the syncUrl callback to keep URL in sync.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Set search query
|
||||||
|
* const setSearchQuery = useAgentWorkOrdersStore((s) => s.setSearchQuery);
|
||||||
|
* setSearchQuery("my-repo");
|
||||||
|
*
|
||||||
|
* // Select repository with URL sync
|
||||||
|
* const selectRepository = useAgentWorkOrdersStore((s) => s.selectRepository);
|
||||||
|
* selectRepository("repo-id-123", (id) => {
|
||||||
|
* setSearchParams(id ? { repo: id } : {});
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const createFiltersSlice: StateCreator<FiltersSlice, [], [], FiltersSlice> = (set) => ({
|
||||||
|
// Initial state
|
||||||
|
searchQuery: "",
|
||||||
|
selectedRepositoryId: undefined,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
|
||||||
|
selectRepository: (id, syncUrl) => {
|
||||||
|
set({ selectedRepositoryId: id });
|
||||||
|
// Callback to sync with URL search params
|
||||||
|
syncUrl?.(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters: () =>
|
||||||
|
set({
|
||||||
|
searchQuery: "",
|
||||||
|
selectedRepositoryId: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { ConfiguredRepository } from "../../types/repository";
|
||||||
|
|
||||||
|
export type ModalsSlice = {
|
||||||
|
// Modal visibility
|
||||||
|
showAddRepoModal: boolean;
|
||||||
|
showEditRepoModal: boolean;
|
||||||
|
showCreateWorkOrderModal: boolean;
|
||||||
|
|
||||||
|
// Modal context (which item is being edited)
|
||||||
|
editingRepository: ConfiguredRepository | null;
|
||||||
|
preselectedRepositoryId: string | undefined;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
openAddRepoModal: () => void;
|
||||||
|
closeAddRepoModal: () => void;
|
||||||
|
openEditRepoModal: (repository: ConfiguredRepository) => void;
|
||||||
|
closeEditRepoModal: () => void;
|
||||||
|
openCreateWorkOrderModal: (repositoryId?: string) => void;
|
||||||
|
closeCreateWorkOrderModal: () => void;
|
||||||
|
closeAllModals: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modals Slice
|
||||||
|
*
|
||||||
|
* Manages modal visibility and context (which repository is being edited, etc.).
|
||||||
|
* Enables opening modals from anywhere without prop drilling.
|
||||||
|
*
|
||||||
|
* Persisted: NO (modals should not persist across page reloads)
|
||||||
|
*
|
||||||
|
* Note: Form state (repositoryUrl, selectedSteps, etc.) can be added to this slice
|
||||||
|
* if centralized validation/submission logic is desired. For simple forms that
|
||||||
|
* reset on close, local useState in the modal component is cleaner.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Open modal from anywhere
|
||||||
|
* const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
|
||||||
|
* openEditRepoModal(repository);
|
||||||
|
*
|
||||||
|
* // Subscribe to modal state
|
||||||
|
* const showEditRepoModal = useAgentWorkOrdersStore((s) => s.showEditRepoModal);
|
||||||
|
* const editingRepository = useAgentWorkOrdersStore((s) => s.editingRepository);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const createModalsSlice: StateCreator<ModalsSlice, [], [], ModalsSlice> = (set) => ({
|
||||||
|
// Initial state
|
||||||
|
showAddRepoModal: false,
|
||||||
|
showEditRepoModal: false,
|
||||||
|
showCreateWorkOrderModal: false,
|
||||||
|
editingRepository: null,
|
||||||
|
preselectedRepositoryId: undefined,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
openAddRepoModal: () => set({ showAddRepoModal: true }),
|
||||||
|
|
||||||
|
closeAddRepoModal: () => set({ showAddRepoModal: false }),
|
||||||
|
|
||||||
|
openEditRepoModal: (repository) =>
|
||||||
|
set({
|
||||||
|
showEditRepoModal: true,
|
||||||
|
editingRepository: repository,
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeEditRepoModal: () =>
|
||||||
|
set({
|
||||||
|
showEditRepoModal: false,
|
||||||
|
editingRepository: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
openCreateWorkOrderModal: (repositoryId) =>
|
||||||
|
set({
|
||||||
|
showCreateWorkOrderModal: true,
|
||||||
|
preselectedRepositoryId: repositoryId,
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeCreateWorkOrderModal: () =>
|
||||||
|
set({
|
||||||
|
showCreateWorkOrderModal: false,
|
||||||
|
preselectedRepositoryId: undefined,
|
||||||
|
}),
|
||||||
|
|
||||||
|
closeAllModals: () =>
|
||||||
|
set({
|
||||||
|
showAddRepoModal: false,
|
||||||
|
showEditRepoModal: false,
|
||||||
|
showCreateWorkOrderModal: false,
|
||||||
|
editingRepository: null,
|
||||||
|
preselectedRepositoryId: undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
import type { LogEntry, SSEConnectionState } from "../../types";
|
||||||
|
|
||||||
|
export type LiveProgress = {
|
||||||
|
currentStep?: string;
|
||||||
|
stepNumber?: number;
|
||||||
|
totalSteps?: number;
|
||||||
|
progressPct?: number;
|
||||||
|
elapsedSeconds?: number;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSESlice = {
|
||||||
|
// Active EventSource connections (keyed by work_order_id)
|
||||||
|
logConnections: Map<string, EventSource>;
|
||||||
|
|
||||||
|
// Connection states
|
||||||
|
connectionStates: Record<string, SSEConnectionState>;
|
||||||
|
|
||||||
|
// Live data from SSE (keyed by work_order_id)
|
||||||
|
// This OVERLAYS on top of TanStack Query cached data
|
||||||
|
liveLogs: Record<string, LogEntry[]>;
|
||||||
|
liveProgress: Record<string, LiveProgress>;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
connectToLogs: (workOrderId: string) => void;
|
||||||
|
disconnectFromLogs: (workOrderId: string) => void;
|
||||||
|
handleLogEvent: (workOrderId: string, log: LogEntry) => void;
|
||||||
|
clearLogs: (workOrderId: string) => void;
|
||||||
|
disconnectAll: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE Slice
|
||||||
|
*
|
||||||
|
* Manages Server-Sent Event connections and real-time data from log streams.
|
||||||
|
* Handles connection lifecycle, auto-reconnect, and live data aggregation.
|
||||||
|
*
|
||||||
|
* Persisted: NO (connections must be re-established on page load)
|
||||||
|
*
|
||||||
|
* Pattern:
|
||||||
|
* 1. Component calls connectToLogs(workOrderId) on mount
|
||||||
|
* 2. Zustand creates EventSource if not exists
|
||||||
|
* 3. Multiple components can subscribe to same connection
|
||||||
|
* 4. handleLogEvent parses logs and updates liveProgress
|
||||||
|
* 5. Component calls disconnectFromLogs on unmount
|
||||||
|
* 6. Zustand closes EventSource when no more subscribers
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Connect to SSE
|
||||||
|
* const connectToLogs = useAgentWorkOrdersStore((s) => s.connectToLogs);
|
||||||
|
* const disconnectFromLogs = useAgentWorkOrdersStore((s) => s.disconnectFromLogs);
|
||||||
|
*
|
||||||
|
* useEffect(() => {
|
||||||
|
* connectToLogs(workOrderId);
|
||||||
|
* return () => disconnectFromLogs(workOrderId);
|
||||||
|
* }, [workOrderId]);
|
||||||
|
*
|
||||||
|
* // Subscribe to live progress
|
||||||
|
* const progress = useAgentWorkOrdersStore((s) => s.liveProgress[workOrderId]);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const createSSESlice: StateCreator<SSESlice, [], [], SSESlice> = (set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
logConnections: new Map(),
|
||||||
|
connectionStates: {},
|
||||||
|
liveLogs: {},
|
||||||
|
liveProgress: {},
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
connectToLogs: (workOrderId) => {
|
||||||
|
const { logConnections } = get();
|
||||||
|
|
||||||
|
// Don't create duplicate connections
|
||||||
|
if (logConnections.has(workOrderId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connecting state
|
||||||
|
set((state) => ({
|
||||||
|
connectionStates: {
|
||||||
|
...state.connectionStates,
|
||||||
|
[workOrderId]: "connecting" as SSEConnectionState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create EventSource for log stream
|
||||||
|
const url = `/api/agent-work-orders/${workOrderId}/logs/stream`;
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
set((state) => ({
|
||||||
|
connectionStates: {
|
||||||
|
...state.connectionStates,
|
||||||
|
[workOrderId]: "connected" as SSEConnectionState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const logEntry: LogEntry = JSON.parse(event.data);
|
||||||
|
get().handleLogEvent(workOrderId, logEntry);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse log entry:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (event) => {
|
||||||
|
// Check if this is a 404 (work order doesn't exist)
|
||||||
|
// EventSource doesn't give us status code, but we can check if it's a permanent failure
|
||||||
|
// by attempting to determine if the server is reachable
|
||||||
|
const target = event.target as EventSource;
|
||||||
|
|
||||||
|
// If the EventSource readyState is CLOSED (2), it won't reconnect
|
||||||
|
// This typically happens on 404 or permanent errors
|
||||||
|
if (target.readyState === EventSource.CLOSED) {
|
||||||
|
// Permanent failure (likely 404) - clean up and don't retry
|
||||||
|
eventSource.close();
|
||||||
|
set((state) => {
|
||||||
|
const newConnections = new Map(state.logConnections);
|
||||||
|
newConnections.delete(workOrderId);
|
||||||
|
|
||||||
|
// Remove from persisted state too
|
||||||
|
const newLiveLogs = { ...state.liveLogs };
|
||||||
|
const newLiveProgress = { ...state.liveProgress };
|
||||||
|
delete newLiveLogs[workOrderId];
|
||||||
|
delete newLiveProgress[workOrderId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
logConnections: newConnections,
|
||||||
|
liveLogs: newLiveLogs,
|
||||||
|
liveProgress: newLiveProgress,
|
||||||
|
connectionStates: {
|
||||||
|
...state.connectionStates,
|
||||||
|
[workOrderId]: "disconnected" as SSEConnectionState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporary error - retry after 5 seconds
|
||||||
|
set((state) => ({
|
||||||
|
connectionStates: {
|
||||||
|
...state.connectionStates,
|
||||||
|
[workOrderId]: "error" as SSEConnectionState,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
eventSource.close();
|
||||||
|
set((state) => {
|
||||||
|
const newConnections = new Map(state.logConnections);
|
||||||
|
newConnections.delete(workOrderId);
|
||||||
|
return { logConnections: newConnections };
|
||||||
|
});
|
||||||
|
get().connectToLogs(workOrderId); // Retry
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store connection
|
||||||
|
const newConnections = new Map(logConnections);
|
||||||
|
newConnections.set(workOrderId, eventSource);
|
||||||
|
set({ logConnections: newConnections });
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectFromLogs: (workOrderId) => {
|
||||||
|
const { logConnections } = get();
|
||||||
|
const connection = logConnections.get(workOrderId);
|
||||||
|
|
||||||
|
if (connection) {
|
||||||
|
connection.close();
|
||||||
|
const newConnections = new Map(logConnections);
|
||||||
|
newConnections.delete(workOrderId);
|
||||||
|
|
||||||
|
set({
|
||||||
|
logConnections: newConnections,
|
||||||
|
connectionStates: {
|
||||||
|
...get().connectionStates,
|
||||||
|
[workOrderId]: "disconnected" as SSEConnectionState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleLogEvent: (workOrderId, log) => {
|
||||||
|
// Add to logs array
|
||||||
|
set((state) => ({
|
||||||
|
liveLogs: {
|
||||||
|
...state.liveLogs,
|
||||||
|
[workOrderId]: [...(state.liveLogs[workOrderId] || []), log].slice(-500), // Keep last 500
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Parse log to update progress
|
||||||
|
const progressUpdate: Partial<LiveProgress> = {};
|
||||||
|
|
||||||
|
if (log.event === "step_started") {
|
||||||
|
progressUpdate.currentStep = log.step;
|
||||||
|
progressUpdate.stepNumber = log.step_number;
|
||||||
|
progressUpdate.totalSteps = log.total_steps;
|
||||||
|
|
||||||
|
// Calculate progress based on COMPLETED steps (current - 1)
|
||||||
|
// If on step 3/3, progress is 66% (2 completed), not 100%
|
||||||
|
if (log.step_number !== undefined && log.total_steps !== undefined && log.total_steps > 0) {
|
||||||
|
const completedSteps = log.step_number - 1; // Steps completed before current
|
||||||
|
progressUpdate.progressPct = Math.round((completedSteps / log.total_steps) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step_completed: Increment progress by 1 step
|
||||||
|
if (log.event === "step_completed") {
|
||||||
|
const currentProgress = get().liveProgress[workOrderId];
|
||||||
|
if (currentProgress?.stepNumber !== undefined && currentProgress?.totalSteps !== undefined) {
|
||||||
|
const completedSteps = currentProgress.stepNumber; // Current step now complete
|
||||||
|
progressUpdate.progressPct = Math.round((completedSteps / currentProgress.totalSteps) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.elapsed_seconds !== undefined) {
|
||||||
|
progressUpdate.elapsedSeconds = log.elapsed_seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.event === "workflow_completed") {
|
||||||
|
progressUpdate.status = "completed";
|
||||||
|
progressUpdate.progressPct = 100; // Ensure 100% on completion
|
||||||
|
}
|
||||||
|
|
||||||
|
if (log.event === "workflow_failed" || log.level === "error") {
|
||||||
|
progressUpdate.status = "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(progressUpdate).length > 0) {
|
||||||
|
set((state) => ({
|
||||||
|
liveProgress: {
|
||||||
|
...state.liveProgress,
|
||||||
|
[workOrderId]: {
|
||||||
|
...state.liveProgress[workOrderId],
|
||||||
|
...progressUpdate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearLogs: (workOrderId) => {
|
||||||
|
set((state) => ({
|
||||||
|
liveLogs: {
|
||||||
|
...state.liveLogs,
|
||||||
|
[workOrderId]: [],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectAll: () => {
|
||||||
|
const { logConnections } = get();
|
||||||
|
logConnections.forEach((conn) => conn.close());
|
||||||
|
|
||||||
|
set({
|
||||||
|
logConnections: new Map(),
|
||||||
|
connectionStates: {},
|
||||||
|
liveLogs: {},
|
||||||
|
liveProgress: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { StateCreator } from "zustand";
|
||||||
|
|
||||||
|
export type LayoutMode = "horizontal" | "sidebar";
|
||||||
|
|
||||||
|
export type UIPreferencesSlice = {
|
||||||
|
// State
|
||||||
|
layoutMode: LayoutMode;
|
||||||
|
sidebarExpanded: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setLayoutMode: (mode: LayoutMode) => void;
|
||||||
|
setSidebarExpanded: (expanded: boolean) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
resetUIPreferences: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI Preferences Slice
|
||||||
|
*
|
||||||
|
* Manages user interface preferences that should persist across sessions.
|
||||||
|
* Includes layout mode (horizontal/sidebar) and sidebar expansion state.
|
||||||
|
*
|
||||||
|
* Persisted: YES (via persist middleware in main store)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const layoutMode = useAgentWorkOrdersStore((s) => s.layoutMode);
|
||||||
|
* const setLayoutMode = useAgentWorkOrdersStore((s) => s.setLayoutMode);
|
||||||
|
* setLayoutMode("horizontal");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const createUIPreferencesSlice: StateCreator<UIPreferencesSlice, [], [], UIPreferencesSlice> = (set) => ({
|
||||||
|
// Initial state
|
||||||
|
layoutMode: "sidebar",
|
||||||
|
sidebarExpanded: true,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setLayoutMode: (mode) => set({ layoutMode: mode }),
|
||||||
|
|
||||||
|
setSidebarExpanded: (expanded) => set({ sidebarExpanded: expanded }),
|
||||||
|
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarExpanded: !state.sidebarExpanded })),
|
||||||
|
|
||||||
|
resetUIPreferences: () =>
|
||||||
|
set({
|
||||||
|
layoutMode: "sidebar",
|
||||||
|
sidebarExpanded: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
219
archon-ui-main/src/features/agent-work-orders/types/index.ts
Normal file
219
archon-ui-main/src/features/agent-work-orders/types/index.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** Optional configured repository ID for linking work order to repository */
|
||||||
|
repository_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from GET /logs endpoint
|
||||||
|
* Contains historical log entries with pagination
|
||||||
|
*/
|
||||||
|
export interface WorkOrderLogsResponse {
|
||||||
|
/** Work order ID */
|
||||||
|
agent_work_order_id: string;
|
||||||
|
|
||||||
|
/** Array of log entries */
|
||||||
|
log_entries: LogEntry[];
|
||||||
|
|
||||||
|
/** Total number of logs available */
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
/** Number of logs returned in this response */
|
||||||
|
limit: number;
|
||||||
|
|
||||||
|
/** Offset used for pagination */
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export repository types
|
||||||
|
export type { ConfiguredRepository, CreateRepositoryRequest, UpdateRepositoryRequest } from "./repository";
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Repository Type Definitions
|
||||||
|
*
|
||||||
|
* This module defines TypeScript interfaces for configured repositories.
|
||||||
|
* These types mirror the backend models from python/src/agent_work_orders/models.py ConfiguredRepository
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SandboxType, WorkflowStep } from "./index";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface ConfiguredRepository {
|
||||||
|
/** Unique UUID identifier for the configured repository */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** GitHub repository URL (https://github.com/owner/repo format) */
|
||||||
|
repository_url: string;
|
||||||
|
|
||||||
|
/** Human-readable repository name (e.g., 'owner/repo-name') */
|
||||||
|
display_name: string | null;
|
||||||
|
|
||||||
|
/** Repository owner/organization name */
|
||||||
|
owner: string | null;
|
||||||
|
|
||||||
|
/** Default branch name (e.g., 'main' or 'master') */
|
||||||
|
default_branch: string | null;
|
||||||
|
|
||||||
|
/** Boolean flag indicating if repository access has been verified */
|
||||||
|
is_verified: boolean;
|
||||||
|
|
||||||
|
/** Timestamp of last successful repository verification */
|
||||||
|
last_verified_at: string | null;
|
||||||
|
|
||||||
|
/** Default sandbox type for work orders */
|
||||||
|
default_sandbox_type: SandboxType;
|
||||||
|
|
||||||
|
/** Default workflow commands for work orders */
|
||||||
|
default_commands: WorkflowStep[];
|
||||||
|
|
||||||
|
/** Timestamp when repository configuration was created */
|
||||||
|
created_at: string;
|
||||||
|
|
||||||
|
/** Timestamp when repository configuration was last updated */
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export interface CreateRepositoryRequest {
|
||||||
|
/** GitHub repository URL to configure */
|
||||||
|
repository_url: string;
|
||||||
|
|
||||||
|
/** Whether to verify repository access via GitHub API and extract metadata */
|
||||||
|
verify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to update an existing configured repository
|
||||||
|
*
|
||||||
|
* All fields are optional for partial updates. Only provided fields will be
|
||||||
|
* updated in the database.
|
||||||
|
*/
|
||||||
|
export interface UpdateRepositoryRequest {
|
||||||
|
/** Update the display name for this repository */
|
||||||
|
display_name?: string;
|
||||||
|
|
||||||
|
/** Update the default sandbox type for this repository */
|
||||||
|
default_sandbox_type?: SandboxType;
|
||||||
|
|
||||||
|
/** Update the default workflow commands for this repository */
|
||||||
|
default_commands?: WorkflowStep[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/**
|
||||||
|
* Agent Work Order Detail View
|
||||||
|
*
|
||||||
|
* Detailed view of a single agent work order showing progress, step history,
|
||||||
|
* logs, and full metadata.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Card } from "@/features/ui/primitives/card";
|
||||||
|
import { RealTimeStats } from "../components/RealTimeStats";
|
||||||
|
import { StepHistoryCard } from "../components/StepHistoryCard";
|
||||||
|
import { WorkflowStepButton } from "../components/WorkflowStepButton";
|
||||||
|
import { useStepHistory, useWorkOrder } from "../hooks/useAgentWorkOrderQueries";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
import type { WorkflowStep } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available workflow steps in execution order
|
||||||
|
*/
|
||||||
|
const ALL_WORKFLOW_STEPS: WorkflowStep[] = [
|
||||||
|
"create-branch",
|
||||||
|
"planning",
|
||||||
|
"execute",
|
||||||
|
"prp-review",
|
||||||
|
"commit",
|
||||||
|
"create-pr",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AgentWorkOrderDetailView() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { data: workOrder, isLoading: isLoadingWorkOrder, isError: isErrorWorkOrder } = useWorkOrder(id);
|
||||||
|
const { data: stepHistory, isLoading: isLoadingSteps, isError: isErrorSteps } = useStepHistory(id);
|
||||||
|
|
||||||
|
// Get live progress from SSE for total steps count
|
||||||
|
const liveProgress = useAgentWorkOrdersStore((s) => (id ? s.liveProgress[id] : undefined));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle step expansion
|
||||||
|
*/
|
||||||
|
const toggleStepExpansion = (stepId: string) => {
|
||||||
|
setExpandedSteps((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(stepId)) {
|
||||||
|
newSet.delete(stepId);
|
||||||
|
} else {
|
||||||
|
newSet.add(stepId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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-200 dark:bg-gray-800 rounded w-1/3" />
|
||||||
|
<div className="h-40 bg-gray-200 dark:bg-gray-800 rounded" />
|
||||||
|
<div className="h-60 bg-gray-200 dark: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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional safety check for repository_url
|
||||||
|
const repoName = workOrder?.repository_url?.split("/").slice(-2).join("/") || "Unknown Repository";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Breadcrumb navigation */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/agent-work-orders")}
|
||||||
|
className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
Work Orders
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">/</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/agent-work-orders")}
|
||||||
|
className="text-cyan-600 dark:text-cyan-400 hover:underline"
|
||||||
|
>
|
||||||
|
{repoName}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-400 dark:text-gray-600">/</span>
|
||||||
|
<span className="text-gray-900 dark:text-white">{workOrder.agent_work_order_id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Real-Time Execution Stats */}
|
||||||
|
<RealTimeStats workOrderId={id} />
|
||||||
|
|
||||||
|
{/* Workflow Progress Bar */}
|
||||||
|
<Card blur="md" transparency="light" edgePosition="top" edgeColor="cyan" size="lg" className="overflow-visible">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{repoName}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||||
|
aria-label={showDetails ? "Hide details" : "Show details"}
|
||||||
|
>
|
||||||
|
{showDetails ? (
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow Steps - Show all steps, highlight completed */}
|
||||||
|
<div className="flex items-center justify-center gap-0">
|
||||||
|
{ALL_WORKFLOW_STEPS.map((stepName, index) => {
|
||||||
|
// Find if this step has been executed
|
||||||
|
const executedStep = stepHistory.steps.find((s) => s.step === stepName);
|
||||||
|
const isCompleted = executedStep?.success || false;
|
||||||
|
// Mark as active if it's the last executed step and not successful (still running)
|
||||||
|
const isActive =
|
||||||
|
executedStep &&
|
||||||
|
stepHistory.steps[stepHistory.steps.length - 1]?.step === stepName &&
|
||||||
|
!executedStep.success;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stepName} className="flex items-center">
|
||||||
|
<WorkflowStepButton
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
isActive={isActive}
|
||||||
|
stepName={stepName}
|
||||||
|
color="cyan"
|
||||||
|
size={50}
|
||||||
|
/>
|
||||||
|
{/* Connecting Line - only show between steps */}
|
||||||
|
{index < ALL_WORKFLOW_STEPS.length - 1 && (
|
||||||
|
<div className="relative flex-shrink-0" style={{ width: "80px", height: "50px" }}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isCompleted
|
||||||
|
? "absolute top-1/2 left-0 right-0 h-[2px] border-t-2 border-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]"
|
||||||
|
: "absolute top-1/2 left-0 right-0 h-[2px] border-t-2 border-gray-600 dark:border-gray-700"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Details Section */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDetails && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
height: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: [0.04, 0.62, 0.23, 0.98],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeInOut",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -20 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: -20 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t border-gray-200/50 dark:border-gray-700/30"
|
||||||
|
>
|
||||||
|
{/* Left Column - Details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Details
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mt-0.5">
|
||||||
|
{workOrder.status.charAt(0).toUpperCase() + workOrder.status.slice(1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Sandbox Type</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">
|
||||||
|
{workOrder.sandbox_type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Repository</p>
|
||||||
|
<a
|
||||||
|
href={workOrder.repository_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
|
||||||
|
>
|
||||||
|
{workOrder.repository_url}
|
||||||
|
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{workOrder.git_branch_name && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Branch</p>
|
||||||
|
<p className="text-sm font-medium font-mono text-gray-900 dark:text-white mt-0.5">
|
||||||
|
{workOrder.git_branch_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Work Order ID</p>
|
||||||
|
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
|
||||||
|
{workOrder.agent_work_order_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{workOrder.agent_session_id && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Session ID</p>
|
||||||
|
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
|
||||||
|
{workOrder.agent_session_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{workOrder.github_pull_request_url && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Pull Request</p>
|
||||||
|
<a
|
||||||
|
href={workOrder.github_pull_request_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
|
||||||
|
>
|
||||||
|
View PR
|
||||||
|
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{workOrder.github_issue_number && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">GitHub Issue</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">
|
||||||
|
#{workOrder.github_issue_number}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Statistics */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Statistics
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Commits</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
|
||||||
|
{workOrder.git_commit_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Files Changed</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
|
||||||
|
{workOrder.git_files_changed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Steps Completed</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">
|
||||||
|
{stepHistory.steps.filter((s) => s.success).length} / {liveProgress?.totalSteps ?? stepHistory.steps.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step History */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{stepHistory.steps.map((step, index) => {
|
||||||
|
const stepId = `${step.step}-${index}`;
|
||||||
|
const isExpanded = expandedSteps.has(stepId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StepHistoryCard
|
||||||
|
key={stepId}
|
||||||
|
step={{
|
||||||
|
id: stepId,
|
||||||
|
stepName: step.step,
|
||||||
|
timestamp: new Date(step.timestamp).toLocaleString(),
|
||||||
|
output: step.output || "No output",
|
||||||
|
session: step.session_id || "Unknown session",
|
||||||
|
collapsible: true,
|
||||||
|
isHumanInLoop: false,
|
||||||
|
}}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onToggle={() => toggleStepExpansion(stepId)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* Agent Work Orders View
|
||||||
|
*
|
||||||
|
* Main view for agent work orders with repository management and layout switching.
|
||||||
|
* Supports horizontal and sidebar layout modes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight, GitBranch, LayoutGrid, List, Plus, Search } from "lucide-react";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useShallow } from "zustand/shallow";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Input } from "@/features/ui/primitives/input";
|
||||||
|
import { PillNavigation, type PillNavigationItem } from "@/features/ui/primitives/pill-navigation";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { AddRepositoryModal } from "../components/AddRepositoryModal";
|
||||||
|
import { CreateWorkOrderModal } from "../components/CreateWorkOrderModal";
|
||||||
|
import { EditRepositoryModal } from "../components/EditRepositoryModal";
|
||||||
|
import { RepositoryCard } from "../components/RepositoryCard";
|
||||||
|
import { SidebarRepositoryCard } from "../components/SidebarRepositoryCard";
|
||||||
|
import { WorkOrderTable } from "../components/WorkOrderTable";
|
||||||
|
import { useStartWorkOrder, useWorkOrders } from "../hooks/useAgentWorkOrderQueries";
|
||||||
|
import { useDeleteRepository, useRepositories } from "../hooks/useRepositoryQueries";
|
||||||
|
import { useAgentWorkOrdersStore } from "../state/agentWorkOrdersStore";
|
||||||
|
|
||||||
|
export function AgentWorkOrdersView() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Zustand UI Preferences - Group related state with useShallow
|
||||||
|
const { layoutMode, sidebarExpanded } = useAgentWorkOrdersStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
layoutMode: s.layoutMode,
|
||||||
|
sidebarExpanded: s.sidebarExpanded,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zustand UI Preference Actions - Functions are stable, select individually
|
||||||
|
const setLayoutMode = useAgentWorkOrdersStore((s) => s.setLayoutMode);
|
||||||
|
const setSidebarExpanded = useAgentWorkOrdersStore((s) => s.setSidebarExpanded);
|
||||||
|
|
||||||
|
// Zustand Modals State - Group with useShallow
|
||||||
|
const { showAddRepoModal, showEditRepoModal, showCreateWorkOrderModal, editingRepository } = useAgentWorkOrdersStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
showAddRepoModal: s.showAddRepoModal,
|
||||||
|
showEditRepoModal: s.showEditRepoModal,
|
||||||
|
showCreateWorkOrderModal: s.showCreateWorkOrderModal,
|
||||||
|
editingRepository: s.editingRepository,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zustand Modal Actions - Functions are stable, select individually
|
||||||
|
const openAddRepoModal = useAgentWorkOrdersStore((s) => s.openAddRepoModal);
|
||||||
|
const closeAddRepoModal = useAgentWorkOrdersStore((s) => s.closeAddRepoModal);
|
||||||
|
const openEditRepoModal = useAgentWorkOrdersStore((s) => s.openEditRepoModal);
|
||||||
|
const closeEditRepoModal = useAgentWorkOrdersStore((s) => s.closeEditRepoModal);
|
||||||
|
const openCreateWorkOrderModal = useAgentWorkOrdersStore((s) => s.openCreateWorkOrderModal);
|
||||||
|
const closeCreateWorkOrderModal = useAgentWorkOrdersStore((s) => s.closeCreateWorkOrderModal);
|
||||||
|
|
||||||
|
// Zustand Filters - Select individually
|
||||||
|
const searchQuery = useAgentWorkOrdersStore((s) => s.searchQuery);
|
||||||
|
const setSearchQuery = useAgentWorkOrdersStore((s) => s.setSearchQuery);
|
||||||
|
|
||||||
|
// Use URL params as source of truth for selected repository (no Zustand state needed)
|
||||||
|
const selectedRepositoryId = searchParams.get("repo") || undefined;
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const { data: repositories = [], isLoading: isLoadingRepos } = useRepositories();
|
||||||
|
const { data: workOrders = [], isLoading: isLoadingWorkOrders } = useWorkOrders();
|
||||||
|
const startWorkOrder = useStartWorkOrder();
|
||||||
|
const deleteRepository = useDeleteRepository();
|
||||||
|
|
||||||
|
// Helper function to select repository (updates URL only)
|
||||||
|
const selectRepository = useCallback(
|
||||||
|
(id: string | undefined) => {
|
||||||
|
if (id) {
|
||||||
|
setSearchParams({ repo: id });
|
||||||
|
} else {
|
||||||
|
setSearchParams({});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setSearchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle repository deletion
|
||||||
|
*/
|
||||||
|
const handleDeleteRepository = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (confirm("Are you sure you want to delete this repository configuration?")) {
|
||||||
|
await deleteRepository.mutateAsync(id);
|
||||||
|
// If this was the selected repository, clear selection
|
||||||
|
if (selectedRepositoryId === id) {
|
||||||
|
selectRepository(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[deleteRepository, selectedRepositoryId, selectRepository],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate work order stats for a repository
|
||||||
|
*/
|
||||||
|
const getRepositoryStats = (repositoryId: string) => {
|
||||||
|
const repoWorkOrders = workOrders.filter((wo) => {
|
||||||
|
const repo = repositories.find((r) => r.id === repositoryId);
|
||||||
|
return repo && wo.repository_url === repo.repository_url;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: repoWorkOrders.length,
|
||||||
|
active: repoWorkOrders.filter((wo) => wo.status === "running" || wo.status === "pending").length,
|
||||||
|
done: repoWorkOrders.filter((wo) => wo.status === "completed").length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build tab items for PillNavigation
|
||||||
|
*/
|
||||||
|
const tabItems: PillNavigationItem[] = [
|
||||||
|
{ id: "all", label: "All Work Orders", icon: <GitBranch className="w-4 h-4" aria-hidden="true" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (selectedRepositoryId) {
|
||||||
|
const selectedRepo = repositories.find((r) => r.id === selectedRepositoryId);
|
||||||
|
if (selectedRepo) {
|
||||||
|
tabItems.push({
|
||||||
|
id: selectedRepositoryId,
|
||||||
|
label: selectedRepo.display_name || selectedRepo.repository_url,
|
||||||
|
icon: <GitBranch className="w-4 h-4" aria-hidden="true" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter repositories by search query
|
||||||
|
const filteredRepositories = repositories.filter((repo) => {
|
||||||
|
const searchLower = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
repo.display_name?.toLowerCase().includes(searchLower) ||
|
||||||
|
repo.repository_url.toLowerCase().includes(searchLower) ||
|
||||||
|
repo.owner?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Agent Work Orders</h1>
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative flex-1 min-w-0 max-w-md">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search repositories..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
aria-label="Search repositories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layout Toggle */}
|
||||||
|
<div className="flex gap-1 p-1 bg-black/30 dark:bg-white/10 rounded-lg border border-white/10 dark:border-gray-700">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLayoutMode("sidebar")}
|
||||||
|
className={cn(
|
||||||
|
"px-3",
|
||||||
|
layoutMode === "sidebar" && "bg-purple-500/20 dark:bg-purple-500/30 text-purple-400 dark:text-purple-300",
|
||||||
|
)}
|
||||||
|
aria-label="Switch to sidebar layout"
|
||||||
|
aria-pressed={layoutMode === "sidebar"}
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLayoutMode("horizontal")}
|
||||||
|
className={cn(
|
||||||
|
"px-3",
|
||||||
|
layoutMode === "horizontal" &&
|
||||||
|
"bg-purple-500/20 dark:bg-purple-500/30 text-purple-400 dark:text-purple-300",
|
||||||
|
)}
|
||||||
|
aria-label="Switch to horizontal layout"
|
||||||
|
aria-pressed={layoutMode === "horizontal"}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Repo Button */}
|
||||||
|
<Button onClick={openAddRepoModal} variant="cyan" aria-label="Add new repository">
|
||||||
|
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
New Repo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<AddRepositoryModal open={showAddRepoModal} onOpenChange={closeAddRepoModal} />
|
||||||
|
<EditRepositoryModal open={showEditRepoModal} onOpenChange={closeEditRepoModal} />
|
||||||
|
<CreateWorkOrderModal open={showCreateWorkOrderModal} onOpenChange={closeCreateWorkOrderModal} />
|
||||||
|
|
||||||
|
{/* Horizontal Layout */}
|
||||||
|
{layoutMode === "horizontal" && (
|
||||||
|
<>
|
||||||
|
{/* Repository cards in horizontal scroll */}
|
||||||
|
<div className="w-full max-w-full">
|
||||||
|
<div className="overflow-x-auto overflow-y-visible py-8 -mx-6 px-6 scrollbar-hide">
|
||||||
|
<div className="flex gap-4 min-w-max">
|
||||||
|
{filteredRepositories.length === 0 ? (
|
||||||
|
<div className="w-full text-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{searchQuery ? "No repositories match your search" : "No repositories configured"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRepositories.map((repository) => (
|
||||||
|
<RepositoryCard
|
||||||
|
key={repository.id}
|
||||||
|
repository={repository}
|
||||||
|
isSelected={selectedRepositoryId === repository.id}
|
||||||
|
showAuroraGlow={selectedRepositoryId === repository.id}
|
||||||
|
onSelect={() => selectRepository(repository.id)}
|
||||||
|
onDelete={() => handleDeleteRepository(repository.id)}
|
||||||
|
stats={getRepositoryStats(repository.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PillNavigation centered */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<PillNavigation
|
||||||
|
items={tabItems}
|
||||||
|
activeSection={selectedRepositoryId || "all"}
|
||||||
|
onSectionClick={(id) => {
|
||||||
|
if (id === "all") {
|
||||||
|
selectRepository(undefined);
|
||||||
|
} else {
|
||||||
|
selectRepository(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar Layout */}
|
||||||
|
{layoutMode === "sidebar" && (
|
||||||
|
<div className="flex gap-4 min-w-0">
|
||||||
|
{/* Collapsible Sidebar */}
|
||||||
|
<div className={cn("shrink-0 transition-all duration-300 space-y-2", sidebarExpanded ? "w-56" : "w-12")}>
|
||||||
|
{/* Collapse/Expand button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSidebarExpanded(!sidebarExpanded)}
|
||||||
|
className="w-full justify-center"
|
||||||
|
aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||||
|
aria-expanded={sidebarExpanded}
|
||||||
|
>
|
||||||
|
{sidebarExpanded ? (
|
||||||
|
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Sidebar content */}
|
||||||
|
{sidebarExpanded && (
|
||||||
|
<div className="space-y-2 px-1">
|
||||||
|
{filteredRepositories.length === 0 ? (
|
||||||
|
<div className="text-center py-8 px-2">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{searchQuery ? "No repositories match" : "No repositories"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRepositories.map((repository) => (
|
||||||
|
<SidebarRepositoryCard
|
||||||
|
key={repository.id}
|
||||||
|
repository={repository}
|
||||||
|
isSelected={selectedRepositoryId === repository.id}
|
||||||
|
isPinned={false}
|
||||||
|
showAuroraGlow={selectedRepositoryId === repository.id}
|
||||||
|
onSelect={() => selectRepository(repository.id)}
|
||||||
|
onDelete={() => handleDeleteRepository(repository.id)}
|
||||||
|
stats={getRepositoryStats(repository.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
{/* PillNavigation centered */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<PillNavigation
|
||||||
|
items={tabItems}
|
||||||
|
activeSection={selectedRepositoryId || "all"}
|
||||||
|
onSectionClick={(id) => {
|
||||||
|
if (id === "all") {
|
||||||
|
selectRepository(undefined);
|
||||||
|
} else {
|
||||||
|
selectRepository(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Work Orders Table */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Work Orders</h3>
|
||||||
|
<Button
|
||||||
|
onClick={() => openCreateWorkOrderModal(selectedRepositoryId)}
|
||||||
|
variant="cyan"
|
||||||
|
aria-label="Create new work order"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
New Work Order
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkOrderTable
|
||||||
|
workOrders={workOrders}
|
||||||
|
selectedRepositoryId={selectedRepositoryId}
|
||||||
|
onStartWorkOrder={(id) => startWorkOrder.mutate(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Horizontal layout work orders table (below repository cards) */}
|
||||||
|
{layoutMode === "horizontal" && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Work Orders</h3>
|
||||||
|
<Button
|
||||||
|
onClick={() => openCreateWorkOrderModal(selectedRepositoryId)}
|
||||||
|
variant="cyan"
|
||||||
|
aria-label="Create new work order"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
New Work Order
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WorkOrderTable
|
||||||
|
workOrders={workOrders}
|
||||||
|
selectedRepositoryId={selectedRepositoryId}
|
||||||
|
onStartWorkOrder={(id) => startWorkOrder.mutate(id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{(isLoadingRepos || isLoadingWorkOrders) && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
|
|||||||
"focus:ring-1 focus:ring-cyan-400 px-2 py-1",
|
"focus:ring-1 focus:ring-cyan-400 px-2 py-1",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{description && description.trim() && (
|
{description?.trim() && (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info
|
<Info
|
||||||
@@ -183,7 +183,7 @@ export const KnowledgeCardTitle: React.FC<KnowledgeCardTitleProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
</SimpleTooltip>
|
</SimpleTooltip>
|
||||||
{description && description.trim() && (
|
{description?.trim() && (
|
||||||
<Tooltip delayDuration={200}>
|
<Tooltip delayDuration={200}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info
|
<Info
|
||||||
|
|||||||
@@ -67,17 +67,17 @@ export const LevelSelector: React.FC<LevelSelectorProps> = ({ value, onValueChan
|
|||||||
Crawl Depth
|
Crawl Depth
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-gray-400 hover:text-cyan-500 transition-colors cursor-help"
|
className="text-gray-400 hover:text-cyan-500 transition-colors cursor-help"
|
||||||
aria-label="Show crawl depth level details"
|
aria-label="Show crawl depth level details"
|
||||||
>
|
>
|
||||||
<Info className="w-4 h-4" />
|
<Info className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{tooltipContent}</TooltipContent>
|
<TooltipContent side="right">{tooltipContent}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Higher levels crawl deeper into the website structure
|
Higher levels crawl deeper into the website structure
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ export const ContentViewer: React.FC<ContentViewerProps> = ({ selectedItem, onCo
|
|||||||
try {
|
try {
|
||||||
// Escape HTML entities FIRST per Prism documentation requirement
|
// Escape HTML entities FIRST per Prism documentation requirement
|
||||||
// Prism expects pre-escaped input to prevent XSS
|
// Prism expects pre-escaped input to prevent XSS
|
||||||
const escaped = code
|
const escaped = code.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
|
|
||||||
const lang = language?.toLowerCase() || "javascript";
|
const lang = language?.toLowerCase() || "javascript";
|
||||||
const grammar = Prism.languages[lang] || Prism.languages.javascript;
|
const grammar = Prism.languages[lang] || Prism.languages.javascript;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const KnowledgeInspector: React.FC<KnowledgeInspectorProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewMode(initialTab);
|
setViewMode(initialTab);
|
||||||
setSelectedItem(null); // Clear selected item when switching tabs
|
setSelectedItem(null); // Clear selected item when switching tabs
|
||||||
}, [item.source_id, initialTab]);
|
}, [initialTab, item.source_id]);
|
||||||
|
|
||||||
// Use pagination hook for current view mode
|
// Use pagination hook for current view mode
|
||||||
const paginationData = useInspectorPagination({
|
const paginationData = useInspectorPagination({
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function usePaginatedInspectorData({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetDocs();
|
resetDocs();
|
||||||
resetCode();
|
resetCode();
|
||||||
}, [sourceId, enabled, resetDocs, resetCode]);
|
}, [resetDocs, resetCode]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documents: {
|
documents: {
|
||||||
|
|||||||
@@ -268,9 +268,7 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
|
|||||||
{operation.discovered_file}
|
{operation.discovered_file}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-400 truncate block">
|
<span className="text-sm text-gray-400 truncate block">{operation.discovered_file}</span>
|
||||||
{operation.discovered_file}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +281,7 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
|
|||||||
{operation.linked_files.length > 1 ? "s" : ""}
|
{operation.linked_files.length > 1 ? "s" : ""}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||||
{operation.linked_files.map((file: string, idx: number) => (
|
{operation.linked_files.map((file: string, idx: number) =>
|
||||||
isValidHttpUrl(file) ? (
|
isValidHttpUrl(file) ? (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -298,8 +296,8 @@ export const CrawlingProgress: React.FC<CrawlingProgressProps> = ({ onSwitchToBr
|
|||||||
<span key={idx} className="text-xs text-gray-400 truncate block">
|
<span key={idx} className="text-xs text-gray-400 truncate block">
|
||||||
• {file}
|
• {file}
|
||||||
</span>
|
</span>
|
||||||
)
|
),
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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 React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { ActiveOperationsResponse, ProgressResponse } from "../../types";
|
import type { ActiveOperationsResponse, ProgressResponse } from "../../types";
|
||||||
|
|||||||
@@ -240,12 +240,12 @@ export function useMultipleOperations(
|
|||||||
|
|
||||||
// Reset tracking sets when progress IDs change
|
// Reset tracking sets when progress IDs change
|
||||||
// Use sorted JSON stringification for stable dependency that handles reordering
|
// 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(() => {
|
useEffect(() => {
|
||||||
completedIds.current.clear();
|
completedIds.current.clear();
|
||||||
errorIds.current.clear();
|
errorIds.current.clear();
|
||||||
notFoundCounts.current.clear();
|
notFoundCounts.current.clear();
|
||||||
}, [progressIdsKey]); // Stable dependency across reorderings
|
}, [_progressIdsKey]); // Stable dependency across reorderings
|
||||||
|
|
||||||
const queries = useQueries({
|
const queries = useQueries({
|
||||||
queries: progressIds.map((progressId) => ({
|
queries: progressIds.map((progressId) => ({
|
||||||
|
|||||||
@@ -13,32 +13,32 @@ const SAFE_PROTOCOLS = ["http:", "https:"];
|
|||||||
* @returns true if URL is safe (http/https), false otherwise
|
* @returns true if URL is safe (http/https), false otherwise
|
||||||
*/
|
*/
|
||||||
export function isValidHttpUrl(url: string | undefined | null): boolean {
|
export function isValidHttpUrl(url: string | undefined | null): boolean {
|
||||||
if (!url || typeof url !== "string") {
|
if (!url || typeof url !== "string") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim whitespace
|
// Trim whitespace
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(trimmed);
|
const parsed = new URL(trimmed);
|
||||||
|
|
||||||
// Only allow http and https protocols
|
// Only allow http and https protocols
|
||||||
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
|
if (!SAFE_PROTOCOLS.includes(parsed.protocol)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic hostname validation (must have at least one dot or be localhost)
|
// Basic hostname validation (must have at least one dot or be localhost)
|
||||||
if (!parsed.hostname.includes(".") && parsed.hostname !== "localhost") {
|
if (!parsed.hostname.includes(".") && parsed.hostname !== "localhost") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
// URL parsing failed - not a valid URL
|
// URL parsing failed - not a valid URL
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
|
optimistic && "opacity-80 ring-1 ring-cyan-400/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
||||||
{/* Main content area with padding */}
|
{/* Main content area with padding */}
|
||||||
<div className="flex-1 p-4 pb-2">
|
<div className="flex-1 p-4 pb-2">
|
||||||
{/* Title section */}
|
{/* Title section */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { LayoutGrid, List, Plus, Search, X } from "lucide-react";
|
import { LayoutGrid, List, Plus, Search, X } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Button } from "../../ui/primitives/button";
|
import { Button } from "../../ui/primitives/button";
|
||||||
import { Input } from "../../ui/primitives/input";
|
import { Input } from "../../ui/primitives/input";
|
||||||
import { cn } from "../../ui/primitives/styles";
|
import { cn } from "../../ui/primitives/styles";
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const DocsTab = ({ project }: DocsTabProps) => {
|
|||||||
await createDocumentMutation.mutateAsync({
|
await createDocumentMutation.mutateAsync({
|
||||||
title,
|
title,
|
||||||
document_type,
|
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.
|
// 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.
|
// "User" is a constant representing the sole user of this Archon instance.
|
||||||
author: "User",
|
author: "User",
|
||||||
@@ -94,7 +94,7 @@ export const DocsTab = ({ project }: DocsTabProps) => {
|
|||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setShowDeleteModal(false);
|
setShowDeleteModal(false);
|
||||||
setDocumentToDelete(null);
|
setDocumentToDelete(null);
|
||||||
}, [projectId]);
|
}, []);
|
||||||
|
|
||||||
// Auto-select first document when documents load
|
// Auto-select first document when documents load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -52,13 +52,7 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
|
|||||||
setError(null);
|
setError(null);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(typeof err === "string" ? err : err instanceof Error ? err.message : "Failed to create document");
|
||||||
typeof err === "string"
|
|
||||||
? err
|
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Failed to create document"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
@@ -81,7 +75,10 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<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
|
Document Title
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -96,7 +93,10 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Document Type
|
||||||
</label>
|
</label>
|
||||||
<Select value={type} onValueChange={setType} disabled={isAdding}>
|
<Select value={type} onValueChange={setType} disabled={isAdding}>
|
||||||
@@ -104,11 +104,21 @@ export const AddDocumentModal = ({ open, onOpenChange, onAdd }: AddDocumentModal
|
|||||||
<SelectValue placeholder="Select a document type" />
|
<SelectValue placeholder="Select a document type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent color="cyan">
|
<SelectContent color="cyan">
|
||||||
<SelectItem value="spec" color="cyan">Specification</SelectItem>
|
<SelectItem value="spec" color="cyan">
|
||||||
<SelectItem value="api" color="cyan">API Documentation</SelectItem>
|
Specification
|
||||||
<SelectItem value="guide" color="cyan">Guide</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="note" color="cyan">Note</SelectItem>
|
<SelectItem value="api" color="cyan">
|
||||||
<SelectItem value="design" color="cyan">Design</SelectItem>
|
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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
|
|||||||
aria-label={`${isActive ? "Selected: " : ""}${document.title}`}
|
aria-label={`${isActive ? "Selected: " : ""}${document.title}`}
|
||||||
className={cn("relative w-full cursor-pointer transition-all duration-300 group", isActive && "scale-[1.02]")}
|
className={cn("relative w-full cursor-pointer transition-all duration-300 group", isActive && "scale-[1.02]")}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{/* Document Type Badge */}
|
{/* Document Type Badge */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -177,7 +177,7 @@ export const DocumentCard = memo(({ document, isActive, onSelect, onDelete }: Do
|
|||||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,11 +60,8 @@ export const documentService = {
|
|||||||
* Delete a document
|
* Delete a document
|
||||||
*/
|
*/
|
||||||
async deleteDocument(projectId: string, documentId: string): Promise<void> {
|
async deleteDocument(projectId: string, documentId: string): Promise<void> {
|
||||||
await callAPIWithETag<{ success: boolean; message: string }>(
|
await callAPIWithETag<{ success: boolean; message: string }>(`/api/projects/${projectId}/docs/${documentId}`, {
|
||||||
`/api/projects/${projectId}/docs/${documentId}`,
|
method: "DELETE",
|
||||||
{
|
});
|
||||||
method: "DELETE",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRef } from "react";
|
|||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { cn } from "../../../ui/primitives/styles";
|
import { cn } from "../../../ui/primitives/styles";
|
||||||
import type { Task } from "../types";
|
import type { Task } from "../types";
|
||||||
import { getColumnColor, getColumnGlow, ItemTypes } from "../utils/task-styles";
|
import { getColumnGlow, ItemTypes } from "../utils/task-styles";
|
||||||
import { TaskCard } from "./TaskCard";
|
import { TaskCard } from "./TaskCard";
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
@@ -90,7 +90,7 @@ export const KanbanColumn = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border backdrop-blur-md",
|
"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}
|
{statusInfo.icon}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderHook, waitFor } from "@testing-library/react";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Task } from "../../types";
|
import type { Task } from "../../types";
|
||||||
import { taskKeys, useCreateTask, useProjectTasks, useTaskCounts } from "../useTaskQueries";
|
import { taskKeys, useCreateTask, useProjectTasks } from "../useTaskQueries";
|
||||||
|
|
||||||
// Mock the services
|
// Mock the services
|
||||||
vi.mock("../../services", () => ({
|
vi.mock("../../services", () => ({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { motion } from "framer-motion";
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
|
import { useStaggeredEntrance } from "../../../hooks/useStaggeredEntrance";
|
||||||
import { isOptimistic } from "../../shared/utils/optimistic";
|
import { isOptimistic } from "../../shared/utils/optimistic";
|
||||||
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
import { DeleteConfirmModal } from "../../ui/components/DeleteConfirmModal";
|
||||||
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
|
|
||||||
import { Button, PillNavigation, SelectableCard } from "../../ui/primitives";
|
import { Button, PillNavigation, SelectableCard } from "../../ui/primitives";
|
||||||
|
import { OptimisticIndicator } from "../../ui/primitives/OptimisticIndicator";
|
||||||
import { StatPill } from "../../ui/primitives/pill";
|
import { StatPill } from "../../ui/primitives/pill";
|
||||||
import { cn } from "../../ui/primitives/styles";
|
import { cn } from "../../ui/primitives/styles";
|
||||||
import { NewProjectModal } from "../components/NewProjectModal";
|
import { NewProjectModal } from "../components/NewProjectModal";
|
||||||
@@ -71,7 +71,7 @@ export function ProjectsView({ className = "", "data-id": dataId }: ProjectsView
|
|||||||
const sortedProjects = useMemo(() => {
|
const sortedProjects = useMemo(() => {
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
const filtered = (projects as Project[]).filter((project) =>
|
const filtered = (projects as Project[]).filter((project) =>
|
||||||
project.title.toLowerCase().includes(searchQuery.toLowerCase())
|
project.title.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort: pinned first, then alphabetically
|
// Sort: pinned first, then alphabetically
|
||||||
|
|||||||
@@ -42,11 +42,18 @@ function buildFullUrl(cleanEndpoint: string): string {
|
|||||||
*/
|
*/
|
||||||
export async function callAPIWithETag<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
export async function callAPIWithETag<T = unknown>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
try {
|
try {
|
||||||
// Clean endpoint
|
// Handle absolute URLs (direct service connections)
|
||||||
const cleanEndpoint = endpoint.startsWith("/api") ? endpoint.substring(4) : endpoint;
|
const isAbsoluteUrl = endpoint.startsWith("http://") || endpoint.startsWith("https://");
|
||||||
|
|
||||||
// Construct the full URL
|
let fullUrl: string;
|
||||||
const fullUrl = buildFullUrl(cleanEndpoint);
|
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
|
// 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
|
// 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.)
|
// Only set Content-Type for requests that have a body (POST, PUT, PATCH, etc.)
|
||||||
// GET and DELETE requests should not have Content-Type header
|
// 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;
|
const hasBody = options.body !== undefined && options.body !== null;
|
||||||
if (hasBody && !headers["Content-Type"]) {
|
if (hasBody && !headers["Content-Type"]) {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { ChevronDown, ChevronUp, ExternalLink, Plus, User } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Card } from "@/features/ui/primitives/card";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/features/ui/primitives/tooltip";
|
||||||
|
import { RealTimeStatsExample } from "./components/RealTimeStatsExample";
|
||||||
|
import { StepHistoryCard } from "./components/StepHistoryCard";
|
||||||
|
import { WorkflowStepButton } from "./components/WorkflowStepButton";
|
||||||
|
|
||||||
|
const MOCK_WORK_ORDER = {
|
||||||
|
id: "wo-1",
|
||||||
|
title: "Create comprehensive documentation",
|
||||||
|
status: "in_progress" as const,
|
||||||
|
workflow: {
|
||||||
|
currentStep: 2,
|
||||||
|
steps: [
|
||||||
|
{ id: "1", name: "Create Branch", status: "completed", duration: "33s" },
|
||||||
|
{ id: "2", name: "Planning", status: "in_progress", duration: "2m 11s" },
|
||||||
|
{ id: "3", name: "Execute", status: "pending", duration: null },
|
||||||
|
{ id: "4", name: "Commit", status: "pending", duration: null },
|
||||||
|
{ id: "5", name: "Create PR", status: "pending", duration: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
stepHistory: [
|
||||||
|
{
|
||||||
|
id: "step-1",
|
||||||
|
stepName: "Create Branch",
|
||||||
|
timestamp: "7 minutes ago",
|
||||||
|
output: "docs/remove-archon-mentions",
|
||||||
|
session: "Session: a342d9ac-56c4-43ae-95b8-9ddf18143961",
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "step-2",
|
||||||
|
stepName: "Planning",
|
||||||
|
timestamp: "5 minutes ago",
|
||||||
|
output: `## Report
|
||||||
|
|
||||||
|
**Work completed:**
|
||||||
|
|
||||||
|
- Conducted comprehensive codebase audit for "archon" and "Archon" mentions
|
||||||
|
- Verified main README.md is already breach (no archon mentions present)
|
||||||
|
- Identified 14 subdirectory README files that need verification
|
||||||
|
- Discovered historical git commits that added "hello from archon" but content has been removed
|
||||||
|
- Identified 3 remote branches with "archon" in their names (out of scope for this task)
|
||||||
|
- Created comprehensive PRP plan for documentation cleanup and verification`,
|
||||||
|
session: "Session: e3889823-b272-43c0-b11d-7a786d7e3c88",
|
||||||
|
collapsible: true,
|
||||||
|
isHumanInLoop: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
document: {
|
||||||
|
id: "doc-1",
|
||||||
|
title: "Planning Document",
|
||||||
|
content: {
|
||||||
|
markdown: `# Documentation Cleanup Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document outlines the plan to remove all "archon" mentions from the codebase.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Audit all README files
|
||||||
|
2. Check git history for sensitive content
|
||||||
|
3. Verify no configuration files reference "archon"
|
||||||
|
4. Update documentation
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
- [x] Initial audit complete
|
||||||
|
- [ ] README updates pending
|
||||||
|
- [ ] Configuration review pending`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentWorkOrderExample = () => {
|
||||||
|
const [hoveredStepIndex, setHoveredStepIndex] = useState<number | null>(null);
|
||||||
|
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set(["step-2"]));
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [humanInLoopCheckpoints, setHumanInLoopCheckpoints] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const toggleStepExpansion = (stepId: string) => {
|
||||||
|
setExpandedSteps((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(stepId)) {
|
||||||
|
newSet.delete(stepId);
|
||||||
|
} else {
|
||||||
|
newSet.add(stepId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addHumanInLoopCheckpoint = (index: number) => {
|
||||||
|
setHumanInLoopCheckpoints((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.add(index);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
setHoveredStepIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeHumanInLoopCheckpoint = (index: number) => {
|
||||||
|
setHumanInLoopCheckpoints((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(index);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Explanation Text */}
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Use this layout for:</strong> Agent work order workflows with step-by-step progress tracking,
|
||||||
|
collapsible history, and integrated document editing for human-in-the-loop approval.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Real-Time Execution Stats */}
|
||||||
|
<RealTimeStatsExample status="plan" stepNumber={2} />
|
||||||
|
|
||||||
|
{/* Workflow Progress Bar */}
|
||||||
|
<Card blur="md" transparency="light" edgePosition="top" edgeColor="cyan" size="lg" className="overflow-visible">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{MOCK_WORK_ORDER.title}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||||
|
aria-label={showDetails ? "Hide details" : "Show details"}
|
||||||
|
>
|
||||||
|
{showDetails ? (
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-0">
|
||||||
|
{MOCK_WORK_ORDER.workflow.steps.map((step, index) => (
|
||||||
|
<div key={step.id} className="flex items-center">
|
||||||
|
{/* Step Button */}
|
||||||
|
<WorkflowStepButton
|
||||||
|
isCompleted={step.status === "completed"}
|
||||||
|
isActive={step.status === "in_progress"}
|
||||||
|
stepName={step.name}
|
||||||
|
color="cyan"
|
||||||
|
size={50}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Connecting Line - only show between steps */}
|
||||||
|
{index < MOCK_WORK_ORDER.workflow.steps.length - 1 && (
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: Visual hover effect container for showing plus button
|
||||||
|
<div
|
||||||
|
className="relative flex-shrink-0"
|
||||||
|
style={{ width: "80px", height: "50px" }}
|
||||||
|
onMouseEnter={() => setHoveredStepIndex(index)}
|
||||||
|
onMouseLeave={() => setHoveredStepIndex(null)}
|
||||||
|
>
|
||||||
|
{/* Neon line */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 left-0 right-0 h-[2px] transition-all duration-200",
|
||||||
|
step.status === "completed"
|
||||||
|
? "border-t-2 border-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.6)]"
|
||||||
|
: "border-t-2 border-gray-600 dark:border-gray-700",
|
||||||
|
hoveredStepIndex === index &&
|
||||||
|
step.status !== "completed" &&
|
||||||
|
"border-cyan-400/50 shadow-[0_0_6px_rgba(34,211,238,0.3)]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Human-in-Loop Checkpoint Indicator */}
|
||||||
|
{humanInLoopCheckpoints.has(index) && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeHumanInLoopCheckpoint(index)}
|
||||||
|
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-orange-500 hover:bg-orange-600 rounded-full p-1.5 shadow-lg shadow-orange-500/50 border-2 border-orange-400 transition-colors cursor-pointer"
|
||||||
|
aria-label="Remove Human-in-Loop checkpoint"
|
||||||
|
>
|
||||||
|
<User className="w-3.5 h-3.5 text-white" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Click to remove</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plus button on hover - only show if no checkpoint exists */}
|
||||||
|
{hoveredStepIndex === index && !humanInLoopCheckpoints.has(index) && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addHumanInLoopCheckpoint(index)}
|
||||||
|
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-orange-500 hover:bg-orange-600 transition-colors shadow-lg shadow-orange-500/50 flex items-center justify-center text-white"
|
||||||
|
aria-label="Add Human-in-Loop step"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Add Human-in-Loop</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Details Section */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDetails && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
height: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: [0.04, 0.62, 0.23, 0.98],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeInOut",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -20 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: -20 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-6 border-t border-gray-200/50 dark:border-gray-700/30"
|
||||||
|
>
|
||||||
|
{/* Left Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Details
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Status</p>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mt-0.5">Running</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Sandbox Type</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white mt-0.5">git_branch</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Repository</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Wirasm/dylan"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline inline-flex items-center gap-1 mt-0.5"
|
||||||
|
>
|
||||||
|
https://github.com/Wirasm/dylan
|
||||||
|
<ExternalLink className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Branch</p>
|
||||||
|
<p className="text-sm font-medium font-mono text-gray-900 dark:text-white mt-0.5">
|
||||||
|
docs/remove-archon-mentions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Work Order ID</p>
|
||||||
|
<p className="text-sm font-medium font-mono text-gray-700 dark:text-gray-300 mt-0.5">
|
||||||
|
wo-7fd39c8d
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Statistics
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Commits</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Files Changed</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">0</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Steps Completed</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-0.5">2 / 2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Step History Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Step History</h3>
|
||||||
|
{MOCK_WORK_ORDER.stepHistory.map((step) => (
|
||||||
|
<StepHistoryCard
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
isExpanded={expandedSteps.has(step.id)}
|
||||||
|
onToggle={() => toggleStepExpansion(step.id)}
|
||||||
|
document={step.isHumanInLoop ? MOCK_WORK_ORDER.document : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/features/ui/primitives/select";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
import { Switch } from "@/features/ui/primitives/switch";
|
||||||
|
|
||||||
|
interface ExecutionLogsExampleProps {
|
||||||
|
/** Work order status to generate appropriate mock logs */
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockLog {
|
||||||
|
timestamp: string;
|
||||||
|
level: "info" | "warning" | "error" | "debug";
|
||||||
|
event: string;
|
||||||
|
step?: string;
|
||||||
|
progress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color class for log level badge - STATIC lookup
|
||||||
|
*/
|
||||||
|
const logLevelColors: Record<string, string> = {
|
||||||
|
info: "bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-400/30",
|
||||||
|
warning: "bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-400/30",
|
||||||
|
error: "bg-red-500/20 text-red-600 dark:text-red-400 border-red-400/30",
|
||||||
|
debug: "bg-gray-500/20 text-gray-600 dark: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`;
|
||||||
|
return `${Math.floor(diffSeconds / 3600)}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual log entry component
|
||||||
|
*/
|
||||||
|
function LogEntryRow({ log }: { log: MockLog }) {
|
||||||
|
const colorClass = logLevelColors[log.level] || logLevelColors.debug;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 py-1 px-2 hover:bg-white/5 dark:hover:bg-black/20 rounded font-mono text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">
|
||||||
|
{formatRelativeTime(log.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className={cn("px-1.5 py-0.5 rounded text-xs border uppercase whitespace-nowrap", colorClass)}>
|
||||||
|
{log.level}
|
||||||
|
</span>
|
||||||
|
{log.step && <span className="text-cyan-600 dark:text-cyan-400 text-xs whitespace-nowrap">[{log.step}]</span>}
|
||||||
|
<span className="text-gray-900 dark:text-gray-300 flex-1">{log.event}</span>
|
||||||
|
{log.progress && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">{log.progress}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionLogsExample({ status }: ExecutionLogsExampleProps) {
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<string>("all");
|
||||||
|
|
||||||
|
// Generate mock logs based on status
|
||||||
|
const generateMockLogs = (): MockLog[] => {
|
||||||
|
const now = Date.now();
|
||||||
|
const baseTime = now - 300000; // 5 minutes ago
|
||||||
|
|
||||||
|
const logs: MockLog[] = [
|
||||||
|
{ timestamp: new Date(baseTime).toISOString(), level: "info", event: "workflow_started" },
|
||||||
|
{ timestamp: new Date(baseTime + 1000).toISOString(), level: "info", event: "sandbox_setup_started" },
|
||||||
|
{
|
||||||
|
timestamp: new Date(baseTime + 3000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
event: "repository_cloned",
|
||||||
|
step: "setup",
|
||||||
|
},
|
||||||
|
{ timestamp: new Date(baseTime + 5000).toISOString(), level: "info", event: "sandbox_setup_completed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (status !== "pending") {
|
||||||
|
logs.push(
|
||||||
|
{
|
||||||
|
timestamp: new Date(baseTime + 10000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
event: "step_started",
|
||||||
|
step: "create-branch",
|
||||||
|
progress: "1/5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(baseTime + 12000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
event: "agent_command_started",
|
||||||
|
step: "create-branch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(baseTime + 45000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
event: "branch_created",
|
||||||
|
step: "create-branch",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "plan" || status === "execute" || status === "commit" || status === "create_pr") {
|
||||||
|
logs.push(
|
||||||
|
{
|
||||||
|
timestamp: new Date(baseTime + 60000).toISOString(),
|
||||||
|
level: "info",
|
||||||
|
event: "step_started",
|
||||||
|
step: "planning",
|
||||||
|
progress: "2/5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: new Date(baseTime + 120000).toISOString(),
|
||||||
|
level: "debug",
|
||||||
|
event: "analyzing_codebase",
|
||||||
|
step: "planning",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLogs = generateMockLogs();
|
||||||
|
const filteredLogs = levelFilter === "all" ? mockLogs : mockLogs.filter((log) => log.level === levelFilter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg overflow-hidden bg-black/20 dark:bg-white/5 backdrop-blur">
|
||||||
|
{/* Header with controls */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 dark:border-gray-700/30 bg-gray-900/50 dark:bg-gray-800/30">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-gray-300">Execution Logs</span>
|
||||||
|
|
||||||
|
{/* Live indicator */}
|
||||||
|
<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-600 dark:text-green-400">Live</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">({filteredLogs.length} entries)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Level filter using proper Select primitive */}
|
||||||
|
<Select value={levelFilter} onValueChange={setLevelFilter}>
|
||||||
|
<SelectTrigger className="w-32 h-8 text-xs" aria-label="Filter log level">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Levels</SelectItem>
|
||||||
|
<SelectItem value="info">Info</SelectItem>
|
||||||
|
<SelectItem value="warning">Warning</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
<SelectItem value="debug">Debug</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Auto-scroll toggle using Switch primitive */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="auto-scroll-toggle" className="text-xs text-gray-700 dark:text-gray-300">
|
||||||
|
Auto-scroll:
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id="auto-scroll-toggle"
|
||||||
|
checked={autoScroll}
|
||||||
|
onCheckedChange={setAutoScroll}
|
||||||
|
aria-label="Toggle auto-scroll"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
autoScroll ? "text-cyan-600 dark:text-cyan-400" : "text-gray-500 dark:text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{autoScroll ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear logs button */}
|
||||||
|
<Button variant="ghost" size="xs" aria-label="Clear logs">
|
||||||
|
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log content - scrollable area */}
|
||||||
|
<div className="max-h-96 overflow-y-auto bg-black/40 dark:bg-black/20">
|
||||||
|
{filteredLogs.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<p>No logs match the current filter</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2">
|
||||||
|
{filteredLogs.map((log, index) => (
|
||||||
|
<LogEntryRow key={`${log.timestamp}-${index}`} log={log} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Activity, ChevronDown, ChevronUp, Clock, TrendingUp } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { ExecutionLogsExample } from "./ExecutionLogsExample";
|
||||||
|
|
||||||
|
interface RealTimeStatsExampleProps {
|
||||||
|
/** Work order status for determining progress */
|
||||||
|
status: string;
|
||||||
|
/** Step number (1-5) */
|
||||||
|
stepNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RealTimeStatsExample({ status, stepNumber }: RealTimeStatsExampleProps) {
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
|
// Mock data based on status
|
||||||
|
const stepNames: Record<string, string> = {
|
||||||
|
create_branch: "create-branch",
|
||||||
|
plan: "planning",
|
||||||
|
execute: "execute",
|
||||||
|
commit: "commit",
|
||||||
|
create_pr: "create-pr",
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStep = stepNames[status] || "initializing";
|
||||||
|
const progressPct = (stepNumber / 5) * 100;
|
||||||
|
const mockElapsedSeconds = stepNumber * 120; // 2 minutes per step
|
||||||
|
|
||||||
|
const activities: Record<string, string> = {
|
||||||
|
create_branch: "Creating new branch for work order...",
|
||||||
|
plan: "Analyzing codebase and generating implementation plan...",
|
||||||
|
execute: "Writing code and applying changes...",
|
||||||
|
commit: "Committing changes to branch...",
|
||||||
|
create_pr: "Creating pull request on GitHub...",
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentActivity = activities[status] || "Initializing workflow...";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="border border-white/10 dark:border-gray-700/30 rounded-lg p-4 bg-black/20 dark:bg-white/5 backdrop-blur">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-300 mb-3 flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" aria-hidden="true" />
|
||||||
|
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 dark:text-gray-400 uppercase tracking-wide">Current Step</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{currentStep}
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 ml-2">({stepNumber}/5)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3 h-3" aria-hidden="true" />
|
||||||
|
Progress
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-gray-700 dark:bg-gray-200/20 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: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-cyan-600 dark:text-cyan-400">{progressPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Elapsed Time */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" aria-hidden="true" />
|
||||||
|
Elapsed Time
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
{formatDuration(mockElapsedSeconds)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Activity with Status Indicator - at top */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-white/10 dark:border-gray-700/30">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide whitespace-nowrap">
|
||||||
|
Latest Activity:
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-900 dark:text-gray-300 flex-1 truncate">{currentActivity}</div>
|
||||||
|
</div>
|
||||||
|
{/* Status Indicator - right side of Latest Activity */}
|
||||||
|
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 flex-shrink-0">
|
||||||
|
<div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||||
|
<span>Running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show Execution Logs button - at bottom */}
|
||||||
|
<div className="mt-3 pt-3 border-t border-white/10 dark:border-gray-700/30">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowLogs(!showLogs)}
|
||||||
|
className="w-full justify-center text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/10"
|
||||||
|
aria-label={showLogs ? "Hide execution logs" : "Show execution logs"}
|
||||||
|
aria-expanded={showLogs}
|
||||||
|
>
|
||||||
|
{showLogs ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
Hide Execution Logs
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
|
Show Execution Logs
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Execution Logs */}
|
||||||
|
{showLogs && <ExecutionLogsExample status={status} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertCircle, CheckCircle2, ChevronDown, ChevronUp, Edit3, Eye } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
|
import { Card } from "@/features/ui/primitives/card";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
|
||||||
|
interface StepHistoryCardProps {
|
||||||
|
step: {
|
||||||
|
id: string;
|
||||||
|
stepName: string;
|
||||||
|
timestamp: string;
|
||||||
|
output: string;
|
||||||
|
session: string;
|
||||||
|
collapsible: boolean;
|
||||||
|
isHumanInLoop?: boolean;
|
||||||
|
};
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
document?: {
|
||||||
|
title: string;
|
||||||
|
content: {
|
||||||
|
markdown: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepHistoryCard = ({ step, isExpanded, onToggle, document }: StepHistoryCardProps) => {
|
||||||
|
const [isEditingDocument, setIsEditingDocument] = useState(false);
|
||||||
|
const [editedContent, setEditedContent] = useState("");
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
const handleToggleEdit = () => {
|
||||||
|
if (!isEditingDocument && document) {
|
||||||
|
setEditedContent(document.content.markdown);
|
||||||
|
}
|
||||||
|
setIsEditingDocument(!isEditingDocument);
|
||||||
|
setHasChanges(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (value: string) => {
|
||||||
|
setEditedContent(value);
|
||||||
|
setHasChanges(document ? value !== document.content.markdown : false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveAndContinue = () => {
|
||||||
|
console.log("Approved and continuing to next step");
|
||||||
|
setHasChanges(false);
|
||||||
|
setIsEditingDocument(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
blur="md"
|
||||||
|
transparency="light"
|
||||||
|
edgePosition="left"
|
||||||
|
edgeColor={step.isHumanInLoop ? "orange" : "blue"}
|
||||||
|
size="md"
|
||||||
|
className="overflow-visible"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white">{step.stepName}</h4>
|
||||||
|
{step.isHumanInLoop && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20">
|
||||||
|
<AlertCircle className="w-3 h-3" aria-hidden="true" />
|
||||||
|
Human-in-Loop
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{step.timestamp}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapse toggle - only show if collapsible */}
|
||||||
|
{step.collapsible && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"px-2 transition-colors",
|
||||||
|
step.isHumanInLoop
|
||||||
|
? "text-orange-500 hover:text-orange-600 dark:hover:text-orange-400"
|
||||||
|
: "text-cyan-500 hover:text-cyan-600 dark:hover:text-cyan-400",
|
||||||
|
)}
|
||||||
|
aria-label={isExpanded ? "Collapse step" : "Expand step"}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - collapsible with animation */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{(isExpanded || !step.collapsible) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
height: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: [0.04, 0.62, 0.23, 0.98],
|
||||||
|
},
|
||||||
|
opacity: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeInOut",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -20 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: -20 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{/* Output content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-lg border",
|
||||||
|
step.isHumanInLoop
|
||||||
|
? "bg-orange-50/50 dark:bg-orange-950/10 border-orange-200/50 dark:border-orange-800/30"
|
||||||
|
: "bg-cyan-50/30 dark:bg-cyan-950/10 border-cyan-200/50 dark:border-cyan-800/30",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<pre className="text-xs font-mono text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||||
|
{step.output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-mono",
|
||||||
|
step.isHumanInLoop ? "text-orange-600 dark:text-orange-400" : "text-cyan-600 dark:text-cyan-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.session}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Review and Approve Plan - only for human-in-loop steps with documents */}
|
||||||
|
{step.isHumanInLoop && document && (
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">Review and Approve Plan</h4>
|
||||||
|
|
||||||
|
{/* Document Card */}
|
||||||
|
<Card blur="md" transparency="light" size="md" className="overflow-visible">
|
||||||
|
{/* View/Edit toggle in top right */}
|
||||||
|
<div className="flex items-center justify-end mb-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleToggleEdit}
|
||||||
|
className="text-gray-600 dark:text-gray-400 hover:bg-gray-500/10"
|
||||||
|
aria-label={isEditingDocument ? "Switch to preview mode" : "Switch to edit mode"}
|
||||||
|
>
|
||||||
|
{isEditingDocument ? (
|
||||||
|
<Eye className="w-4 h-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Edit3 className="w-4 h-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingDocument ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<textarea
|
||||||
|
value={editedContent}
|
||||||
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"w-full min-h-[300px] p-4 rounded-lg",
|
||||||
|
"bg-white/50 dark:bg-black/30",
|
||||||
|
"border border-gray-300 dark:border-gray-700",
|
||||||
|
"text-gray-900 dark:text-white font-mono text-sm",
|
||||||
|
"focus:outline-none focus:border-orange-400 focus:ring-2 focus:ring-orange-400/20",
|
||||||
|
"resize-y",
|
||||||
|
)}
|
||||||
|
placeholder="Enter markdown content..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
h1: ({ node, ...props }) => (
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-3 mt-4" {...props} />
|
||||||
|
),
|
||||||
|
h2: ({ node, ...props }) => (
|
||||||
|
<h2
|
||||||
|
className="text-lg font-semibold text-gray-900 dark:text-white mb-2 mt-3"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
h3: ({ node, ...props }) => (
|
||||||
|
<h3
|
||||||
|
className="text-base font-semibold text-gray-900 dark:text-white mb-2 mt-3"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
p: ({ node, ...props }) => (
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2 leading-relaxed" {...props} />
|
||||||
|
),
|
||||||
|
ul: ({ node, ...props }) => (
|
||||||
|
<ul
|
||||||
|
className="list-disc list-inside text-sm text-gray-700 dark:text-gray-300 mb-2 space-y-1"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
li: ({ node, ...props }) => <li className="ml-4" {...props} />,
|
||||||
|
code: ({ node, ...props }) => (
|
||||||
|
<code
|
||||||
|
className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono text-orange-600 dark:text-orange-400"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{document.content.markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Approve button - always visible with glass styling */}
|
||||||
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-200/50 dark:border-gray-700/30">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{hasChanges ? "Unsaved changes" : "No changes"}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleApproveAndContinue}
|
||||||
|
className={cn(
|
||||||
|
"backdrop-blur-md",
|
||||||
|
"bg-gradient-to-b from-green-100/80 to-white/60",
|
||||||
|
"dark:from-green-500/20 dark:to-green-500/10",
|
||||||
|
"text-green-700 dark:text-green-100",
|
||||||
|
"border border-green-300/50 dark:border-green-500/50",
|
||||||
|
"hover:from-green-200/90 hover:to-green-100/70",
|
||||||
|
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
|
||||||
|
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
|
||||||
|
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
|
||||||
|
"shadow-lg shadow-green-500/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
|
Approve and Move to Next Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type React from "react";
|
||||||
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
|
||||||
|
interface WorkflowStepButtonProps {
|
||||||
|
isCompleted: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
stepName: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
color?: "cyan" | "green" | "blue" | "purple";
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get color hex values for animations
|
||||||
|
const getColorValue = (color: string) => {
|
||||||
|
const colorValues = {
|
||||||
|
purple: "rgb(168,85,247)",
|
||||||
|
green: "rgb(34,197,94)",
|
||||||
|
blue: "rgb(59,130,246)",
|
||||||
|
cyan: "rgb(34,211,238)",
|
||||||
|
};
|
||||||
|
return colorValues[color as keyof typeof colorValues] || colorValues.blue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkflowStepButton: React.FC<WorkflowStepButtonProps> = ({
|
||||||
|
isCompleted,
|
||||||
|
isActive,
|
||||||
|
stepName,
|
||||||
|
onClick,
|
||||||
|
color = "cyan",
|
||||||
|
size = 40,
|
||||||
|
}) => {
|
||||||
|
const colorMap = {
|
||||||
|
purple: {
|
||||||
|
border: "border-purple-400 dark:border-purple-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(168,85,247,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(168,85,247,1)]",
|
||||||
|
fill: "bg-purple-400 dark:bg-purple-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(168,85,247,0.8)]",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
border: "border-green-400 dark:border-green-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(34,197,94,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(34,197,94,1)]",
|
||||||
|
fill: "bg-green-400 dark:bg-green-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(34,197,94,0.8)]",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
border: "border-blue-400 dark:border-blue-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(59,130,246,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(59,130,246,1)]",
|
||||||
|
fill: "bg-blue-400 dark:bg-blue-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(59,130,246,0.8)]",
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
border: "border-cyan-400 dark:border-cyan-300",
|
||||||
|
glow: "shadow-[0_0_15px_rgba(34,211,238,0.8)]",
|
||||||
|
glowHover: "hover:shadow-[0_0_25px_rgba(34,211,238,1)]",
|
||||||
|
fill: "bg-cyan-400 dark:bg-cyan-300",
|
||||||
|
innerGlow: "shadow-[inset_0_0_10px_rgba(34,211,238,0.8)]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = colorMap[color];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<motion.button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-full border-2 transition-all duration-300",
|
||||||
|
styles.border,
|
||||||
|
isCompleted ? styles.glow : "shadow-[0_0_5px_rgba(0,0,0,0.3)]",
|
||||||
|
styles.glowHover,
|
||||||
|
"bg-gradient-to-b from-gray-900 to-black dark:from-gray-800 dark:to-gray-900",
|
||||||
|
"hover:scale-110 active:scale-95",
|
||||||
|
)}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
type="button"
|
||||||
|
aria-label={`${stepName} - ${isCompleted ? "completed" : isActive ? "in progress" : "pending"}`}
|
||||||
|
>
|
||||||
|
{/* Outer ring glow effect */}
|
||||||
|
<motion.div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-[-4px] rounded-full border-2 blur-sm",
|
||||||
|
isCompleted ? styles.border : "border-transparent",
|
||||||
|
)}
|
||||||
|
animate={{
|
||||||
|
opacity: isCompleted ? [0.3, 0.6, 0.3] : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Inner glow effect */}
|
||||||
|
<motion.div
|
||||||
|
className={cn("absolute inset-[2px] rounded-full blur-md opacity-20", isCompleted && styles.fill)}
|
||||||
|
animate={{
|
||||||
|
opacity: isCompleted ? [0.1, 0.3, 0.1] : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Checkmark icon container */}
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
|
<motion.svg
|
||||||
|
width={size * 0.5}
|
||||||
|
height={size * 0.5}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="relative z-10"
|
||||||
|
role="img"
|
||||||
|
aria-label={`${stepName} status indicator`}
|
||||||
|
animate={{
|
||||||
|
filter: isCompleted
|
||||||
|
? [
|
||||||
|
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
|
||||||
|
`drop-shadow(0 0 12px ${getColorValue(color)}) drop-shadow(0 0 16px ${getColorValue(color)})`,
|
||||||
|
`drop-shadow(0 0 8px ${getColorValue(color)}) drop-shadow(0 0 12px ${getColorValue(color)})`,
|
||||||
|
]
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Checkmark path */}
|
||||||
|
<path
|
||||||
|
d="M20 6L9 17l-5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={isCompleted ? "text-white" : "text-gray-600"}
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{/* Step name label */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium transition-colors",
|
||||||
|
isCompleted
|
||||||
|
? "text-cyan-400 dark:text-cyan-300"
|
||||||
|
: isActive
|
||||||
|
? "text-blue-500 dark:text-blue-400"
|
||||||
|
: "text-gray-500 dark:text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stepName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/features/ui/primitives/button";
|
||||||
import { cn } from "@/features/ui/primitives/styles";
|
import { cn } from "@/features/ui/primitives/styles";
|
||||||
|
|
||||||
export interface SideNavigationSection {
|
export interface SideNavigationSection {
|
||||||
@@ -14,9 +17,23 @@ interface SideNavigationProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SideNavigation = ({ sections, activeSection, onSectionClick }: SideNavigationProps) => {
|
export const SideNavigation = ({ sections, activeSection, onSectionClick }: SideNavigationProps) => {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-32 flex-shrink-0">
|
<div className={cn("flex-shrink-0 transition-all duration-300", isCollapsed ? "w-12" : "w-32")}>
|
||||||
<div className="sticky top-4 space-y-0.5">
|
<div className="sticky top-4 space-y-0.5">
|
||||||
|
{/* Collapse/Expand button */}
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
|
className="px-2 py-1 h-auto text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
aria-label={isCollapsed ? "Expand navigation" : "Collapse navigation"}
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
const isActive = activeSection === section.id;
|
const isActive = activeSection === section.id;
|
||||||
return (
|
return (
|
||||||
@@ -24,16 +41,18 @@ export const SideNavigation = ({ sections, activeSection, onSectionClick }: Side
|
|||||||
key={section.id}
|
key={section.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSectionClick(section.id)}
|
onClick={() => onSectionClick(section.id)}
|
||||||
|
title={isCollapsed ? section.label : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left px-2 py-1.5 rounded-md transition-all duration-200",
|
"w-full text-left px-2 py-1.5 rounded-md transition-all duration-200",
|
||||||
"flex items-center gap-1.5",
|
"flex items-center gap-1.5",
|
||||||
isActive
|
isActive
|
||||||
? "bg-blue-500/10 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300 border-l-2 border-blue-500"
|
? "bg-blue-500/10 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300 border-l-2 border-blue-500"
|
||||||
: "text-gray-600 dark:text-gray-400 hover:bg-white/5 dark:hover:bg-white/5 border-l-2 border-transparent",
|
: "text-gray-600 dark:text-gray-400 hover:bg-white/5 dark:hover:bg-white/5 border-l-2 border-transparent",
|
||||||
|
isCollapsed && "justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.icon && <span className="flex-shrink-0 w-3 h-3">{section.icon}</span>}
|
{section.icon && <span className="flex-shrink-0 w-3 h-3">{section.icon}</span>}
|
||||||
<span className="text-xs font-medium truncate">{section.label}</span>
|
{!isCollapsed && <span className="text-xs font-medium truncate">{section.label}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Database, FileText, FolderKanban, Navigation, Settings } from "lucide-react";
|
import { Briefcase, Database, FileText, FolderKanban, Navigation, Settings } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { AgentWorkOrderLayoutExample } from "../layouts/AgentWorkOrderLayoutExample";
|
||||||
import { DocumentBrowserExample } from "../layouts/DocumentBrowserExample";
|
import { DocumentBrowserExample } from "../layouts/DocumentBrowserExample";
|
||||||
import { KnowledgeLayoutExample } from "../layouts/KnowledgeLayoutExample";
|
import { KnowledgeLayoutExample } from "../layouts/KnowledgeLayoutExample";
|
||||||
import { NavigationExplanation } from "../layouts/NavigationExplanation";
|
import { NavigationExplanation } from "../layouts/NavigationExplanation";
|
||||||
@@ -16,6 +17,7 @@ export const LayoutsTab = () => {
|
|||||||
{ id: "settings", label: "Settings", icon: <Settings className="w-4 h-4" /> },
|
{ id: "settings", label: "Settings", icon: <Settings className="w-4 h-4" /> },
|
||||||
{ id: "knowledge", label: "Knowledge", icon: <Database className="w-4 h-4" /> },
|
{ id: "knowledge", label: "Knowledge", icon: <Database className="w-4 h-4" /> },
|
||||||
{ id: "document-browser", label: "Document Browser", icon: <FileText className="w-4 h-4" /> },
|
{ id: "document-browser", label: "Document Browser", icon: <FileText className="w-4 h-4" /> },
|
||||||
|
{ id: "agent-work-orders", label: "Agent Work Orders", icon: <Briefcase className="w-4 h-4" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Render content based on active section
|
// Render content based on active section
|
||||||
@@ -68,6 +70,16 @@ export const LayoutsTab = () => {
|
|||||||
<DocumentBrowserExample />
|
<DocumentBrowserExample />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case "agent-work-orders":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Agent Work Orders Layout</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Repository-based work order management with table view, status tracking, and integrated detail view.
|
||||||
|
</p>
|
||||||
|
<AgentWorkOrderLayoutExample />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from "react";
|
|||||||
import { cn } from "./styles";
|
import { cn } from "./styles";
|
||||||
|
|
||||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: "default" | "destructive" | "outline" | "ghost" | "link" | "cyan" | "knowledge"; // Tron-style purple button used on Knowledge Base
|
variant?: "default" | "destructive" | "outline" | "ghost" | "link" | "cyan" | "knowledge" | "green" | "blue"; // Tron-style glass buttons
|
||||||
size?: "default" | "sm" | "lg" | "icon" | "xs";
|
size?: "default" | "sm" | "lg" | "icon" | "xs";
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -88,6 +88,30 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
"dark:hover:shadow-[0_0_25px_rgba(168,85,247,0.7)]",
|
"dark:hover:shadow-[0_0_25px_rgba(168,85,247,0.7)]",
|
||||||
"focus-visible:ring-purple-500",
|
"focus-visible:ring-purple-500",
|
||||||
),
|
),
|
||||||
|
green: cn(
|
||||||
|
"backdrop-blur-md",
|
||||||
|
"bg-gradient-to-b from-green-100/80 to-white/60",
|
||||||
|
"dark:from-green-500/20 dark:to-green-500/10",
|
||||||
|
"text-green-700 dark:text-green-100",
|
||||||
|
"border border-green-300/50 dark:border-green-500/50",
|
||||||
|
"hover:from-green-200/90 hover:to-green-100/70",
|
||||||
|
"dark:hover:from-green-400/30 dark:hover:to-green-500/20",
|
||||||
|
"hover:shadow-[0_0_20px_rgba(34,197,94,0.5)]",
|
||||||
|
"dark:hover:shadow-[0_0_25px_rgba(34,197,94,0.7)]",
|
||||||
|
"focus-visible:ring-green-500",
|
||||||
|
),
|
||||||
|
blue: cn(
|
||||||
|
"backdrop-blur-md",
|
||||||
|
"bg-gradient-to-b from-blue-100/80 to-white/60",
|
||||||
|
"dark:from-blue-500/20 dark:to-blue-500/10",
|
||||||
|
"text-blue-700 dark:text-blue-100",
|
||||||
|
"border border-blue-300/50 dark:border-blue-500/50",
|
||||||
|
"hover:from-blue-200/90 hover:to-blue-100/70",
|
||||||
|
"dark:hover:from-blue-400/30 dark:hover:to-blue-500/20",
|
||||||
|
"hover:shadow-[0_0_20px_rgba(59,130,246,0.5)]",
|
||||||
|
"dark:hover:shadow-[0_0_25px_rgba(59,130,246,0.7)]",
|
||||||
|
"focus-visible:ring-blue-500",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
type ButtonSize = NonNullable<ButtonProps["size"]>;
|
type ButtonSize = NonNullable<ButtonProps["size"]>;
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const ComboBox = React.forwardRef<HTMLButtonElement, ComboBoxProps>(
|
|||||||
const highlightedElement = optionsRef.current.querySelector('[data-highlighted="true"]');
|
const highlightedElement = optionsRef.current.querySelector('[data-highlighted="true"]');
|
||||||
highlightedElement?.scrollIntoView({ block: "nearest" });
|
highlightedElement?.scrollIntoView({ block: "nearest" });
|
||||||
}
|
}
|
||||||
}, [highlightedIndex, open]);
|
}, [open, highlightedIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||||
|
|||||||
12
archon-ui-main/src/pages/AgentWorkOrderDetailPage.tsx
Normal file
12
archon-ui-main/src/pages/AgentWorkOrderDetailPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Agent Work Order 2 Detail Page
|
||||||
|
*
|
||||||
|
* Page wrapper for the redesigned agent work order detail view.
|
||||||
|
* Routes to this page from /agent-work-orders2/:id
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AgentWorkOrderDetailView } from "../features/agent-work-orders/views/AgentWorkOrderDetailView";
|
||||||
|
|
||||||
|
export function AgentWorkOrderDetailPage() {
|
||||||
|
return <AgentWorkOrderDetailView />;
|
||||||
|
}
|
||||||
12
archon-ui-main/src/pages/AgentWorkOrdersPage.tsx
Normal file
12
archon-ui-main/src/pages/AgentWorkOrdersPage.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Agent Work Orders Page
|
||||||
|
*
|
||||||
|
* Page wrapper for the agent work orders interface.
|
||||||
|
* Routes to this page from /agent-work-orders
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AgentWorkOrdersView } from "../features/agent-work-orders/views/AgentWorkOrdersView";
|
||||||
|
|
||||||
|
export function AgentWorkOrdersPage() {
|
||||||
|
return <AgentWorkOrdersView />;
|
||||||
|
}
|
||||||
@@ -294,35 +294,105 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
|||||||
: [];
|
: [];
|
||||||
return [...new Set([...defaultHosts, ...hostFromEnv, ...customHosts])];
|
return [...new Set([...defaultHosts, ...hostFromEnv, ...customHosts])];
|
||||||
})(),
|
})(),
|
||||||
proxy: {
|
proxy: (() => {
|
||||||
'/api': {
|
const proxyConfig: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Check if agent work orders service should be enabled
|
||||||
|
// This can be disabled via environment variable to prevent hard dependency
|
||||||
|
const agentWorkOrdersEnabled = env.AGENT_WORK_ORDERS_ENABLED !== 'false';
|
||||||
|
const agentWorkOrdersPort = env.AGENT_WORK_ORDERS_PORT || '8053';
|
||||||
|
|
||||||
|
// Agent Work Orders API proxy (must come before general /api if enabled)
|
||||||
|
if (agentWorkOrdersEnabled) {
|
||||||
|
proxyConfig['/api/agent-work-orders'] = {
|
||||||
|
target: isDocker ? `http://archon-agent-work-orders:${agentWorkOrdersPort}` : `http://localhost:${agentWorkOrdersPort}`,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
timeout: 10000, // 10 second timeout
|
||||||
|
configure: (proxy: any, options: any) => {
|
||||||
|
const targetUrl = isDocker ? `http://archon-agent-work-orders:${agentWorkOrdersPort}` : `http://localhost:${agentWorkOrdersPort}`;
|
||||||
|
|
||||||
|
// Handle proxy errors (e.g., service is down)
|
||||||
|
proxy.on('error', (err: Error, req: any, res: any) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Send proper error response instead of hanging
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(503, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Service-Unavailable': 'agent-work-orders'
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: 'Service Unavailable',
|
||||||
|
message: 'Agent Work Orders service is not available',
|
||||||
|
service: 'agent-work-orders',
|
||||||
|
target: targetUrl
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle connection timeout
|
||||||
|
proxy.on('proxyReq', (proxyReq: any, req: any, res: any) => {
|
||||||
|
console.log('🔄 [VITE PROXY - Agent Work Orders] Forwarding:', req.method, req.url, 'to', `${targetUrl}${req.url}`);
|
||||||
|
|
||||||
|
// Set timeout for the proxy request
|
||||||
|
proxyReq.setTimeout(10000, () => {
|
||||||
|
console.log('⏱️ [VITE PROXY - Agent Work Orders] Request timeout');
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(504, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Service-Unavailable': 'agent-work-orders'
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
error: 'Gateway Timeout',
|
||||||
|
message: 'Agent Work Orders service did not respond in time',
|
||||||
|
service: 'agent-work-orders',
|
||||||
|
target: targetUrl
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [VITE PROXY] Agent Work Orders proxy disabled via AGENT_WORK_ORDERS_ENABLED=false');
|
||||||
|
}
|
||||||
|
|
||||||
|
// General /api proxy (always enabled, comes after specific routes if agent work orders is enabled)
|
||||||
|
proxyConfig['/api'] = {
|
||||||
target: `http://${proxyHost}:${port}`,
|
target: `http://${proxyHost}:${port}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
configure: (proxy, options) => {
|
configure: (proxy: any, options: any) => {
|
||||||
proxy.on('error', (err, req, res) => {
|
proxy.on('error', (err: Error, req: any, res: any) => {
|
||||||
console.log('🚨 [VITE PROXY ERROR]:', err.message);
|
console.log('🚨 [VITE PROXY ERROR]:', err.message);
|
||||||
console.log('🚨 [VITE PROXY ERROR] Target:', `http://${proxyHost}:${port}`);
|
console.log('🚨 [VITE PROXY ERROR] Target:', `http://${proxyHost}:${port}`);
|
||||||
console.log('🚨 [VITE PROXY ERROR] Request:', req.url);
|
console.log('🚨 [VITE PROXY ERROR] Request:', req.url);
|
||||||
});
|
});
|
||||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
proxy.on('proxyReq', (proxyReq: any, req: any, res: any) => {
|
||||||
console.log('🔄 [VITE PROXY] Forwarding:', req.method, req.url, 'to', `http://${proxyHost}:${port}${req.url}`);
|
console.log('🔄 [VITE PROXY] Forwarding:', req.method, req.url, 'to', `http://${proxyHost}:${port}${req.url}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
|
|
||||||
// Health check endpoint proxy
|
// Health check endpoint proxy
|
||||||
'/health': {
|
proxyConfig['/health'] = {
|
||||||
target: `http://${host}:${port}`,
|
target: `http://${host}:${port}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false
|
secure: false
|
||||||
},
|
};
|
||||||
|
|
||||||
// Socket.IO specific proxy configuration
|
// Socket.IO specific proxy configuration
|
||||||
'/socket.io': {
|
proxyConfig['/socket.io'] = {
|
||||||
target: `http://${host}:${port}`,
|
target: `http://${host}:${port}`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true
|
ws: true
|
||||||
}
|
};
|
||||||
},
|
|
||||||
|
return proxyConfig;
|
||||||
|
})(),
|
||||||
},
|
},
|
||||||
define: {
|
define: {
|
||||||
// CRITICAL: Don't inject Docker internal hostname into the build
|
// CRITICAL: Don't inject Docker internal hostname into the build
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
- ARCHON_SERVER_PORT=${ARCHON_SERVER_PORT:-8181}
|
- ARCHON_SERVER_PORT=${ARCHON_SERVER_PORT:-8181}
|
||||||
- ARCHON_MCP_PORT=${ARCHON_MCP_PORT:-8051}
|
- ARCHON_MCP_PORT=${ARCHON_MCP_PORT:-8051}
|
||||||
- ARCHON_AGENTS_PORT=${ARCHON_AGENTS_PORT:-8052}
|
- ARCHON_AGENTS_PORT=${ARCHON_AGENTS_PORT:-8052}
|
||||||
|
- AGENT_WORK_ORDERS_PORT=${AGENT_WORK_ORDERS_PORT:-8053}
|
||||||
- AGENTS_ENABLED=${AGENTS_ENABLED:-false}
|
- AGENTS_ENABLED=${AGENTS_ENABLED:-false}
|
||||||
- ARCHON_HOST=${HOST:-localhost}
|
- ARCHON_HOST=${HOST:-localhost}
|
||||||
networks:
|
networks:
|
||||||
@@ -146,6 +147,58 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:-}
|
||||||
|
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
|
||||||
|
- 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
|
# Frontend
|
||||||
archon-frontend:
|
archon-frontend:
|
||||||
build: ./archon-ui-main
|
build: ./archon-ui-main
|
||||||
|
|||||||
135
migration/AGENT_WORK_ORDERS.md
Normal file
135
migration/AGENT_WORK_ORDERS.md
Normal 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)
|
||||||
233
migration/agent_work_orders_repositories.sql
Normal file
233
migration/agent_work_orders_repositories.sql
Normal 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
|
||||||
|
-- =====================================================
|
||||||
356
migration/agent_work_orders_state.sql
Normal file
356
migration/agent_work_orders_state.sql
Normal 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
|
||||||
|
-- =====================================================
|
||||||
81
python/.claude/commands/agent-work-orders/commit.md
Normal file
81
python/.claude/commands/agent-work-orders/commit.md
Normal 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
|
||||||
|
```
|
||||||
104
python/.claude/commands/agent-work-orders/create-branch.md
Normal file
104
python/.claude/commands/agent-work-orders/create-branch.md
Normal 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
|
||||||
|
```
|
||||||
201
python/.claude/commands/agent-work-orders/create-pr.md
Normal file
201
python/.claude/commands/agent-work-orders/create-pr.md
Normal 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
|
||||||
27
python/.claude/commands/agent-work-orders/execute.md
Normal file
27
python/.claude/commands/agent-work-orders/execute.md
Normal 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)
|
||||||
176
python/.claude/commands/agent-work-orders/noqa.md
Normal file
176
python/.claude/commands/agent-work-orders/noqa.md
Normal 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)
|
||||||
|
```
|
||||||
176
python/.claude/commands/agent-work-orders/planning.md
Normal file
176
python/.claude/commands/agent-work-orders/planning.md
Normal 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. include 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`)
|
||||||
28
python/.claude/commands/agent-work-orders/prime.md
Normal file
28
python/.claude/commands/agent-work-orders/prime.md
Normal 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.
|
||||||
120
python/.claude/commands/agent-work-orders/prp-review.md
Normal file
120
python/.claude/commands/agent-work-orders/prp-review.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Review and Fix
|
||||||
|
|
||||||
|
Review implemented work against a PRP specification, identify issues, and automatically fix blocker/major problems before committing.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix Issues
|
||||||
|
|
||||||
|
After generating the review report, automatically fix blocker and major issues:
|
||||||
|
|
||||||
|
**Parse the Report:**
|
||||||
|
- Read the generated `PRPs/reports/report-#.json` file
|
||||||
|
- Extract all issues with severity "blocker" or "major"
|
||||||
|
|
||||||
|
**Apply Fixes:**
|
||||||
|
|
||||||
|
For each blocker/major issue:
|
||||||
|
1. Read the file mentioned in `file_path`
|
||||||
|
2. Apply the fix described in `issue_resolution`
|
||||||
|
3. Log what was fixed
|
||||||
|
|
||||||
|
**Re-validate:**
|
||||||
|
- Rerun linters: `uv run ruff check src/ --fix`
|
||||||
|
- Rerun type checker: `uv run mypy src/`
|
||||||
|
- Rerun tests: `uv run pytest tests/ -v`
|
||||||
|
|
||||||
|
**Report Results:**
|
||||||
|
- If all blockers fixed and validation passes → Output "✅ All critical issues fixed, validation passing"
|
||||||
|
- If fixes failed or validation still failing → Output "⚠️ Some issues remain" with details
|
||||||
|
- Minor issues can be left for manual review later
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
- Only fix blocker/major issues automatically
|
||||||
|
- Minor issues should be left in the report for human review
|
||||||
|
- If a fix might break something, skip it and note in output
|
||||||
|
- Run validation after ALL fixes applied, not after each individual fix
|
||||||
33
python/.claude/commands/agent-work-orders/start-server.md
Normal file
33
python/.claude/commands/agent-work-orders/start-server.md
Normal 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"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user