mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-21 21:03:08 +00:00
Compare commits
30 Commits
76dd4b8d80
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca1f6a5e2 | ||
|
|
b15f45c094 | ||
|
|
f999e1937d | ||
|
|
8b2251331d | ||
|
|
7f875c3bbd | ||
|
|
e26ca3761b | ||
|
|
5d3c04a3c7 | ||
|
|
df23a978cb | ||
|
|
41c1a14ae3 | ||
|
|
472064c3da | ||
|
|
afc2f4ac3c | ||
|
|
dceb535ade | ||
|
|
4f102e7bc2 | ||
|
|
9af0f309b7 | ||
|
|
49442f0d43 | ||
|
|
f786879908 | ||
|
|
dcdd06e02e | ||
|
|
b7aef15c3b | ||
|
|
d65fa0ca56 | ||
|
|
d712e58ff5 | ||
|
|
69d9313c07 | ||
|
|
a434767b41 | ||
|
|
090dcf977b | ||
|
|
ca5fc48443 | ||
|
|
d846a021b8 | ||
|
|
819ebcd112 | ||
|
|
f4636fdfd5 | ||
|
|
c114248b09 | ||
|
|
656df0fd9a | ||
|
|
9721368188 |
@@ -12,7 +12,13 @@ Pull request(s): $ARGUMENTS
|
|||||||
1. **Retrieve PR Details**
|
1. **Retrieve PR Details**
|
||||||
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
|
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
|
||||||
|
|
||||||
2. **Assess PR Complexity**
|
2. **Check for Merge Conflicts**
|
||||||
|
- After retrieving PR details, check whether the PR has merge conflicts against the target branch
|
||||||
|
- Use `gh pr view <number> --json mergeable,mergeStateStatus` or attempt a local merge check with `git merge-tree`
|
||||||
|
- If conflicts exist, note the conflicting files — these must be resolved on the PR branch before merging
|
||||||
|
- Surface conflicts early so they inform the rest of the review (don't discover them as a surprise at merge time)
|
||||||
|
|
||||||
|
3. **Assess PR Complexity**
|
||||||
|
|
||||||
After retrieving PR details, assess complexity based on:
|
After retrieving PR details, assess complexity based on:
|
||||||
- Number of files changed
|
- Number of files changed
|
||||||
@@ -34,13 +40,13 @@ Pull request(s): $ARGUMENTS
|
|||||||
- >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
|
- >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
|
||||||
- Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
|
- Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
|
||||||
|
|
||||||
3. **Analyze Codebase Impact**
|
4. **Analyze Codebase Impact**
|
||||||
- Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
|
- Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
|
||||||
- For Simple PRs: analyze directly without spawning agents
|
- For Simple PRs: analyze directly without spawning agents
|
||||||
- For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
|
- For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
|
||||||
- For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
|
- For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
|
||||||
|
|
||||||
4. **PR Scope & Title Alignment Check**
|
5. **PR Scope & Title Alignment Check**
|
||||||
- Compare the PR title and description against the actual diff content
|
- Compare the PR title and description against the actual diff content
|
||||||
- Check whether the PR is focused on a single coherent change or contains multiple unrelated changes
|
- Check whether the PR is focused on a single coherent change or contains multiple unrelated changes
|
||||||
- If the title/description describe one thing but the PR contains significantly more (e.g., title says "fix typo in README" but the diff touches 20 files across multiple domains), flag this as a **scope mismatch**
|
- If the title/description describe one thing but the PR contains significantly more (e.g., title says "fix typo in README" but the diff touches 20 files across multiple domains), flag this as a **scope mismatch**
|
||||||
@@ -48,28 +54,53 @@ Pull request(s): $ARGUMENTS
|
|||||||
- Suggest specific ways to split the PR (e.g., "separate the refactor from the feature addition")
|
- Suggest specific ways to split the PR (e.g., "separate the refactor from the feature addition")
|
||||||
- Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
|
- Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
|
||||||
|
|
||||||
5. **Vision Alignment Check**
|
6. **Vision Alignment Check**
|
||||||
- Read the project's README.md and CLAUDE.md to understand the application's core purpose
|
- **VISION.md protection**: First, check whether the PR diff modifies `VISION.md` in any way (edits, deletions, renames). If it does, **stop the review immediately** — verdict is **DON'T MERGE**. VISION.md is immutable and no PR is permitted to alter it. Explain this to the user and skip all remaining steps.
|
||||||
- Assess whether this PR aligns with the application's intended functionality
|
- Read the project's `VISION.md`, `README.md`, and `CLAUDE.md` to understand the application's core purpose and mandatory architectural constraints
|
||||||
- If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review
|
- Assess whether this PR aligns with the vision defined in `VISION.md`
|
||||||
- This is not a blocker, but should be flagged for the reviewer's consideration
|
- **Vision deviation is a merge blocker.** If the PR introduces functionality, integrations, or architectural changes that conflict with `VISION.md`, the verdict must be **DON'T MERGE**. This is not negotiable — the vision document takes precedence over any PR rationale.
|
||||||
|
|
||||||
6. **Safety Assessment**
|
7. **Safety Assessment**
|
||||||
- Provide a review on whether the PR is safe to merge as-is
|
- Provide a review on whether the PR is safe to merge as-is
|
||||||
- Provide any feedback in terms of risk level
|
- Provide any feedback in terms of risk level
|
||||||
|
|
||||||
7. **Improvements**
|
8. **Improvements**
|
||||||
- Propose any improvements in terms of importance and complexity
|
- Propose any improvements in terms of importance and complexity
|
||||||
|
|
||||||
8. **Merge Recommendation**
|
9. **Merge Recommendation**
|
||||||
- Based on all findings, provide a clear merge/don't-merge recommendation
|
- Based on all findings (including merge conflict status from step 2), provide a clear recommendation
|
||||||
- If all concerns are minor (cosmetic issues, naming suggestions, small style nits, missing comments, etc.), recommend **merging the PR** and note that the reviewer can address these minor concerns themselves with a quick follow-up commit pushed directly to master
|
- **If no concerns and no conflicts**: recommend merging as-is
|
||||||
- If there are significant concerns (bugs, security issues, architectural problems, scope mismatch), recommend **not merging** and explain what needs to be resolved first
|
- **If concerns are minor/fixable and/or merge conflicts exist**: recommend fixing on the PR branch first, then merging. Never merge a PR with known issues to main — always fix on the PR branch first
|
||||||
|
- **If there are significant concerns** (bugs, security issues, architectural problems, scope mismatch) that require author input or are too risky to fix: recommend **not merging** and explain what needs to be resolved
|
||||||
|
|
||||||
9. **TLDR**
|
10. **TLDR**
|
||||||
- End the review with a `## TLDR` section
|
- End the review with a `## TLDR` section
|
||||||
- In 3-5 bullet points maximum, summarize:
|
- In 3-5 bullet points maximum, summarize:
|
||||||
- What this PR is actually about (one sentence)
|
- What this PR is actually about (one sentence)
|
||||||
- The key concerns, if any (or "no significant concerns")
|
- Merge conflict status (clean or conflicting files)
|
||||||
- **Verdict: MERGE** / **MERGE (with minor follow-up)** / **DON'T MERGE** with a one-line reason
|
- The key concerns, if any (or "no significant concerns")
|
||||||
- This section should be scannable in under 10 seconds
|
- **Verdict: MERGE** / **MERGE (after fixes)** / **DON'T MERGE** with a one-line reason
|
||||||
|
- This section should be scannable in under 10 seconds
|
||||||
|
|
||||||
|
Verdict definitions:
|
||||||
|
- **MERGE** — no issues, clean to merge as-is
|
||||||
|
- **MERGE (after fixes)** — minor issues and/or conflicts exist, but can be resolved on the PR branch first, then merged
|
||||||
|
- **DON'T MERGE** — needs author attention, too complex or risky to fix without their input
|
||||||
|
|
||||||
|
11. **Post-Review Action**
|
||||||
|
- Immediately after the TLDR, provide a `## Recommended Action` section
|
||||||
|
- Based on the verdict, recommend one of the following actions:
|
||||||
|
|
||||||
|
**If verdict is MERGE (no concerns):**
|
||||||
|
- Merge as-is. No further action needed.
|
||||||
|
|
||||||
|
**If verdict is MERGE (after fixes):**
|
||||||
|
- List the specific changes that need to be made (fixes, conflict resolutions, etc.)
|
||||||
|
- Offer to: check out the PR branch, resolve any merge conflicts, apply the minor fixes identified during review, push the updated branch, then merge the now-clean PR
|
||||||
|
- Ask the user: *"Should I check out the PR branch, apply these fixes, and then merge?"*
|
||||||
|
- **Never merge first and fix on main later** — always fix on the PR branch before merging
|
||||||
|
|
||||||
|
**If verdict is DON'T MERGE:**
|
||||||
|
- If the issues are contained and you are confident you can fix them: offer the same workflow as "MERGE (after fixes)" — check out the PR branch, apply fixes, push, then merge
|
||||||
|
- If the issues are too complex, risky, or require author input (e.g., design decisions, major refactors, unclear intent): recommend sending the PR back to the author with specific feedback on what needs to change
|
||||||
|
- Be honest about your confidence level — if you're unsure whether you can address the concerns correctly, say so and defer to the author
|
||||||
18
.claude/launch.json
Normal file
18
.claude/launch.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"runtimeExecutable": "python",
|
||||||
|
"runtimeArgs": ["-m", "uvicorn", "server.main:app", "--host", "127.0.0.1", "--port", "8888", "--reload"],
|
||||||
|
"port": 8888
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"runtimeExecutable": "cmd",
|
||||||
|
"runtimeArgs": ["/c", "cd ui && npx vite"],
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"autoVerify": true
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ python autonomous_agent_demo.py --project-dir my-app --yolo
|
|||||||
# Parallel mode: run multiple agents concurrently (1-5 agents)
|
# Parallel mode: run multiple agents concurrently (1-5 agents)
|
||||||
python autonomous_agent_demo.py --project-dir my-app --parallel --max-concurrency 3
|
python autonomous_agent_demo.py --project-dir my-app --parallel --max-concurrency 3
|
||||||
|
|
||||||
# Batch mode: implement multiple features per agent session (1-3)
|
# Batch mode: implement multiple features per agent session (1-15)
|
||||||
python autonomous_agent_demo.py --project-dir my-app --batch-size 3
|
python autonomous_agent_demo.py --project-dir my-app --batch-size 3
|
||||||
|
|
||||||
# Batch specific features by ID
|
# Batch specific features by ID
|
||||||
@@ -496,9 +496,9 @@ The orchestrator enforces strict bounds on concurrent processes:
|
|||||||
|
|
||||||
### Multi-Feature Batching
|
### Multi-Feature Batching
|
||||||
|
|
||||||
Agents can implement multiple features per session using `--batch-size` (1-3, default: 3):
|
Agents can implement multiple features per session using `--batch-size` (1-15, default: 3):
|
||||||
- `--batch-size N` - Max features per coding agent batch
|
- `--batch-size N` - Max features per coding agent batch
|
||||||
- `--testing-batch-size N` - Features per testing batch (1-5, default: 3)
|
- `--testing-batch-size N` - Features per testing batch (1-15, default: 3)
|
||||||
- `--batch-features 1,2,3` - Specific feature IDs for batch implementation
|
- `--batch-features 1,2,3` - Specific feature IDs for batch implementation
|
||||||
- `--testing-batch-features 1,2,3` - Specific feature IDs for batch regression testing
|
- `--testing-batch-features 1,2,3` - Specific feature IDs for batch regression testing
|
||||||
- `prompts.py` provides `get_batch_feature_prompt()` for multi-feature prompt generation
|
- `prompts.py` provides `get_batch_feature_prompt()` for multi-feature prompt generation
|
||||||
|
|||||||
22
VISION.md
Normal file
22
VISION.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# VISION
|
||||||
|
|
||||||
|
This document defines the mandatory project vision for AutoForge. All contributions must align with these principles. PRs that deviate from this vision will be rejected. This file itself is immutable via PR — any PR that modifies VISION.md will be rejected outright.
|
||||||
|
|
||||||
|
## Claude Agent SDK Exclusivity
|
||||||
|
|
||||||
|
AutoForge is a wrapper around the **Claude Agent SDK**. This is a foundational architectural decision, not a preference.
|
||||||
|
|
||||||
|
**What this means:**
|
||||||
|
|
||||||
|
- AutoForge only supports providers, models, and integrations that work through the Claude Agent SDK.
|
||||||
|
- We will not integrate with, accommodate, or add support for other AI SDKs, CLIs, or coding agent platforms (e.g., Codex, OpenCode, Aider, Continue, Cursor agents, or similar tools).
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
|
||||||
|
Each platform has its own approach to MCP tools, skills, context management, and feature integration. Attempting to support multiple agent frameworks creates an unsustainable maintenance burden and dilutes the quality of the core experience. By committing to the Claude Agent SDK exclusively, we can build deep, reliable integration rather than shallow compatibility across many targets.
|
||||||
|
|
||||||
|
**In practice:**
|
||||||
|
|
||||||
|
- PRs adding support for non-Claude agent frameworks will be rejected.
|
||||||
|
- PRs introducing abstractions designed to make AutoForge "agent-agnostic" will be rejected.
|
||||||
|
- Alternative API providers (e.g., Vertex AI, AWS Bedrock) are acceptable only when accessed through the Claude Agent SDK's own configuration.
|
||||||
91
agent.py
91
agent.py
@@ -74,46 +74,65 @@ async def run_agent_session(
|
|||||||
await client.query(message)
|
await client.query(message)
|
||||||
|
|
||||||
# Collect response text and show tool use
|
# Collect response text and show tool use
|
||||||
|
# Retry receive_response() on MessageParseError — the SDK raises this for
|
||||||
|
# unknown CLI message types (e.g. "rate_limit_event") which kills the async
|
||||||
|
# generator. The subprocess is still alive so we restart to read remaining
|
||||||
|
# messages from the buffered channel.
|
||||||
response_text = ""
|
response_text = ""
|
||||||
async for msg in client.receive_response():
|
max_parse_retries = 50
|
||||||
msg_type = type(msg).__name__
|
parse_retries = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
# Handle AssistantMessage (text and tool use)
|
# Handle AssistantMessage (text and tool use)
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
response_text += block.text
|
response_text += block.text
|
||||||
print(block.text, end="", flush=True)
|
print(block.text, end="", flush=True)
|
||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
print(f"\n[Tool: {block.name}]", flush=True)
|
print(f"\n[Tool: {block.name}]", flush=True)
|
||||||
if hasattr(block, "input"):
|
if hasattr(block, "input"):
|
||||||
input_str = str(block.input)
|
input_str = str(block.input)
|
||||||
if len(input_str) > 200:
|
if len(input_str) > 200:
|
||||||
print(f" Input: {input_str[:200]}...", flush=True)
|
print(f" Input: {input_str[:200]}...", flush=True)
|
||||||
else:
|
else:
|
||||||
print(f" Input: {input_str}", flush=True)
|
print(f" Input: {input_str}", flush=True)
|
||||||
|
|
||||||
# Handle UserMessage (tool results)
|
# Handle UserMessage (tool results)
|
||||||
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "ToolResultBlock":
|
if block_type == "ToolResultBlock":
|
||||||
result_content = getattr(block, "content", "")
|
result_content = getattr(block, "content", "")
|
||||||
is_error = getattr(block, "is_error", False)
|
is_error = getattr(block, "is_error", False)
|
||||||
|
|
||||||
# Check if command was blocked by security hook
|
# Check if command was blocked by security hook
|
||||||
if "blocked" in str(result_content).lower():
|
if "blocked" in str(result_content).lower():
|
||||||
print(f" [BLOCKED] {result_content}", flush=True)
|
print(f" [BLOCKED] {result_content}", flush=True)
|
||||||
elif is_error:
|
elif is_error:
|
||||||
# Show errors (truncated)
|
# Show errors (truncated)
|
||||||
error_str = str(result_content)[:500]
|
error_str = str(result_content)[:500]
|
||||||
print(f" [Error] {error_str}", flush=True)
|
print(f" [Error] {error_str}", flush=True)
|
||||||
else:
|
else:
|
||||||
# Tool succeeded - just show brief confirmation
|
# Tool succeeded - just show brief confirmation
|
||||||
print(" [Done]", flush=True)
|
print(" [Done]", flush=True)
|
||||||
|
|
||||||
|
break # Normal completion
|
||||||
|
except Exception as inner_exc:
|
||||||
|
if type(inner_exc).__name__ == "MessageParseError":
|
||||||
|
parse_retries += 1
|
||||||
|
if parse_retries > max_parse_retries:
|
||||||
|
print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
|
||||||
|
break
|
||||||
|
print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
|
||||||
|
continue
|
||||||
|
raise # Re-raise to outer except
|
||||||
|
|
||||||
print("\n" + "-" * 70 + "\n")
|
print("\n" + "-" * 70 + "\n")
|
||||||
return "continue", response_text
|
return "continue", response_text
|
||||||
@@ -222,7 +241,7 @@ async def run_autonomous_agent(
|
|||||||
# Check if all features are already complete (before starting a new session)
|
# Check if all features are already complete (before starting a new session)
|
||||||
# Skip this check if running as initializer (needs to create features first)
|
# Skip this check if running as initializer (needs to create features first)
|
||||||
if not is_initializer and iteration == 1:
|
if not is_initializer and iteration == 1:
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
|
||||||
if total > 0 and passing == total:
|
if total > 0 and passing == total:
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" ALL FEATURES ALREADY COMPLETE!")
|
print(" ALL FEATURES ALREADY COMPLETE!")
|
||||||
@@ -348,7 +367,7 @@ async def run_autonomous_agent(
|
|||||||
print_progress_summary(project_dir)
|
print_progress_summary(project_dir)
|
||||||
|
|
||||||
# Check if all features are complete - exit gracefully if done
|
# Check if all features are complete - exit gracefully if done
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, _nhi = count_passing_tests(project_dir)
|
||||||
if total > 0 and passing == total:
|
if total > 0 and passing == total:
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" ALL FEATURES COMPLETE!")
|
print(" ALL FEATURES COMPLETE!")
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class Feature(Base):
|
|||||||
|
|
||||||
__tablename__ = "features"
|
__tablename__ = "features"
|
||||||
|
|
||||||
# Composite index for common status query pattern (passes, in_progress)
|
# Composite index for common status query pattern (passes, in_progress, needs_human_input)
|
||||||
# Used by feature_get_stats, get_ready_features, and other status queries
|
# Used by feature_get_stats, get_ready_features, and other status queries
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_feature_status', 'passes', 'in_progress'),
|
Index('ix_feature_status', 'passes', 'in_progress', 'needs_human_input'),
|
||||||
)
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
@@ -61,6 +61,11 @@ class Feature(Base):
|
|||||||
# NULL/empty = no dependencies (backwards compatible)
|
# NULL/empty = no dependencies (backwards compatible)
|
||||||
dependencies = Column(JSON, nullable=True, default=None)
|
dependencies = Column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
# Human input: agent can request structured input from a human
|
||||||
|
needs_human_input = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request
|
||||||
|
human_input_response = Column(JSON, nullable=True, default=None) # Human's response
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert feature to dictionary for JSON serialization."""
|
"""Convert feature to dictionary for JSON serialization."""
|
||||||
return {
|
return {
|
||||||
@@ -75,6 +80,10 @@ class Feature(Base):
|
|||||||
"in_progress": self.in_progress if self.in_progress is not None else False,
|
"in_progress": self.in_progress if self.in_progress is not None else False,
|
||||||
# Dependencies: NULL/empty treated as empty list for backwards compat
|
# Dependencies: NULL/empty treated as empty list for backwards compat
|
||||||
"dependencies": self.dependencies if self.dependencies else [],
|
"dependencies": self.dependencies if self.dependencies else [],
|
||||||
|
# Human input fields
|
||||||
|
"needs_human_input": self.needs_human_input if self.needs_human_input is not None else False,
|
||||||
|
"human_input_request": self.human_input_request,
|
||||||
|
"human_input_response": self.human_input_response,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_dependencies_safe(self) -> list[int]:
|
def get_dependencies_safe(self) -> list[int]:
|
||||||
@@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_human_input_columns(engine) -> None:
|
||||||
|
"""Add human input columns to existing databases that don't have them."""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(text("PRAGMA table_info(features)"))
|
||||||
|
columns = [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
if "needs_human_input" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN needs_human_input BOOLEAN DEFAULT 0"))
|
||||||
|
if "human_input_request" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_request TEXT DEFAULT NULL"))
|
||||||
|
if "human_input_response" not in columns:
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN human_input_response TEXT DEFAULT NULL"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_schedules_tables(engine) -> None:
|
def _migrate_add_schedules_tables(engine) -> None:
|
||||||
"""Create schedules and schedule_overrides tables if they don't exist."""
|
"""Create schedules and schedule_overrides tables if they don't exist."""
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
@@ -425,6 +449,7 @@ def create_database(project_dir: Path) -> tuple:
|
|||||||
_migrate_fix_null_boolean_fields(engine)
|
_migrate_fix_null_boolean_fields(engine)
|
||||||
_migrate_add_dependencies_column(engine)
|
_migrate_add_dependencies_column(engine)
|
||||||
_migrate_add_testing_columns(engine)
|
_migrate_add_testing_columns(engine)
|
||||||
|
_migrate_add_human_input_columns(engine)
|
||||||
|
|
||||||
# Migrate to add schedules tables
|
# Migrate to add schedules tables
|
||||||
_migrate_add_schedules_tables(engine)
|
_migrate_add_schedules_tables(engine)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ assistant.db-wal
|
|||||||
assistant.db-shm
|
assistant.db-shm
|
||||||
.agent.lock
|
.agent.lock
|
||||||
.devserver.lock
|
.devserver.lock
|
||||||
|
.pause_drain
|
||||||
.claude_settings.json
|
.claude_settings.json
|
||||||
.claude_assistant_settings.json
|
.claude_assistant_settings.json
|
||||||
.claude_settings.expand.*.json
|
.claude_settings.expand.*.json
|
||||||
@@ -146,6 +147,15 @@ def get_claude_assistant_settings_path(project_dir: Path) -> Path:
|
|||||||
return _resolve_path(project_dir, ".claude_assistant_settings.json")
|
return _resolve_path(project_dir, ".claude_assistant_settings.json")
|
||||||
|
|
||||||
|
|
||||||
|
def get_pause_drain_path(project_dir: Path) -> Path:
|
||||||
|
"""Return the path to the ``.pause_drain`` signal file.
|
||||||
|
|
||||||
|
This file is created to request a graceful pause (drain mode).
|
||||||
|
Always uses the new location since it's a transient signal file.
|
||||||
|
"""
|
||||||
|
return project_dir / ".autoforge" / ".pause_drain"
|
||||||
|
|
||||||
|
|
||||||
def get_progress_cache_path(project_dir: Path) -> Path:
|
def get_progress_cache_path(project_dir: Path) -> Path:
|
||||||
"""Resolve the path to ``.progress_cache``."""
|
"""Resolve the path to ``.progress_cache``."""
|
||||||
return _resolve_path(project_dir, ".progress_cache")
|
return _resolve_path(project_dir, ".progress_cache")
|
||||||
|
|||||||
@@ -176,14 +176,14 @@ Authentication:
|
|||||||
"--testing-batch-size",
|
"--testing-batch-size",
|
||||||
type=int,
|
type=int,
|
||||||
default=3,
|
default=3,
|
||||||
help="Number of features per testing batch (1-5, default: 3)",
|
help="Number of features per testing batch (1-15, default: 3)",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--batch-size",
|
"--batch-size",
|
||||||
type=int,
|
type=int,
|
||||||
default=3,
|
default=3,
|
||||||
help="Max features per coding agent batch (1-3, default: 3)",
|
help="Max features per coding agent batch (1-15, default: 3)",
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|||||||
@@ -151,17 +151,20 @@ def feature_get_stats() -> str:
|
|||||||
result = session.query(
|
result = session.query(
|
||||||
func.count(Feature.id).label('total'),
|
func.count(Feature.id).label('total'),
|
||||||
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
|
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
|
||||||
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress')
|
func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress'),
|
||||||
|
func.sum(case((Feature.needs_human_input == True, 1), else_=0)).label('needs_human_input')
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
total = result.total or 0
|
total = result.total or 0
|
||||||
passing = int(result.passing or 0)
|
passing = int(result.passing or 0)
|
||||||
in_progress = int(result.in_progress or 0)
|
in_progress = int(result.in_progress or 0)
|
||||||
|
needs_human_input = int(result.needs_human_input or 0)
|
||||||
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
|
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"passing": passing,
|
"passing": passing,
|
||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": percentage
|
"percentage": percentage
|
||||||
})
|
})
|
||||||
@@ -221,6 +224,7 @@ def feature_get_summary(
|
|||||||
"name": feature.name,
|
"name": feature.name,
|
||||||
"passes": feature.passes,
|
"passes": feature.passes,
|
||||||
"in_progress": feature.in_progress,
|
"in_progress": feature.in_progress,
|
||||||
|
"needs_human_input": feature.needs_human_input if feature.needs_human_input is not None else False,
|
||||||
"dependencies": feature.dependencies or []
|
"dependencies": feature.dependencies or []
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
@@ -401,11 +405,11 @@ def feature_mark_in_progress(
|
|||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
# Atomic claim: only succeeds if feature is not already claimed or passing
|
# Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input
|
||||||
result = session.execute(text("""
|
result = session.execute(text("""
|
||||||
UPDATE features
|
UPDATE features
|
||||||
SET in_progress = 1
|
SET in_progress = 1
|
||||||
WHERE id = :id AND passes = 0 AND in_progress = 0
|
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
|
||||||
"""), {"id": feature_id})
|
"""), {"id": feature_id})
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -418,6 +422,8 @@ def feature_mark_in_progress(
|
|||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
if feature.in_progress:
|
if feature.in_progress:
|
||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
|
||||||
|
if getattr(feature, 'needs_human_input', False):
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
|
||||||
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
|
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
|
||||||
|
|
||||||
# Fetch the claimed feature
|
# Fetch the claimed feature
|
||||||
@@ -455,11 +461,14 @@ def feature_claim_and_get(
|
|||||||
if feature.passes:
|
if feature.passes:
|
||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
|
|
||||||
# Try atomic claim: only succeeds if not already claimed
|
if getattr(feature, 'needs_human_input', False):
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"})
|
||||||
|
|
||||||
|
# Try atomic claim: only succeeds if not already claimed and not blocked for human input
|
||||||
result = session.execute(text("""
|
result = session.execute(text("""
|
||||||
UPDATE features
|
UPDATE features
|
||||||
SET in_progress = 1
|
SET in_progress = 1
|
||||||
WHERE id = :id AND passes = 0 AND in_progress = 0
|
WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0
|
||||||
"""), {"id": feature_id})
|
"""), {"id": feature_id})
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -806,6 +815,8 @@ def feature_get_ready(
|
|||||||
for f in all_features:
|
for f in all_features:
|
||||||
if f.passes or f.in_progress:
|
if f.passes or f.in_progress:
|
||||||
continue
|
continue
|
||||||
|
if getattr(f, 'needs_human_input', False):
|
||||||
|
continue
|
||||||
deps = f.dependencies or []
|
deps = f.dependencies or []
|
||||||
if all(dep_id in passing_ids for dep_id in deps):
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
ready.append(f.to_dict())
|
ready.append(f.to_dict())
|
||||||
@@ -888,6 +899,8 @@ def feature_get_graph() -> str:
|
|||||||
|
|
||||||
if f.passes:
|
if f.passes:
|
||||||
status = "done"
|
status = "done"
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
status = "needs_human_input"
|
||||||
elif blocking:
|
elif blocking:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
@@ -984,6 +997,116 @@ def feature_set_dependencies(
|
|||||||
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_request_human_input(
|
||||||
|
feature_id: Annotated[int, Field(description="The ID of the feature that needs human input", ge=1)],
|
||||||
|
prompt: Annotated[str, Field(min_length=1, description="Explain what you need from the human and why")],
|
||||||
|
fields: Annotated[list[dict], Field(min_length=1, description="List of input fields to collect")]
|
||||||
|
) -> str:
|
||||||
|
"""Request structured input from a human for a feature that is blocked.
|
||||||
|
|
||||||
|
Use this ONLY when the feature genuinely cannot proceed without human intervention:
|
||||||
|
- Creating API keys or external accounts
|
||||||
|
- Choosing between design approaches that require human preference
|
||||||
|
- Configuring external services the agent cannot access
|
||||||
|
- Providing credentials or secrets
|
||||||
|
|
||||||
|
Do NOT use this for issues you can solve yourself (debugging, reading docs, etc.).
|
||||||
|
|
||||||
|
The feature will be moved out of in_progress and into a "needs human input" state.
|
||||||
|
Once the human provides their response, the feature returns to the pending queue
|
||||||
|
and will include the human's response when you pick it up again.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: The ID of the feature that needs human input
|
||||||
|
prompt: A clear explanation of what you need and why
|
||||||
|
fields: List of input fields, each with:
|
||||||
|
- id (str): Unique field identifier
|
||||||
|
- label (str): Human-readable label
|
||||||
|
- type (str): "text", "textarea", "select", or "boolean" (default: "text")
|
||||||
|
- required (bool): Whether the field is required (default: true)
|
||||||
|
- placeholder (str, optional): Placeholder text
|
||||||
|
- options (list, optional): For select type: [{value, label}]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success confirmation or error message
|
||||||
|
"""
|
||||||
|
# Validate fields
|
||||||
|
VALID_FIELD_TYPES = {"text", "textarea", "select", "boolean"}
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for i, field in enumerate(fields):
|
||||||
|
if "id" not in field or "label" not in field:
|
||||||
|
return json.dumps({"error": f"Field at index {i} missing required 'id' or 'label'"})
|
||||||
|
fid = field["id"]
|
||||||
|
flabel = field["label"]
|
||||||
|
if not isinstance(fid, str) or not fid.strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i} has empty or invalid 'id'"})
|
||||||
|
if not isinstance(flabel, str) or not flabel.strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i} has empty or invalid 'label'"})
|
||||||
|
if fid in seen_ids:
|
||||||
|
return json.dumps({"error": f"Duplicate field id '{fid}' at index {i}"})
|
||||||
|
seen_ids.add(fid)
|
||||||
|
ftype = field.get("type", "text")
|
||||||
|
if ftype not in VALID_FIELD_TYPES:
|
||||||
|
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
|
||||||
|
if ftype == "select":
|
||||||
|
options = field.get("options")
|
||||||
|
if not options or not isinstance(options, list):
|
||||||
|
return json.dumps({"error": f"Field at index {i} is type 'select' but missing or invalid 'options' array"})
|
||||||
|
for j, opt in enumerate(options):
|
||||||
|
if not isinstance(opt, dict):
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} must be an object with 'value' and 'label'"})
|
||||||
|
if "value" not in opt or "label" not in opt:
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} missing required 'value' or 'label'"})
|
||||||
|
if not isinstance(opt["value"], str) or not opt["value"].strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'value'"})
|
||||||
|
if not isinstance(opt["label"], str) or not opt["label"].strip():
|
||||||
|
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'label'"})
|
||||||
|
elif field.get("options"):
|
||||||
|
return json.dumps({"error": f"Field at index {i} has 'options' but type is '{ftype}' (only 'select' uses options)"})
|
||||||
|
|
||||||
|
request_data = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# Atomically set needs_human_input, clear in_progress, store request, clear previous response
|
||||||
|
result = session.execute(text("""
|
||||||
|
UPDATE features
|
||||||
|
SET needs_human_input = 1,
|
||||||
|
in_progress = 0,
|
||||||
|
human_input_request = :request,
|
||||||
|
human_input_response = NULL
|
||||||
|
WHERE id = :id AND passes = 0 AND in_progress = 1
|
||||||
|
"""), {"id": feature_id, "request": json.dumps(request_data)})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
if result.rowcount == 0:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if feature is None:
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} not found"})
|
||||||
|
if feature.passes:
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
|
if not feature.in_progress:
|
||||||
|
return json.dumps({"error": f"Feature with ID {feature_id} is not in progress"})
|
||||||
|
return json.dumps({"error": "Failed to request human input for unknown reason"})
|
||||||
|
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
return json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
"name": feature.name,
|
||||||
|
"message": f"Feature '{feature.name}' is now blocked waiting for human input"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return json.dumps({"error": f"Failed to request human input: {str(e)}"})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def ask_user(
|
def ask_user(
|
||||||
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]
|
questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autoforge-ai",
|
"name": "autoforge-ai",
|
||||||
"version": "0.1.12",
|
"version": "0.1.17",
|
||||||
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
"description": "Autonomous coding agent with web UI - build complete apps with AI",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ def _dump_database_state(feature_dicts: list[dict], label: str = ""):
|
|||||||
MAX_PARALLEL_AGENTS = 5
|
MAX_PARALLEL_AGENTS = 5
|
||||||
MAX_TOTAL_AGENTS = 10
|
MAX_TOTAL_AGENTS = 10
|
||||||
DEFAULT_CONCURRENCY = 3
|
DEFAULT_CONCURRENCY = 3
|
||||||
DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-5)
|
DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-15)
|
||||||
POLL_INTERVAL = 5 # seconds between checking for ready features
|
POLL_INTERVAL = 5 # seconds between checking for ready features
|
||||||
MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
|
MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
|
||||||
INITIALIZER_TIMEOUT = 1800 # 30 minutes timeout for initializer
|
INITIALIZER_TIMEOUT = 1800 # 30 minutes timeout for initializer
|
||||||
@@ -168,7 +168,7 @@ class ParallelOrchestrator:
|
|||||||
yolo_mode: Whether to run in YOLO mode (skip testing agents entirely)
|
yolo_mode: Whether to run in YOLO mode (skip testing agents entirely)
|
||||||
testing_agent_ratio: Number of regression testing agents to maintain (0-3).
|
testing_agent_ratio: Number of regression testing agents to maintain (0-3).
|
||||||
0 = disabled, 1-3 = maintain that many testing agents running independently.
|
0 = disabled, 1-3 = maintain that many testing agents running independently.
|
||||||
testing_batch_size: Number of features to include per testing session (1-5).
|
testing_batch_size: Number of features to include per testing session (1-15).
|
||||||
Each testing agent receives this many features to regression test.
|
Each testing agent receives this many features to regression test.
|
||||||
on_output: Callback for agent output (feature_id, line)
|
on_output: Callback for agent output (feature_id, line)
|
||||||
on_status: Callback for agent status changes (feature_id, status)
|
on_status: Callback for agent status changes (feature_id, status)
|
||||||
@@ -178,8 +178,8 @@ class ParallelOrchestrator:
|
|||||||
self.model = model
|
self.model = model
|
||||||
self.yolo_mode = yolo_mode
|
self.yolo_mode = yolo_mode
|
||||||
self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3
|
self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3
|
||||||
self.testing_batch_size = min(max(testing_batch_size, 1), 5) # Clamp 1-5
|
self.testing_batch_size = min(max(testing_batch_size, 1), 15) # Clamp 1-15
|
||||||
self.batch_size = min(max(batch_size, 1), 3) # Clamp 1-3
|
self.batch_size = min(max(batch_size, 1), 15) # Clamp 1-15
|
||||||
self.on_output = on_output
|
self.on_output = on_output
|
||||||
self.on_status = on_status
|
self.on_status = on_status
|
||||||
|
|
||||||
@@ -213,6 +213,9 @@ class ParallelOrchestrator:
|
|||||||
# Signal handlers only set this flag; cleanup happens in the main loop
|
# Signal handlers only set this flag; cleanup happens in the main loop
|
||||||
self._shutdown_requested = False
|
self._shutdown_requested = False
|
||||||
|
|
||||||
|
# Graceful pause (drain mode) flag
|
||||||
|
self._drain_requested = False
|
||||||
|
|
||||||
# Session tracking for logging/debugging
|
# Session tracking for logging/debugging
|
||||||
self.session_start_time: datetime | None = None
|
self.session_start_time: datetime | None = None
|
||||||
|
|
||||||
@@ -493,6 +496,9 @@ class ParallelOrchestrator:
|
|||||||
for fd in feature_dicts:
|
for fd in feature_dicts:
|
||||||
if not fd.get("in_progress") or fd.get("passes"):
|
if not fd.get("in_progress") or fd.get("passes"):
|
||||||
continue
|
continue
|
||||||
|
# Skip if blocked for human input
|
||||||
|
if fd.get("needs_human_input"):
|
||||||
|
continue
|
||||||
# Skip if already running in this orchestrator instance
|
# Skip if already running in this orchestrator instance
|
||||||
if fd["id"] in running_ids:
|
if fd["id"] in running_ids:
|
||||||
continue
|
continue
|
||||||
@@ -537,11 +543,14 @@ class ParallelOrchestrator:
|
|||||||
running_ids.update(batch_ids)
|
running_ids.update(batch_ids)
|
||||||
|
|
||||||
ready = []
|
ready = []
|
||||||
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0}
|
skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0}
|
||||||
for fd in feature_dicts:
|
for fd in feature_dicts:
|
||||||
if fd.get("passes"):
|
if fd.get("passes"):
|
||||||
skipped_reasons["passes"] += 1
|
skipped_reasons["passes"] += 1
|
||||||
continue
|
continue
|
||||||
|
if fd.get("needs_human_input"):
|
||||||
|
skipped_reasons["needs_human_input"] += 1
|
||||||
|
continue
|
||||||
if fd.get("in_progress"):
|
if fd.get("in_progress"):
|
||||||
skipped_reasons["in_progress"] += 1
|
skipped_reasons["in_progress"] += 1
|
||||||
continue
|
continue
|
||||||
@@ -1387,6 +1396,9 @@ class ParallelOrchestrator:
|
|||||||
# Must happen before any debug_log.log() calls
|
# Must happen before any debug_log.log() calls
|
||||||
debug_log.start_session()
|
debug_log.start_session()
|
||||||
|
|
||||||
|
# Clear any stale drain signal from a previous session
|
||||||
|
self._clear_drain_signal()
|
||||||
|
|
||||||
# Log startup to debug file
|
# Log startup to debug file
|
||||||
debug_log.section("ORCHESTRATOR STARTUP")
|
debug_log.section("ORCHESTRATOR STARTUP")
|
||||||
debug_log.log("STARTUP", "Orchestrator run_loop starting",
|
debug_log.log("STARTUP", "Orchestrator run_loop starting",
|
||||||
@@ -1508,6 +1520,34 @@ class ParallelOrchestrator:
|
|||||||
print("\nAll features complete!", flush=True)
|
print("\nAll features complete!", flush=True)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# --- Graceful pause (drain mode) ---
|
||||||
|
if not self._drain_requested and self._check_drain_signal():
|
||||||
|
self._drain_requested = True
|
||||||
|
print("Graceful pause requested - draining running agents...", flush=True)
|
||||||
|
debug_log.log("DRAIN", "Graceful pause requested, draining running agents")
|
||||||
|
|
||||||
|
if self._drain_requested:
|
||||||
|
with self._lock:
|
||||||
|
coding_count = len(self.running_coding_agents)
|
||||||
|
testing_count = len(self.running_testing_agents)
|
||||||
|
|
||||||
|
if coding_count == 0 and testing_count == 0:
|
||||||
|
print("All agents drained - paused.", flush=True)
|
||||||
|
debug_log.log("DRAIN", "All agents drained, entering paused state")
|
||||||
|
# Wait until signal file is removed (resume) or shutdown
|
||||||
|
while self._check_drain_signal() and self.is_running and not self._shutdown_requested:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
if not self.is_running or self._shutdown_requested:
|
||||||
|
break
|
||||||
|
self._drain_requested = False
|
||||||
|
print("Resuming from graceful pause...", flush=True)
|
||||||
|
debug_log.log("DRAIN", "Resuming from graceful pause")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
debug_log.log("DRAIN", f"Waiting for agents to finish: coding={coding_count}, testing={testing_count}")
|
||||||
|
await self._wait_for_agent_completion()
|
||||||
|
continue
|
||||||
|
|
||||||
# Maintain testing agents independently (runs every iteration)
|
# Maintain testing agents independently (runs every iteration)
|
||||||
self._maintain_testing_agents(feature_dicts)
|
self._maintain_testing_agents(feature_dicts)
|
||||||
|
|
||||||
@@ -1632,6 +1672,17 @@ class ParallelOrchestrator:
|
|||||||
"yolo_mode": self.yolo_mode,
|
"yolo_mode": self.yolo_mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _check_drain_signal(self) -> bool:
|
||||||
|
"""Check if the graceful pause (drain) signal file exists."""
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
return get_pause_drain_path(self.project_dir).exists()
|
||||||
|
|
||||||
|
def _clear_drain_signal(self) -> None:
|
||||||
|
"""Delete the drain signal file and reset the flag."""
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
self._drain_requested = False
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""Clean up database resources. Safe to call multiple times.
|
"""Clean up database resources. Safe to call multiple times.
|
||||||
|
|
||||||
|
|||||||
59
progress.py
59
progress.py
@@ -62,54 +62,71 @@ def has_features(project_dir: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
|
def count_passing_tests(project_dir: Path) -> tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
Count passing, in_progress, and total tests via direct database access.
|
Count passing, in_progress, total, and needs_human_input tests via direct database access.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_dir: Directory containing the project
|
project_dir: Directory containing the project
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(passing_count, in_progress_count, total_count)
|
(passing_count, in_progress_count, total_count, needs_human_input_count)
|
||||||
"""
|
"""
|
||||||
from autoforge_paths import get_features_db_path
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return 0, 0, 0
|
return 0, 0, 0, 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with closing(_get_connection(db_file)) as conn:
|
with closing(_get_connection(db_file)) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# Single aggregate query instead of 3 separate COUNT queries
|
# Single aggregate query instead of separate COUNT queries
|
||||||
# Handle case where in_progress column doesn't exist yet (legacy DBs)
|
# Handle case where columns don't exist yet (legacy DBs)
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
||||||
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
|
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress,
|
||||||
|
SUM(CASE WHEN needs_human_input = 1 THEN 1 ELSE 0 END) as needs_human_input
|
||||||
FROM features
|
FROM features
|
||||||
""")
|
""")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
total = row[0] or 0
|
total = row[0] or 0
|
||||||
passing = row[1] or 0
|
passing = row[1] or 0
|
||||||
in_progress = row[2] or 0
|
in_progress = row[2] or 0
|
||||||
|
needs_human_input = row[3] or 0
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
# Fallback for databases without in_progress column
|
# Fallback for databases without newer columns
|
||||||
cursor.execute("""
|
try:
|
||||||
SELECT
|
cursor.execute("""
|
||||||
COUNT(*) as total,
|
SELECT
|
||||||
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
|
COUNT(*) as total,
|
||||||
FROM features
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
||||||
""")
|
SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress
|
||||||
row = cursor.fetchone()
|
FROM features
|
||||||
total = row[0] or 0
|
""")
|
||||||
passing = row[1] or 0
|
row = cursor.fetchone()
|
||||||
in_progress = 0
|
total = row[0] or 0
|
||||||
return passing, in_progress, total
|
passing = row[1] or 0
|
||||||
|
in_progress = row[2] or 0
|
||||||
|
needs_human_input = 0
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
|
||||||
|
FROM features
|
||||||
|
""")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
total = row[0] or 0
|
||||||
|
passing = row[1] or 0
|
||||||
|
in_progress = 0
|
||||||
|
needs_human_input = 0
|
||||||
|
return passing, in_progress, total, needs_human_input
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Database error in count_passing_tests: {e}]")
|
print(f"[Database error in count_passing_tests: {e}]")
|
||||||
return 0, 0, 0
|
return 0, 0, 0, 0
|
||||||
|
|
||||||
|
|
||||||
def get_all_passing_features(project_dir: Path) -> list[dict]:
|
def get_all_passing_features(project_dir: Path) -> list[dict]:
|
||||||
@@ -234,7 +251,7 @@ def print_session_header(session_num: int, is_initializer: bool) -> None:
|
|||||||
|
|
||||||
def print_progress_summary(project_dir: Path) -> None:
|
def print_progress_summary(project_dir: Path) -> None:
|
||||||
"""Print a summary of current progress."""
|
"""Print a summary of current progress."""
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, _needs_human_input = count_passing_tests(project_dir)
|
||||||
|
|
||||||
if total > 0:
|
if total > 0:
|
||||||
percentage = (passing / total) * 100
|
percentage = (passing / total) * 100
|
||||||
|
|||||||
@@ -671,6 +671,7 @@ API_PROVIDERS: dict[str, dict[str, Any]] = {
|
|||||||
"requires_auth": True,
|
"requires_auth": True,
|
||||||
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
"auth_env_var": "ANTHROPIC_AUTH_TOKEN",
|
||||||
"models": [
|
"models": [
|
||||||
|
{"id": "glm-5", "name": "GLM 5"},
|
||||||
{"id": "glm-4.7", "name": "GLM 4.7"},
|
{"id": "glm-4.7", "name": "GLM 4.7"},
|
||||||
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
|
{"id": "glm-4.5-air", "name": "GLM 4.5 Air"},
|
||||||
],
|
],
|
||||||
@@ -743,7 +744,7 @@ def get_effective_sdk_env() -> dict[str, str]:
|
|||||||
sdk_env[var] = value
|
sdk_env[var] = value
|
||||||
return sdk_env
|
return sdk_env
|
||||||
|
|
||||||
sdk_env: dict[str, str] = {}
|
sdk_env = {}
|
||||||
|
|
||||||
# Explicitly clear credentials that could leak from the server process env.
|
# Explicitly clear credentials that could leak from the server process env.
|
||||||
# For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY.
|
# For providers using ANTHROPIC_AUTH_TOKEN (GLM, Custom), clear ANTHROPIC_API_KEY.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Production runtime dependencies only
|
# Production runtime dependencies only
|
||||||
# For development, use requirements.txt (includes ruff, mypy, pytest)
|
# For development, use requirements.txt (includes ruff, mypy, pytest)
|
||||||
claude-agent-sdk>=0.1.0,<0.2.0
|
claude-agent-sdk>=0.1.39,<0.2.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
sqlalchemy>=2.0.0
|
sqlalchemy>=2.0.0
|
||||||
fastapi>=0.115.0
|
fastapi>=0.115.0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
claude-agent-sdk>=0.1.0,<0.2.0
|
claude-agent-sdk>=0.1.39,<0.2.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
sqlalchemy>=2.0.0
|
sqlalchemy>=2.0.0
|
||||||
fastapi>=0.115.0
|
fastapi>=0.115.0
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .routers import (
|
|||||||
features_router,
|
features_router,
|
||||||
filesystem_router,
|
filesystem_router,
|
||||||
projects_router,
|
projects_router,
|
||||||
|
scaffold_router,
|
||||||
schedules_router,
|
schedules_router,
|
||||||
settings_router,
|
settings_router,
|
||||||
spec_creation_router,
|
spec_creation_router,
|
||||||
@@ -169,6 +170,7 @@ app.include_router(filesystem_router)
|
|||||||
app.include_router(assistant_chat_router)
|
app.include_router(assistant_chat_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(terminal_router)
|
app.include_router(terminal_router)
|
||||||
|
app.include_router(scaffold_router)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
|
|||||||
from .features import router as features_router
|
from .features import router as features_router
|
||||||
from .filesystem import router as filesystem_router
|
from .filesystem import router as filesystem_router
|
||||||
from .projects import router as projects_router
|
from .projects import router as projects_router
|
||||||
|
from .scaffold import router as scaffold_router
|
||||||
from .schedules import router as schedules_router
|
from .schedules import router as schedules_router
|
||||||
from .settings import router as settings_router
|
from .settings import router as settings_router
|
||||||
from .spec_creation import router as spec_creation_router
|
from .spec_creation import router as spec_creation_router
|
||||||
@@ -29,4 +30,5 @@ __all__ = [
|
|||||||
"assistant_chat_router",
|
"assistant_chat_router",
|
||||||
"settings_router",
|
"settings_router",
|
||||||
"terminal_router",
|
"terminal_router",
|
||||||
|
"scaffold_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ from ..utils.project_helpers import get_project_path as _get_project_path
|
|||||||
from ..utils.validation import validate_project_name
|
from ..utils.validation import validate_project_name
|
||||||
|
|
||||||
|
|
||||||
def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
|
def _get_settings_defaults() -> tuple[bool, str, int, bool, int, int]:
|
||||||
"""Get defaults from global settings.
|
"""Get defaults from global settings.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size)
|
Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size, testing_batch_size)
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
root = Path(__file__).parent.parent.parent
|
root = Path(__file__).parent.parent.parent
|
||||||
@@ -47,7 +47,12 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
batch_size = 3
|
batch_size = 3
|
||||||
|
|
||||||
return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size
|
try:
|
||||||
|
testing_batch_size = int(settings.get("testing_batch_size", "3"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
testing_batch_size = 3
|
||||||
|
|
||||||
|
return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size, testing_batch_size
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
||||||
@@ -96,7 +101,7 @@ async def start_agent(
|
|||||||
manager = get_project_manager(project_name)
|
manager = get_project_manager(project_name)
|
||||||
|
|
||||||
# Get defaults from global settings if not provided in request
|
# Get defaults from global settings if not provided in request
|
||||||
default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size = _get_settings_defaults()
|
default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size, default_testing_batch_size = _get_settings_defaults()
|
||||||
|
|
||||||
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
||||||
model = request.model if request.model else default_model
|
model = request.model if request.model else default_model
|
||||||
@@ -104,6 +109,7 @@ async def start_agent(
|
|||||||
testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
|
testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
|
||||||
|
|
||||||
batch_size = default_batch_size
|
batch_size = default_batch_size
|
||||||
|
testing_batch_size = default_testing_batch_size
|
||||||
|
|
||||||
success, message = await manager.start(
|
success, message = await manager.start(
|
||||||
yolo_mode=yolo_mode,
|
yolo_mode=yolo_mode,
|
||||||
@@ -112,6 +118,7 @@ async def start_agent(
|
|||||||
testing_agent_ratio=testing_agent_ratio,
|
testing_agent_ratio=testing_agent_ratio,
|
||||||
playwright_headless=playwright_headless,
|
playwright_headless=playwright_headless,
|
||||||
batch_size=batch_size,
|
batch_size=batch_size,
|
||||||
|
testing_batch_size=testing_batch_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify scheduler of manual start (to prevent auto-stop during scheduled window)
|
# Notify scheduler of manual start (to prevent auto-stop during scheduled window)
|
||||||
@@ -175,3 +182,31 @@ async def resume_agent(project_name: str):
|
|||||||
status=manager.status,
|
status=manager.status,
|
||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/graceful-pause", response_model=AgentActionResponse)
|
||||||
|
async def graceful_pause_agent(project_name: str):
|
||||||
|
"""Request a graceful pause (drain mode) - finish current work then pause."""
|
||||||
|
manager = get_project_manager(project_name)
|
||||||
|
|
||||||
|
success, message = await manager.graceful_pause()
|
||||||
|
|
||||||
|
return AgentActionResponse(
|
||||||
|
success=success,
|
||||||
|
status=manager.status,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/graceful-resume", response_model=AgentActionResponse)
|
||||||
|
async def graceful_resume_agent(project_name: str):
|
||||||
|
"""Resume from a graceful pause."""
|
||||||
|
manager = get_project_manager(project_name)
|
||||||
|
|
||||||
|
success, message = await manager.graceful_resume()
|
||||||
|
|
||||||
|
return AgentActionResponse(
|
||||||
|
success=success,
|
||||||
|
status=manager.status,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ..schemas import (
|
|||||||
FeatureListResponse,
|
FeatureListResponse,
|
||||||
FeatureResponse,
|
FeatureResponse,
|
||||||
FeatureUpdate,
|
FeatureUpdate,
|
||||||
|
HumanInputResponse,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import validate_project_name
|
from ..utils.validation import validate_project_name
|
||||||
@@ -104,6 +105,9 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon
|
|||||||
in_progress=f.in_progress if f.in_progress is not None else False,
|
in_progress=f.in_progress if f.in_progress is not None else False,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
blocking_dependencies=blocking,
|
blocking_dependencies=blocking,
|
||||||
|
needs_human_input=getattr(f, 'needs_human_input', False) or False,
|
||||||
|
human_input_request=getattr(f, 'human_input_request', None),
|
||||||
|
human_input_response=getattr(f, 'human_input_response', None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,11 +147,14 @@ async def list_features(project_name: str):
|
|||||||
pending = []
|
pending = []
|
||||||
in_progress = []
|
in_progress = []
|
||||||
done = []
|
done = []
|
||||||
|
needs_human_input_list = []
|
||||||
|
|
||||||
for f in all_features:
|
for f in all_features:
|
||||||
feature_response = feature_to_response(f, passing_ids)
|
feature_response = feature_to_response(f, passing_ids)
|
||||||
if f.passes:
|
if f.passes:
|
||||||
done.append(feature_response)
|
done.append(feature_response)
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
needs_human_input_list.append(feature_response)
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
in_progress.append(feature_response)
|
in_progress.append(feature_response)
|
||||||
else:
|
else:
|
||||||
@@ -157,6 +164,7 @@ async def list_features(project_name: str):
|
|||||||
pending=pending,
|
pending=pending,
|
||||||
in_progress=in_progress,
|
in_progress=in_progress,
|
||||||
done=done,
|
done=done,
|
||||||
|
needs_human_input=needs_human_input_list,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -341,9 +349,11 @@ async def get_dependency_graph(project_name: str):
|
|||||||
deps = f.dependencies or []
|
deps = f.dependencies or []
|
||||||
blocking = [d for d in deps if d not in passing_ids]
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
|
||||||
status: Literal["pending", "in_progress", "done", "blocked"]
|
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
|
||||||
if f.passes:
|
if f.passes:
|
||||||
status = "done"
|
status = "done"
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
status = "needs_human_input"
|
||||||
elif blocking:
|
elif blocking:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
@@ -564,6 +574,71 @@ async def skip_feature(project_name: str, feature_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{feature_id}/resolve-human-input", response_model=FeatureResponse)
|
||||||
|
async def resolve_human_input(project_name: str, feature_id: int, response: HumanInputResponse):
|
||||||
|
"""Resolve a human input request for a feature.
|
||||||
|
|
||||||
|
Validates all required fields have values, stores the response,
|
||||||
|
and returns the feature to the pending queue for agents to pick up.
|
||||||
|
"""
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
|
||||||
|
if not project_dir:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session(project_dir) as session:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||||
|
|
||||||
|
if not getattr(feature, 'needs_human_input', False):
|
||||||
|
raise HTTPException(status_code=400, detail="Feature is not waiting for human input")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
request_data = feature.human_input_request
|
||||||
|
if request_data and isinstance(request_data, dict):
|
||||||
|
for field_def in request_data.get("fields", []):
|
||||||
|
if field_def.get("required", True):
|
||||||
|
field_id = field_def.get("id")
|
||||||
|
if field_id not in response.fields or response.fields[field_id] in (None, ""):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Required field '{field_def.get('label', field_id)}' is missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store response and return to pending queue
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
response_data = {
|
||||||
|
"fields": {k: v for k, v in response.fields.items()},
|
||||||
|
"responded_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
feature.human_input_response = response_data
|
||||||
|
feature.needs_human_input = False
|
||||||
|
# Keep in_progress=False, passes=False so it returns to pending
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.refresh(feature)
|
||||||
|
|
||||||
|
# Compute passing IDs for response
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
return feature_to_response(feature, passing_ids)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to resolve human input")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to resolve human input")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Dependency Management Endpoints
|
# Dependency Management Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
|
|||||||
"""Get statistics for a project."""
|
"""Get statistics for a project."""
|
||||||
_init_imports()
|
_init_imports()
|
||||||
assert _count_passing_tests is not None # guaranteed by _init_imports()
|
assert _count_passing_tests is not None # guaranteed by _init_imports()
|
||||||
passing, in_progress, total = _count_passing_tests(project_dir)
|
passing, in_progress, total, _needs_human_input = _count_passing_tests(project_dir)
|
||||||
percentage = (passing / total * 100) if total > 0 else 0.0
|
percentage = (passing / total * 100) if total > 0 else 0.0
|
||||||
return ProjectStats(
|
return ProjectStats(
|
||||||
passing=passing,
|
passing=passing,
|
||||||
|
|||||||
136
server/routers/scaffold.py
Normal file
136
server/routers/scaffold.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Scaffold Router
|
||||||
|
================
|
||||||
|
|
||||||
|
SSE streaming endpoint for running project scaffold commands.
|
||||||
|
Supports templated project creation (e.g., Next.js agentic starter).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .filesystem import is_path_blocked
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
|
||||||
|
|
||||||
|
# Hardcoded templates — no arbitrary commands allowed
|
||||||
|
TEMPLATES: dict[str, list[str]] = {
|
||||||
|
"agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScaffoldRequest(BaseModel):
|
||||||
|
template: str
|
||||||
|
target_path: str
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_event(data: dict) -> str:
|
||||||
|
"""Format a dict as an SSE data line."""
|
||||||
|
return f"data: {json.dumps(data)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream_scaffold(template: str, target_path: str, request: Request):
|
||||||
|
"""Run the scaffold command and yield SSE events."""
|
||||||
|
# Validate template
|
||||||
|
if template not in TEMPLATES:
|
||||||
|
yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate path
|
||||||
|
path = Path(target_path)
|
||||||
|
try:
|
||||||
|
path = path.resolve()
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_path_blocked(path):
|
||||||
|
yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if not path.exists() or not path.is_dir():
|
||||||
|
yield _sse_event({"type": "error", "message": "Target directory does not exist"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check npx is available
|
||||||
|
npx_name = "npx"
|
||||||
|
if sys.platform == "win32":
|
||||||
|
npx_name = "npx.cmd"
|
||||||
|
|
||||||
|
if not shutil.which(npx_name):
|
||||||
|
yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
argv = list(TEMPLATES[template])
|
||||||
|
if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
|
||||||
|
argv[0] = argv[0] + ".cmd"
|
||||||
|
|
||||||
|
process = None
|
||||||
|
try:
|
||||||
|
popen_kwargs: dict = {
|
||||||
|
"stdout": subprocess.PIPE,
|
||||||
|
"stderr": subprocess.STDOUT,
|
||||||
|
"stdin": subprocess.DEVNULL,
|
||||||
|
"cwd": str(path),
|
||||||
|
}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
process = subprocess.Popen(argv, **popen_kwargs)
|
||||||
|
logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
|
||||||
|
|
||||||
|
# Stream stdout lines
|
||||||
|
assert process.stdout is not None
|
||||||
|
for raw_line in iter(process.stdout.readline, b""):
|
||||||
|
# Check if client disconnected
|
||||||
|
if await request.is_disconnected():
|
||||||
|
logger.info("Client disconnected during scaffold, terminating process")
|
||||||
|
break
|
||||||
|
|
||||||
|
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
||||||
|
yield _sse_event({"type": "output", "line": line})
|
||||||
|
# Yield control to event loop so disconnect checks work
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
process.wait()
|
||||||
|
exit_code = process.returncode
|
||||||
|
success = exit_code == 0
|
||||||
|
logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
|
||||||
|
yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Scaffold error: %s", e)
|
||||||
|
yield _sse_event({"type": "error", "message": str(e)})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if process and process.poll() is None:
|
||||||
|
try:
|
||||||
|
process.terminate()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
process.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run")
|
||||||
|
async def run_scaffold(body: ScaffoldRequest, request: Request):
|
||||||
|
"""Run a scaffold template command with SSE streaming output."""
|
||||||
|
return StreamingResponse(
|
||||||
|
_stream_scaffold(body.template, body.target_path, request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -113,6 +113,7 @@ async def get_settings():
|
|||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
|
||||||
api_provider=api_provider,
|
api_provider=api_provider,
|
||||||
api_base_url=all_settings.get("api_base_url"),
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
@@ -138,6 +139,9 @@ async def update_settings(update: SettingsUpdate):
|
|||||||
if update.batch_size is not None:
|
if update.batch_size is not None:
|
||||||
set_setting("batch_size", str(update.batch_size))
|
set_setting("batch_size", str(update.batch_size))
|
||||||
|
|
||||||
|
if update.testing_batch_size is not None:
|
||||||
|
set_setting("testing_batch_size", str(update.testing_batch_size))
|
||||||
|
|
||||||
# API provider settings
|
# API provider settings
|
||||||
if update.api_provider is not None:
|
if update.api_provider is not None:
|
||||||
old_provider = get_setting("api_provider", "claude")
|
old_provider = get_setting("api_provider", "claude")
|
||||||
@@ -177,6 +181,7 @@ async def update_settings(update: SettingsUpdate):
|
|||||||
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
||||||
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
|
||||||
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
||||||
|
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
|
||||||
api_provider=api_provider,
|
api_provider=api_provider,
|
||||||
api_base_url=all_settings.get("api_base_url"),
|
api_base_url=all_settings.get("api_base_url"),
|
||||||
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
||||||
|
|||||||
@@ -120,16 +120,41 @@ class FeatureResponse(FeatureBase):
|
|||||||
in_progress: bool
|
in_progress: bool
|
||||||
blocked: bool = False # Computed: has unmet dependencies
|
blocked: bool = False # Computed: has unmet dependencies
|
||||||
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
|
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
|
||||||
|
needs_human_input: bool = False
|
||||||
|
human_input_request: dict | None = None
|
||||||
|
human_input_response: dict | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputField(BaseModel):
|
||||||
|
"""Schema for a single human input field."""
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: Literal["text", "textarea", "select", "boolean"] = "text"
|
||||||
|
required: bool = True
|
||||||
|
placeholder: str | None = None
|
||||||
|
options: list[dict] | None = None # For select: [{value, label}]
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputRequest(BaseModel):
|
||||||
|
"""Schema for an agent's human input request."""
|
||||||
|
prompt: str
|
||||||
|
fields: list[HumanInputField]
|
||||||
|
|
||||||
|
|
||||||
|
class HumanInputResponse(BaseModel):
|
||||||
|
"""Schema for a human's response to an input request."""
|
||||||
|
fields: dict[str, str | bool | list[str]]
|
||||||
|
|
||||||
|
|
||||||
class FeatureListResponse(BaseModel):
|
class FeatureListResponse(BaseModel):
|
||||||
"""Response containing list of features organized by status."""
|
"""Response containing list of features organized by status."""
|
||||||
pending: list[FeatureResponse]
|
pending: list[FeatureResponse]
|
||||||
in_progress: list[FeatureResponse]
|
in_progress: list[FeatureResponse]
|
||||||
done: list[FeatureResponse]
|
done: list[FeatureResponse]
|
||||||
|
needs_human_input: list[FeatureResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FeatureBulkCreate(BaseModel):
|
class FeatureBulkCreate(BaseModel):
|
||||||
@@ -153,7 +178,7 @@ class DependencyGraphNode(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: str
|
||||||
status: Literal["pending", "in_progress", "done", "blocked"]
|
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
|
||||||
priority: int
|
priority: int
|
||||||
dependencies: list[int]
|
dependencies: list[int]
|
||||||
|
|
||||||
@@ -217,7 +242,7 @@ class AgentStartRequest(BaseModel):
|
|||||||
|
|
||||||
class AgentStatus(BaseModel):
|
class AgentStatus(BaseModel):
|
||||||
"""Current agent status."""
|
"""Current agent status."""
|
||||||
status: Literal["stopped", "running", "paused", "crashed"]
|
status: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]
|
||||||
pid: int | None = None
|
pid: int | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
@@ -257,6 +282,7 @@ class WSProgressMessage(BaseModel):
|
|||||||
in_progress: int
|
in_progress: int
|
||||||
total: int
|
total: int
|
||||||
percentage: float
|
percentage: float
|
||||||
|
needs_human_input: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WSFeatureUpdateMessage(BaseModel):
|
class WSFeatureUpdateMessage(BaseModel):
|
||||||
@@ -418,7 +444,8 @@ class SettingsResponse(BaseModel):
|
|||||||
ollama_mode: bool = False # True when api_provider is "ollama"
|
ollama_mode: bool = False # True when api_provider is "ollama"
|
||||||
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
||||||
playwright_headless: bool = True
|
playwright_headless: bool = True
|
||||||
batch_size: int = 3 # Features per coding agent batch (1-3)
|
batch_size: int = 3 # Features per coding agent batch (1-15)
|
||||||
|
testing_batch_size: int = 3 # Features per testing agent batch (1-15)
|
||||||
api_provider: str = "claude"
|
api_provider: str = "claude"
|
||||||
api_base_url: str | None = None
|
api_base_url: str | None = None
|
||||||
api_has_auth_token: bool = False # Never expose actual token
|
api_has_auth_token: bool = False # Never expose actual token
|
||||||
@@ -437,7 +464,8 @@ class SettingsUpdate(BaseModel):
|
|||||||
model: str | None = None
|
model: str | None = None
|
||||||
testing_agent_ratio: int | None = None # 0-3
|
testing_agent_ratio: int | None = None # 0-3
|
||||||
playwright_headless: bool | None = None
|
playwright_headless: bool | None = None
|
||||||
batch_size: int | None = None # Features per agent batch (1-3)
|
batch_size: int | None = None # Features per agent batch (1-15)
|
||||||
|
testing_batch_size: int | None = None # Features per testing agent batch (1-15)
|
||||||
api_provider: str | None = None
|
api_provider: str | None = None
|
||||||
api_base_url: str | None = Field(None, max_length=500)
|
api_base_url: str | None = Field(None, max_length=500)
|
||||||
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
|
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
|
||||||
@@ -474,8 +502,15 @@ class SettingsUpdate(BaseModel):
|
|||||||
@field_validator('batch_size')
|
@field_validator('batch_size')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_batch_size(cls, v: int | None) -> int | None:
|
def validate_batch_size(cls, v: int | None) -> int | None:
|
||||||
if v is not None and (v < 1 or v > 3):
|
if v is not None and (v < 1 or v > 15):
|
||||||
raise ValueError("batch_size must be between 1 and 3")
|
raise ValueError("batch_size must be between 1 and 15")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('testing_batch_size')
|
||||||
|
@classmethod
|
||||||
|
def validate_testing_batch_size(cls, v: int | None) -> int | None:
|
||||||
|
if v is not None and (v < 1 or v > 15):
|
||||||
|
raise ValueError("testing_batch_size must be between 1 and 15")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ from .assistant_database import (
|
|||||||
create_conversation,
|
create_conversation,
|
||||||
get_messages,
|
get_messages,
|
||||||
)
|
)
|
||||||
from .chat_constants import ROOT_DIR
|
from .chat_constants import (
|
||||||
|
ROOT_DIR,
|
||||||
|
check_rate_limit_error,
|
||||||
|
safe_receive_response,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -394,38 +398,46 @@ class AssistantChatSession:
|
|||||||
full_response = ""
|
full_response = ""
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
async for msg in self.client.receive_response():
|
try:
|
||||||
msg_type = type(msg).__name__
|
async for msg in safe_receive_response(self.client, logger):
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
text = block.text
|
text = block.text
|
||||||
if text:
|
if text:
|
||||||
full_response += text
|
full_response += text
|
||||||
yield {"type": "text", "content": text}
|
yield {"type": "text", "content": text}
|
||||||
|
|
||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
tool_name = block.name
|
tool_name = block.name
|
||||||
tool_input = getattr(block, "input", {})
|
tool_input = getattr(block, "input", {})
|
||||||
|
|
||||||
# Intercept ask_user tool calls -> yield as question message
|
# Intercept ask_user tool calls -> yield as question message
|
||||||
if tool_name == "mcp__features__ask_user":
|
if tool_name == "mcp__features__ask_user":
|
||||||
questions = tool_input.get("questions", [])
|
questions = tool_input.get("questions", [])
|
||||||
if questions:
|
if questions:
|
||||||
yield {
|
yield {
|
||||||
"type": "question",
|
"type": "question",
|
||||||
"questions": questions,
|
"questions": questions,
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "tool_call",
|
"type": "tool_call",
|
||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
"input": tool_input,
|
"input": tool_input,
|
||||||
}
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
is_rate_limit, _ = check_rate_limit_error(exc)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.warning(f"Rate limited: {exc}")
|
||||||
|
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
# Store the complete response in the database
|
# Store the complete response in the database
|
||||||
if full_response and self.conversation_id:
|
if full_response and self.conversation_id:
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ project root and is re-exported here for convenience so that existing
|
|||||||
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Root directory of the autoforge project (repository root).
|
# Root directory of the autoforge project (repository root).
|
||||||
@@ -32,6 +33,59 @@ if _root_str not in sys.path:
|
|||||||
# imports continue to work unchanged.
|
# imports continue to work unchanged.
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
from env_constants import API_ENV_VARS # noqa: E402, F401
|
from env_constants import API_ENV_VARS # noqa: E402, F401
|
||||||
|
from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
|
||||||
|
"""Inspect an exception and determine if it represents a rate-limit.
|
||||||
|
|
||||||
|
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
|
||||||
|
parsed Retry-After value when available, otherwise ``None`` (caller
|
||||||
|
should use exponential backoff).
|
||||||
|
"""
|
||||||
|
# MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
|
||||||
|
# These are informational events, NOT actual rate limit errors.
|
||||||
|
# The word "rate_limit" in the type name would false-positive the regex.
|
||||||
|
if type(exc).__name__ == "MessageParseError":
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# For all other exceptions: match error text against known rate-limit patterns
|
||||||
|
exc_str = str(exc)
|
||||||
|
if is_rate_limit_error(exc_str):
|
||||||
|
retry = parse_retry_after(exc_str)
|
||||||
|
return True, retry
|
||||||
|
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
|
||||||
|
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.
|
||||||
|
|
||||||
|
The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
|
||||||
|
that the installed Python SDK does not recognise, causing
|
||||||
|
``MessageParseError`` which kills the async generator. The CLI
|
||||||
|
subprocess is still alive and the SDK uses a buffered memory channel,
|
||||||
|
so we restart ``receive_response()`` to continue reading remaining
|
||||||
|
messages without losing data.
|
||||||
|
"""
|
||||||
|
max_retries = 50
|
||||||
|
retries = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async for msg in client.receive_response():
|
||||||
|
yield msg
|
||||||
|
return # Normal completion
|
||||||
|
except Exception as exc:
|
||||||
|
if type(exc).__name__ == "MessageParseError":
|
||||||
|
retries += 1
|
||||||
|
if retries > max_retries:
|
||||||
|
log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
|
||||||
|
return
|
||||||
|
log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from ..schemas import ImageAttachment
|
from ..schemas import ImageAttachment
|
||||||
from .chat_constants import ROOT_DIR, make_multimodal_message
|
from .chat_constants import (
|
||||||
|
ROOT_DIR,
|
||||||
|
check_rate_limit_error,
|
||||||
|
make_multimodal_message,
|
||||||
|
safe_receive_response,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -299,23 +304,31 @@ class ExpandChatSession:
|
|||||||
await self.client.query(message)
|
await self.client.query(message)
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
async for msg in self.client.receive_response():
|
try:
|
||||||
msg_type = type(msg).__name__
|
async for msg in safe_receive_response(self.client, logger):
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
text = block.text
|
text = block.text
|
||||||
if text:
|
if text:
|
||||||
yield {"type": "text", "content": text}
|
yield {"type": "text", "content": text}
|
||||||
|
|
||||||
self.messages.append({
|
self.messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": text,
|
"content": text,
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat()
|
||||||
})
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
is_rate_limit, _ = check_rate_limit_error(exc)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.warning(f"Rate limited: {exc}")
|
||||||
|
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
def get_features_created(self) -> int:
|
def get_features_created(self) -> int:
|
||||||
"""Get the total number of features created in this session."""
|
"""Get the total number of features created in this session."""
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class AgentProcessManager:
|
|||||||
self.project_dir = project_dir
|
self.project_dir = project_dir
|
||||||
self.root_dir = root_dir
|
self.root_dir = root_dir
|
||||||
self.process: subprocess.Popen | None = None
|
self.process: subprocess.Popen | None = None
|
||||||
self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped"
|
self._status: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"] = "stopped"
|
||||||
self.started_at: datetime | None = None
|
self.started_at: datetime | None = None
|
||||||
self._output_task: asyncio.Task | None = None
|
self._output_task: asyncio.Task | None = None
|
||||||
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
||||||
@@ -96,11 +96,11 @@ class AgentProcessManager:
|
|||||||
self.lock_file = get_agent_lock_path(self.project_dir)
|
self.lock_file = get_agent_lock_path(self.project_dir)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
|
def status(self) -> Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@status.setter
|
@status.setter
|
||||||
def status(self, value: Literal["stopped", "running", "paused", "crashed"]):
|
def status(self, value: Literal["stopped", "running", "paused", "crashed", "pausing", "paused_graceful"]):
|
||||||
old_status = self._status
|
old_status = self._status
|
||||||
self._status = value
|
self._status = value
|
||||||
if old_status != value:
|
if old_status != value:
|
||||||
@@ -277,7 +277,7 @@ class AgentProcessManager:
|
|||||||
).all()
|
).all()
|
||||||
if stuck:
|
if stuck:
|
||||||
for f in stuck:
|
for f in stuck:
|
||||||
f.in_progress = False
|
f.in_progress = False # type: ignore[assignment]
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Cleaned up %d stuck feature(s) for %s",
|
"Cleaned up %d stuck feature(s) for %s",
|
||||||
@@ -330,6 +330,12 @@ class AgentProcessManager:
|
|||||||
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
||||||
await self._broadcast_output(help_line)
|
await self._broadcast_output(help_line)
|
||||||
|
|
||||||
|
# Detect graceful pause status transitions from orchestrator output
|
||||||
|
if "All agents drained - paused." in decoded:
|
||||||
|
self.status = "paused_graceful"
|
||||||
|
elif "Resuming from graceful pause..." in decoded:
|
||||||
|
self.status = "running"
|
||||||
|
|
||||||
await self._broadcast_output(sanitized)
|
await self._broadcast_output(sanitized)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -340,7 +346,7 @@ class AgentProcessManager:
|
|||||||
# Check if process ended
|
# Check if process ended
|
||||||
if self.process and self.process.poll() is not None:
|
if self.process and self.process.poll() is not None:
|
||||||
exit_code = self.process.returncode
|
exit_code = self.process.returncode
|
||||||
if exit_code != 0 and self.status == "running":
|
if exit_code != 0 and self.status in ("running", "pausing", "paused_graceful"):
|
||||||
# Check buffered output for auth errors if we haven't detected one yet
|
# Check buffered output for auth errors if we haven't detected one yet
|
||||||
if not auth_error_detected:
|
if not auth_error_detected:
|
||||||
combined_output = '\n'.join(output_buffer)
|
combined_output = '\n'.join(output_buffer)
|
||||||
@@ -348,10 +354,16 @@ class AgentProcessManager:
|
|||||||
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
for help_line in AUTH_ERROR_HELP.strip().split('\n'):
|
||||||
await self._broadcast_output(help_line)
|
await self._broadcast_output(help_line)
|
||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
elif self.status == "running":
|
elif self.status in ("running", "pausing", "paused_graceful"):
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self._cleanup_stale_features()
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
# Clean up drain signal file if present
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def start(
|
async def start(
|
||||||
self,
|
self,
|
||||||
@@ -362,6 +374,7 @@ class AgentProcessManager:
|
|||||||
testing_agent_ratio: int = 1,
|
testing_agent_ratio: int = 1,
|
||||||
playwright_headless: bool = True,
|
playwright_headless: bool = True,
|
||||||
batch_size: int = 3,
|
batch_size: int = 3,
|
||||||
|
testing_batch_size: int = 3,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Start the agent as a subprocess.
|
Start the agent as a subprocess.
|
||||||
@@ -377,7 +390,7 @@ class AgentProcessManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
"""
|
"""
|
||||||
if self.status in ("running", "paused"):
|
if self.status in ("running", "paused", "pausing", "paused_graceful"):
|
||||||
return False, f"Agent is already {self.status}"
|
return False, f"Agent is already {self.status}"
|
||||||
|
|
||||||
if not self._check_lock():
|
if not self._check_lock():
|
||||||
@@ -428,6 +441,9 @@ class AgentProcessManager:
|
|||||||
# Add --batch-size flag for multi-feature batching
|
# Add --batch-size flag for multi-feature batching
|
||||||
cmd.extend(["--batch-size", str(batch_size)])
|
cmd.extend(["--batch-size", str(batch_size)])
|
||||||
|
|
||||||
|
# Add --testing-batch-size flag for testing agent batching
|
||||||
|
cmd.extend(["--testing-batch-size", str(testing_batch_size)])
|
||||||
|
|
||||||
# Apply headless setting to .playwright/cli.config.json so playwright-cli
|
# Apply headless setting to .playwright/cli.config.json so playwright-cli
|
||||||
# picks it up (the only mechanism it supports for headless control)
|
# picks it up (the only mechanism it supports for headless control)
|
||||||
self._apply_playwright_headless(playwright_headless)
|
self._apply_playwright_headless(playwright_headless)
|
||||||
@@ -526,6 +542,12 @@ class AgentProcessManager:
|
|||||||
|
|
||||||
self._cleanup_stale_features()
|
self._cleanup_stale_features()
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
# Clean up drain signal file if present
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self.process = None
|
self.process = None
|
||||||
self.started_at = None
|
self.started_at = None
|
||||||
@@ -586,6 +608,47 @@ class AgentProcessManager:
|
|||||||
logger.exception("Failed to resume agent")
|
logger.exception("Failed to resume agent")
|
||||||
return False, f"Failed to resume agent: {e}"
|
return False, f"Failed to resume agent: {e}"
|
||||||
|
|
||||||
|
async def graceful_pause(self) -> tuple[bool, str]:
|
||||||
|
"""Request a graceful pause (drain mode).
|
||||||
|
|
||||||
|
Creates a signal file that the orchestrator polls. Running agents
|
||||||
|
finish their current work before the orchestrator enters a paused state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
if not self.process or self.status not in ("running",):
|
||||||
|
return False, "Agent is not running"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
drain_path = get_pause_drain_path(self.project_dir)
|
||||||
|
drain_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
drain_path.write_text(str(self.process.pid))
|
||||||
|
self.status = "pausing"
|
||||||
|
return True, "Graceful pause requested"
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to request graceful pause")
|
||||||
|
return False, f"Failed to request graceful pause: {e}"
|
||||||
|
|
||||||
|
async def graceful_resume(self) -> tuple[bool, str]:
|
||||||
|
"""Resume from a graceful pause by removing the drain signal file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
if not self.process or self.status not in ("pausing", "paused_graceful"):
|
||||||
|
return False, "Agent is not in a graceful pause state"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
self.status = "running"
|
||||||
|
return True, "Agent resumed from graceful pause"
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to resume from graceful pause")
|
||||||
|
return False, f"Failed to resume: {e}"
|
||||||
|
|
||||||
async def healthcheck(self) -> bool:
|
async def healthcheck(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the agent process is still alive.
|
Check if the agent process is still alive.
|
||||||
@@ -601,8 +664,14 @@ class AgentProcessManager:
|
|||||||
poll = self.process.poll()
|
poll = self.process.poll()
|
||||||
if poll is not None:
|
if poll is not None:
|
||||||
# Process has terminated
|
# Process has terminated
|
||||||
if self.status in ("running", "paused"):
|
if self.status in ("running", "paused", "pausing", "paused_graceful"):
|
||||||
self._cleanup_stale_features()
|
self._cleanup_stale_features()
|
||||||
|
# Clean up drain signal file if present
|
||||||
|
try:
|
||||||
|
from autoforge_paths import get_pause_drain_path
|
||||||
|
get_pause_drain_path(self.project_dir).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.status = "crashed"
|
self.status = "crashed"
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
return False
|
return False
|
||||||
@@ -687,8 +756,14 @@ def cleanup_orphaned_locks() -> int:
|
|||||||
if not project_path.exists():
|
if not project_path.exists():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Clean up stale drain signal files
|
||||||
|
from autoforge_paths import get_autoforge_dir, get_pause_drain_path
|
||||||
|
drain_file = get_pause_drain_path(project_path)
|
||||||
|
if drain_file.exists():
|
||||||
|
drain_file.unlink(missing_ok=True)
|
||||||
|
logger.info("Removed stale drain signal file for project '%s'", name)
|
||||||
|
|
||||||
# Check both legacy and new locations for lock files
|
# Check both legacy and new locations for lock files
|
||||||
from autoforge_paths import get_autoforge_dir
|
|
||||||
lock_locations = [
|
lock_locations = [
|
||||||
project_path / ".agent.lock",
|
project_path / ".agent.lock",
|
||||||
get_autoforge_dir(project_path) / ".agent.lock",
|
get_autoforge_dir(project_path) / ".agent.lock",
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from ..schemas import ImageAttachment
|
from ..schemas import ImageAttachment
|
||||||
from .chat_constants import ROOT_DIR, make_multimodal_message
|
from .chat_constants import (
|
||||||
|
ROOT_DIR,
|
||||||
|
check_rate_limit_error,
|
||||||
|
make_multimodal_message,
|
||||||
|
safe_receive_response,
|
||||||
|
)
|
||||||
|
|
||||||
# Load environment variables from .env file if present
|
# Load environment variables from .env file if present
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -304,117 +309,125 @@ class SpecChatSession:
|
|||||||
# Store paths for the completion message
|
# Store paths for the completion message
|
||||||
spec_path = None
|
spec_path = None
|
||||||
|
|
||||||
# Stream the response using receive_response
|
# Stream the response
|
||||||
async for msg in self.client.receive_response():
|
try:
|
||||||
msg_type = type(msg).__name__
|
async for msg in safe_receive_response(self.client, logger):
|
||||||
|
msg_type = type(msg).__name__
|
||||||
|
|
||||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||||
# Process content blocks in the assistant message
|
# Process content blocks in the assistant message
|
||||||
for block in msg.content:
|
for block in msg.content:
|
||||||
block_type = type(block).__name__
|
block_type = type(block).__name__
|
||||||
|
|
||||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||||
# Accumulate text and yield it
|
# Accumulate text and yield it
|
||||||
text = block.text
|
text = block.text
|
||||||
if text:
|
if text:
|
||||||
current_text += text
|
current_text += text
|
||||||
yield {"type": "text", "content": text}
|
yield {"type": "text", "content": text}
|
||||||
|
|
||||||
# Store in message history
|
# Store in message history
|
||||||
self.messages.append({
|
self.messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": text,
|
"content": text,
|
||||||
"timestamp": datetime.now().isoformat()
|
"timestamp": datetime.now().isoformat()
|
||||||
})
|
})
|
||||||
|
|
||||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||||
tool_name = block.name
|
tool_name = block.name
|
||||||
tool_input = getattr(block, "input", {})
|
tool_input = getattr(block, "input", {})
|
||||||
tool_id = getattr(block, "id", "")
|
tool_id = getattr(block, "id", "")
|
||||||
|
|
||||||
if tool_name in ("Write", "Edit"):
|
if tool_name in ("Write", "Edit"):
|
||||||
# File being written or edited - track for verification
|
# File being written or edited - track for verification
|
||||||
file_path = tool_input.get("file_path", "")
|
file_path = tool_input.get("file_path", "")
|
||||||
|
|
||||||
# Track app_spec.txt
|
# Track app_spec.txt
|
||||||
if "app_spec.txt" in str(file_path):
|
if "app_spec.txt" in str(file_path):
|
||||||
pending_writes["app_spec"] = {
|
pending_writes["app_spec"] = {
|
||||||
"tool_id": tool_id,
|
"tool_id": tool_id,
|
||||||
"path": file_path
|
"path": file_path
|
||||||
}
|
|
||||||
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
|
|
||||||
|
|
||||||
# Track initializer_prompt.md
|
|
||||||
elif "initializer_prompt.md" in str(file_path):
|
|
||||||
pending_writes["initializer"] = {
|
|
||||||
"tool_id": tool_id,
|
|
||||||
"path": file_path
|
|
||||||
}
|
|
||||||
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
|
||||||
|
|
||||||
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
||||||
# Tool results - check for write confirmations and errors
|
|
||||||
for block in msg.content:
|
|
||||||
block_type = type(block).__name__
|
|
||||||
if block_type == "ToolResultBlock":
|
|
||||||
is_error = getattr(block, "is_error", False)
|
|
||||||
tool_use_id = getattr(block, "tool_use_id", "")
|
|
||||||
|
|
||||||
if is_error:
|
|
||||||
content = getattr(block, "content", "Unknown error")
|
|
||||||
logger.warning(f"Tool error: {content}")
|
|
||||||
# Clear any pending writes that failed
|
|
||||||
for key in pending_writes:
|
|
||||||
pending_write = pending_writes[key]
|
|
||||||
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
|
||||||
logger.error(f"{key} write failed: {content}")
|
|
||||||
pending_writes[key] = None
|
|
||||||
else:
|
|
||||||
# Tool succeeded - check which file was written
|
|
||||||
|
|
||||||
# Check app_spec.txt
|
|
||||||
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
|
||||||
file_path = pending_writes["app_spec"]["path"]
|
|
||||||
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
||||||
if full_path.exists():
|
|
||||||
logger.info(f"app_spec.txt verified at: {full_path}")
|
|
||||||
files_written["app_spec"] = True
|
|
||||||
spec_path = file_path
|
|
||||||
|
|
||||||
# Notify about file write (but NOT completion yet)
|
|
||||||
yield {
|
|
||||||
"type": "file_written",
|
|
||||||
"path": str(file_path)
|
|
||||||
}
|
}
|
||||||
else:
|
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
|
||||||
logger.error(f"app_spec.txt not found after write: {full_path}")
|
|
||||||
pending_writes["app_spec"] = None
|
|
||||||
|
|
||||||
# Check initializer_prompt.md
|
# Track initializer_prompt.md
|
||||||
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
|
elif "initializer_prompt.md" in str(file_path):
|
||||||
file_path = pending_writes["initializer"]["path"]
|
pending_writes["initializer"] = {
|
||||||
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
"tool_id": tool_id,
|
||||||
if full_path.exists():
|
"path": file_path
|
||||||
logger.info(f"initializer_prompt.md verified at: {full_path}")
|
|
||||||
files_written["initializer"] = True
|
|
||||||
|
|
||||||
# Notify about file write
|
|
||||||
yield {
|
|
||||||
"type": "file_written",
|
|
||||||
"path": str(file_path)
|
|
||||||
}
|
}
|
||||||
else:
|
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
||||||
logger.error(f"initializer_prompt.md not found after write: {full_path}")
|
|
||||||
pending_writes["initializer"] = None
|
|
||||||
|
|
||||||
# Check if BOTH files are now written - only then signal completion
|
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||||
if files_written["app_spec"] and files_written["initializer"]:
|
# Tool results - check for write confirmations and errors
|
||||||
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
|
for block in msg.content:
|
||||||
self.complete = True
|
block_type = type(block).__name__
|
||||||
yield {
|
if block_type == "ToolResultBlock":
|
||||||
"type": "spec_complete",
|
is_error = getattr(block, "is_error", False)
|
||||||
"path": str(spec_path)
|
tool_use_id = getattr(block, "tool_use_id", "")
|
||||||
}
|
|
||||||
|
if is_error:
|
||||||
|
content = getattr(block, "content", "Unknown error")
|
||||||
|
logger.warning(f"Tool error: {content}")
|
||||||
|
# Clear any pending writes that failed
|
||||||
|
for key in pending_writes:
|
||||||
|
pending_write = pending_writes[key]
|
||||||
|
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
||||||
|
logger.error(f"{key} write failed: {content}")
|
||||||
|
pending_writes[key] = None
|
||||||
|
else:
|
||||||
|
# Tool succeeded - check which file was written
|
||||||
|
|
||||||
|
# Check app_spec.txt
|
||||||
|
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
||||||
|
file_path = pending_writes["app_spec"]["path"]
|
||||||
|
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
||||||
|
if full_path.exists():
|
||||||
|
logger.info(f"app_spec.txt verified at: {full_path}")
|
||||||
|
files_written["app_spec"] = True
|
||||||
|
spec_path = file_path
|
||||||
|
|
||||||
|
# Notify about file write (but NOT completion yet)
|
||||||
|
yield {
|
||||||
|
"type": "file_written",
|
||||||
|
"path": str(file_path)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"app_spec.txt not found after write: {full_path}")
|
||||||
|
pending_writes["app_spec"] = None
|
||||||
|
|
||||||
|
# Check initializer_prompt.md
|
||||||
|
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
|
||||||
|
file_path = pending_writes["initializer"]["path"]
|
||||||
|
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
||||||
|
if full_path.exists():
|
||||||
|
logger.info(f"initializer_prompt.md verified at: {full_path}")
|
||||||
|
files_written["initializer"] = True
|
||||||
|
|
||||||
|
# Notify about file write
|
||||||
|
yield {
|
||||||
|
"type": "file_written",
|
||||||
|
"path": str(file_path)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.error(f"initializer_prompt.md not found after write: {full_path}")
|
||||||
|
pending_writes["initializer"] = None
|
||||||
|
|
||||||
|
# Check if BOTH files are now written - only then signal completion
|
||||||
|
if files_written["app_spec"] and files_written["initializer"]:
|
||||||
|
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
|
||||||
|
self.complete = True
|
||||||
|
yield {
|
||||||
|
"type": "spec_complete",
|
||||||
|
"path": str(spec_path)
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
is_rate_limit, _ = check_rate_limit_error(exc)
|
||||||
|
if is_rate_limit:
|
||||||
|
logger.warning(f"Rate limited: {exc}")
|
||||||
|
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
def is_complete(self) -> bool:
|
def is_complete(self) -> bool:
|
||||||
"""Check if spec creation is complete."""
|
"""Check if spec creation is complete."""
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ THOUGHT_PATTERNS = [
|
|||||||
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
|
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
|
||||||
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
|
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
|
||||||
# Test results
|
# Test results
|
||||||
(re.compile(r'(?:PASS|passed|success)', re.I), 'success'),
|
(re.compile(r'(?:PASS|passed|success)', re.I), 'testing'),
|
||||||
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -78,6 +78,9 @@ ORCHESTRATOR_PATTERNS = {
|
|||||||
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
|
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
|
||||||
'all_complete': re.compile(r'All features complete'),
|
'all_complete': re.compile(r'All features complete'),
|
||||||
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
|
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
|
||||||
|
'drain_start': re.compile(r'Graceful pause requested'),
|
||||||
|
'drain_complete': re.compile(r'All agents drained'),
|
||||||
|
'drain_resume': re.compile(r'Resuming from graceful pause'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -562,6 +565,30 @@ class OrchestratorTracker:
|
|||||||
'All features complete!'
|
'All features complete!'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Graceful pause (drain mode) events
|
||||||
|
elif ORCHESTRATOR_PATTERNS['drain_start'].search(line):
|
||||||
|
self.state = 'draining'
|
||||||
|
update = self._create_update(
|
||||||
|
'drain_start',
|
||||||
|
'Draining active agents...'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif ORCHESTRATOR_PATTERNS['drain_complete'].search(line):
|
||||||
|
self.state = 'paused'
|
||||||
|
self.coding_agents = 0
|
||||||
|
self.testing_agents = 0
|
||||||
|
update = self._create_update(
|
||||||
|
'drain_complete',
|
||||||
|
'All agents drained. Paused.'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif ORCHESTRATOR_PATTERNS['drain_resume'].search(line):
|
||||||
|
self.state = 'scheduling'
|
||||||
|
update = self._create_update(
|
||||||
|
'drain_resume',
|
||||||
|
'Resuming feature scheduling'
|
||||||
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|
||||||
def _create_update(
|
def _create_update(
|
||||||
@@ -689,15 +716,19 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
|
|||||||
last_in_progress = -1
|
last_in_progress = -1
|
||||||
last_total = -1
|
last_total = -1
|
||||||
|
|
||||||
|
last_needs_human_input = -1
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
|
||||||
|
|
||||||
# Only send if changed
|
# Only send if changed
|
||||||
if passing != last_passing or in_progress != last_in_progress or total != last_total:
|
if (passing != last_passing or in_progress != last_in_progress
|
||||||
|
or total != last_total or needs_human_input != last_needs_human_input):
|
||||||
last_passing = passing
|
last_passing = passing
|
||||||
last_in_progress = in_progress
|
last_in_progress = in_progress
|
||||||
last_total = total
|
last_total = total
|
||||||
|
last_needs_human_input = needs_human_input
|
||||||
percentage = (passing / total * 100) if total > 0 else 0
|
percentage = (passing / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@@ -706,6 +737,7 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
|
|||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": round(percentage, 1),
|
"percentage": round(percentage, 1),
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
})
|
})
|
||||||
|
|
||||||
await asyncio.sleep(2) # Poll every 2 seconds
|
await asyncio.sleep(2) # Poll every 2 seconds
|
||||||
@@ -858,7 +890,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
|
|
||||||
# Send initial progress
|
# Send initial progress
|
||||||
count_passing_tests = _get_count_passing_tests()
|
count_passing_tests = _get_count_passing_tests()
|
||||||
passing, in_progress, total = count_passing_tests(project_dir)
|
passing, in_progress, total, needs_human_input = count_passing_tests(project_dir)
|
||||||
percentage = (passing / total * 100) if total > 0 else 0
|
percentage = (passing / total * 100) if total > 0 else 0
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "progress",
|
"type": "progress",
|
||||||
@@ -866,6 +898,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": round(percentage, 1),
|
"percentage": round(percentage, 1),
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Keep connection alive and handle incoming messages
|
# Keep connection alive and handle incoming messages
|
||||||
|
|||||||
276
ui/package-lock.json
generated
276
ui/package-lock.json
generated
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"..": {
|
"..": {
|
||||||
"name": "autoforge-ai",
|
"name": "autoforge-ai",
|
||||||
"version": "0.1.12",
|
"version": "0.1.17",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"autoforge": "bin/autoforge.js"
|
"autoforge": "bin/autoforge.js"
|
||||||
@@ -1991,9 +1991,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2005,9 +2005,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2019,9 +2019,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2033,9 +2033,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2047,9 +2047,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2061,9 +2061,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2075,9 +2075,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2089,9 +2089,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2103,9 +2103,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2117,9 +2117,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2131,9 +2131,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -2145,9 +2159,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2159,9 +2187,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2173,9 +2201,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2187,9 +2215,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2201,9 +2229,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2215,9 +2243,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2228,10 +2256,24 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2243,9 +2285,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2257,9 +2299,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2271,9 +2313,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2285,9 +2327,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3042,24 +3084,37 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz",
|
||||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
"integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^2.0.1"
|
"brace-expansion": "^5.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
@@ -3227,9 +3282,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4757,9 +4812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
@@ -5664,9 +5719,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6150,9 +6205,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.54.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6166,28 +6221,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.54.0",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.54.0",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.54.0",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.54.0",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.54.0",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.54.0",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.54.0",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.54.0",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.54.0",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.54.0",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.54.0",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.54.0",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.54.0",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.54.0",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.54.0",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.54.0",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.54.0",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.54.0",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ function App() {
|
|||||||
const allFeatures = [
|
const allFeatures = [
|
||||||
...(features?.pending ?? []),
|
...(features?.pending ?? []),
|
||||||
...(features?.in_progress ?? []),
|
...(features?.in_progress ?? []),
|
||||||
...(features?.done ?? [])
|
...(features?.done ?? []),
|
||||||
|
...(features?.needs_human_input ?? [])
|
||||||
]
|
]
|
||||||
const feature = allFeatures.find(f => f.id === nodeId)
|
const feature = allFeatures.find(f => f.id === nodeId)
|
||||||
if (feature) setSelectedFeature(feature)
|
if (feature) setSelectedFeature(feature)
|
||||||
@@ -181,7 +182,7 @@ function App() {
|
|||||||
|
|
||||||
// E : Expand project with AI (when project selected, has spec and has features)
|
// E : Expand project with AI (when project selected, has spec and has features)
|
||||||
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
|
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
|
||||||
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
|
(features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowExpandProject(true)
|
setShowExpandProject(true)
|
||||||
}
|
}
|
||||||
@@ -210,8 +211,8 @@ function App() {
|
|||||||
setShowKeyboardHelp(true)
|
setShowKeyboardHelp(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// R : Open reset modal (when project selected and agent not running)
|
// R : Open reset modal (when project selected and agent not running/draining)
|
||||||
if ((e.key === 'r' || e.key === 'R') && selectedProject && wsState.agentStatus !== 'running') {
|
if ((e.key === 'r' || e.key === 'R') && selectedProject && !['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setShowResetModal(true)
|
setShowResetModal(true)
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,7 @@ function App() {
|
|||||||
// Combine WebSocket progress with feature data
|
// Combine WebSocket progress with feature data
|
||||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||||
passing: features?.done.length ?? 0,
|
passing: features?.done.length ?? 0,
|
||||||
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
|
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0) + (features?.needs_human_input?.length ?? 0),
|
||||||
percentage: 0,
|
percentage: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +381,7 @@ function App() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Reset Project"
|
aria-label="Reset Project"
|
||||||
disabled={wsState.agentStatus === 'running'}
|
disabled={['running', 'pausing', 'paused_graceful'].includes(wsState.agentStatus)}
|
||||||
>
|
>
|
||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -443,6 +444,7 @@ function App() {
|
|||||||
features.pending.length === 0 &&
|
features.pending.length === 0 &&
|
||||||
features.in_progress.length === 0 &&
|
features.in_progress.length === 0 &&
|
||||||
features.done.length === 0 &&
|
features.done.length === 0 &&
|
||||||
|
(features.needs_human_input?.length || 0) === 0 &&
|
||||||
wsState.agentStatus === 'running' && (
|
wsState.agentStatus === 'running' && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -458,7 +460,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View Toggle - only show when there are features */}
|
{/* View Toggle - only show when there are features */}
|
||||||
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
|
{features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0 && (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react'
|
import { Play, Square, Loader2, GitBranch, Clock, Pause, PlayCircle } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useStartAgent,
|
useStartAgent,
|
||||||
useStopAgent,
|
useStopAgent,
|
||||||
|
useGracefulPauseAgent,
|
||||||
|
useGracefulResumeAgent,
|
||||||
useSettings,
|
useSettings,
|
||||||
useUpdateProjectSettings,
|
useUpdateProjectSettings,
|
||||||
} from '../hooks/useProjects'
|
} from '../hooks/useProjects'
|
||||||
@@ -60,12 +62,14 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
|
|
||||||
const startAgent = useStartAgent(projectName)
|
const startAgent = useStartAgent(projectName)
|
||||||
const stopAgent = useStopAgent(projectName)
|
const stopAgent = useStopAgent(projectName)
|
||||||
|
const gracefulPause = useGracefulPauseAgent(projectName)
|
||||||
|
const gracefulResume = useGracefulResumeAgent(projectName)
|
||||||
const { data: nextRun } = useNextScheduledRun(projectName)
|
const { data: nextRun } = useNextScheduledRun(projectName)
|
||||||
|
|
||||||
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||||
|
|
||||||
const isLoading = startAgent.isPending || stopAgent.isPending
|
const isLoading = startAgent.isPending || stopAgent.isPending || gracefulPause.isPending || gracefulResume.isPending
|
||||||
const isRunning = status === 'running' || status === 'paused'
|
const isRunning = status === 'running' || status === 'paused' || status === 'pausing' || status === 'paused_graceful'
|
||||||
const isLoadingStatus = status === 'loading'
|
const isLoadingStatus = status === 'loading'
|
||||||
const isParallel = concurrency > 1
|
const isParallel = concurrency > 1
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Start/Stop button */}
|
{/* Start/Stop/Pause/Resume buttons */}
|
||||||
{isLoadingStatus ? (
|
{isLoadingStatus ? (
|
||||||
<Button disabled variant="outline" size="sm">
|
<Button disabled variant="outline" size="sm">
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
@@ -146,19 +150,69 @@ export function AgentControl({ projectName, status, defaultConcurrency = 3 }: Ag
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={handleStop}
|
{/* Pausing indicator */}
|
||||||
disabled={isLoading}
|
{status === 'pausing' && (
|
||||||
variant="destructive"
|
<Badge variant="secondary" className="gap-1 animate-pulse">
|
||||||
size="sm"
|
<Loader2 size={12} className="animate-spin" />
|
||||||
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
|
Pausing...
|
||||||
>
|
</Badge>
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Square size={18} />
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
|
{/* Paused indicator + Resume button */}
|
||||||
|
{status === 'paused_graceful' && (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
Paused
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={() => gracefulResume.mutate()}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
title="Resume agent"
|
||||||
|
>
|
||||||
|
{gracefulResume.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle size={18} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Graceful pause button (only when running normally) */}
|
||||||
|
{status === 'running' && (
|
||||||
|
<Button
|
||||||
|
onClick={() => gracefulPause.mutate()}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
title="Pause agent (finish current work first)"
|
||||||
|
>
|
||||||
|
{gracefulPause.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Pause size={18} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stop button (always available) */}
|
||||||
|
<Button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
title="Stop Agent (immediate)"
|
||||||
|
>
|
||||||
|
{stopAgent.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square size={18} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Clock button to open schedule modal */}
|
{/* Clock button to open schedule modal */}
|
||||||
|
|||||||
@@ -72,9 +72,13 @@ export function AgentMissionControl({
|
|||||||
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
|
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
|
||||||
: orchestratorStatus?.state === 'initializing'
|
: orchestratorStatus?.state === 'initializing'
|
||||||
? 'Initializing'
|
? 'Initializing'
|
||||||
: orchestratorStatus?.state === 'complete'
|
: orchestratorStatus?.state === 'draining'
|
||||||
? 'Complete'
|
? 'Draining'
|
||||||
: 'Orchestrating'
|
: orchestratorStatus?.state === 'paused'
|
||||||
|
? 'Paused'
|
||||||
|
: orchestratorStatus?.state === 'complete'
|
||||||
|
? 'Complete'
|
||||||
|
: 'Orchestrating'
|
||||||
}
|
}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function AgentThought({ logs, agentStatus }: AgentThoughtProps) {
|
|||||||
// Determine if component should be visible
|
// Determine if component should be visible
|
||||||
const shouldShow = useMemo(() => {
|
const shouldShow = useMemo(() => {
|
||||||
if (!thought) return false
|
if (!thought) return false
|
||||||
if (agentStatus === 'running') return true
|
if (agentStatus === 'running' || agentStatus === 'pausing') return true
|
||||||
if (agentStatus === 'paused') {
|
if (agentStatus === 'paused') {
|
||||||
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
|
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Handle,
|
Handle,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import dagre from 'dagre'
|
import dagre from 'dagre'
|
||||||
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react'
|
import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw, UserCircle } from 'lucide-react'
|
||||||
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
|
import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
|
|||||||
|
|
||||||
// Custom node component
|
// Custom node component
|
||||||
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
||||||
const statusColors = {
|
const statusColors: Record<string, string> = {
|
||||||
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
|
pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700',
|
||||||
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
|
in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700',
|
||||||
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
|
done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700',
|
||||||
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
|
blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700',
|
||||||
|
needs_human_input: 'bg-amber-100 border-amber-300 dark:bg-amber-900/30 dark:border-amber-700',
|
||||||
}
|
}
|
||||||
|
|
||||||
const textColors = {
|
const textColors: Record<string, string> = {
|
||||||
pending: 'text-yellow-900 dark:text-yellow-100',
|
pending: 'text-yellow-900 dark:text-yellow-100',
|
||||||
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
||||||
done: 'text-green-900 dark:text-green-100',
|
done: 'text-green-900 dark:text-green-100',
|
||||||
blocked: 'text-red-900 dark:text-red-100',
|
blocked: 'text-red-900 dark:text-red-100',
|
||||||
|
needs_human_input: 'text-amber-900 dark:text-amber-100',
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusIcon = () => {
|
const StatusIcon = () => {
|
||||||
@@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
|
|||||||
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return <AlertTriangle size={16} className="text-destructive" />
|
return <AlertTriangle size={16} className="text-destructive" />
|
||||||
|
case 'needs_human_input':
|
||||||
|
return <UserCircle size={16} className={textColors[data.status]} />
|
||||||
default:
|
default:
|
||||||
return <Circle size={16} className={textColors[data.status]} />
|
return <Circle size={16} className={textColors[data.status]} />
|
||||||
}
|
}
|
||||||
@@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
|
|||||||
return '#06b6d4' // cyan-500
|
return '#06b6d4' // cyan-500
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return '#ef4444' // red-500
|
return '#ef4444' // red-500
|
||||||
|
case 'needs_human_input':
|
||||||
|
return '#f59e0b' // amber-500
|
||||||
default:
|
default:
|
||||||
return '#eab308' // yellow-500
|
return '#eab308' // yellow-500
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
|
import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react'
|
||||||
import type { Feature, ActiveAgent } from '../lib/types'
|
import type { Feature, ActiveAgent } from '../lib/types'
|
||||||
import { DependencyBadge } from './DependencyBadge'
|
import { DependencyBadge } from './DependencyBadge'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
@@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
|||||||
cursor-pointer transition-all hover:border-primary py-3
|
cursor-pointer transition-all hover:border-primary py-3
|
||||||
${isInProgress ? 'animate-pulse' : ''}
|
${isInProgress ? 'animate-pulse' : ''}
|
||||||
${feature.passes ? 'border-primary/50' : ''}
|
${feature.passes ? 'border-primary/50' : ''}
|
||||||
${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''}
|
${feature.needs_human_input ? 'border-amber-500/50' : ''}
|
||||||
|
${isBlocked && !feature.passes && !feature.needs_human_input ? 'border-destructive/50 opacity-80' : ''}
|
||||||
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
|
${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -105,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
|||||||
<CheckCircle2 size={16} className="text-primary" />
|
<CheckCircle2 size={16} className="text-primary" />
|
||||||
<span className="text-primary font-medium">Complete</span>
|
<span className="text-primary font-medium">Complete</span>
|
||||||
</>
|
</>
|
||||||
|
) : feature.needs_human_input ? (
|
||||||
|
<>
|
||||||
|
<UserCircle size={16} className="text-amber-500" />
|
||||||
|
<span className="text-amber-500 font-medium">Needs Your Input</span>
|
||||||
|
</>
|
||||||
) : isBlocked ? (
|
) : isBlocked ? (
|
||||||
<>
|
<>
|
||||||
<Circle size={16} className="text-destructive" />
|
<Circle size={16} className="text-destructive" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
|
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react'
|
||||||
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
|
import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects'
|
||||||
import { EditFeatureForm } from './EditFeatureForm'
|
import { EditFeatureForm } from './EditFeatureForm'
|
||||||
|
import { HumanInputForm } from './HumanInputForm'
|
||||||
import type { Feature } from '../lib/types'
|
import type { Feature } from '../lib/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
const deleteFeature = useDeleteFeature(projectName)
|
const deleteFeature = useDeleteFeature(projectName)
|
||||||
const { data: allFeatures } = useFeatures(projectName)
|
const { data: allFeatures } = useFeatures(projectName)
|
||||||
|
|
||||||
|
const resolveHumanInput = useResolveHumanInput(projectName)
|
||||||
|
|
||||||
// Build a map of feature ID to feature for looking up dependency names
|
// Build a map of feature ID to feature for looking up dependency names
|
||||||
const featureMap = new Map<number, Feature>()
|
const featureMap = new Map<number, Feature>()
|
||||||
if (allFeatures) {
|
if (allFeatures) {
|
||||||
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => {
|
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => {
|
||||||
featureMap.set(f.id, f)
|
featureMap.set(f.id, f)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
<CheckCircle2 size={24} className="text-primary" />
|
<CheckCircle2 size={24} className="text-primary" />
|
||||||
<span className="font-semibold text-primary">COMPLETE</span>
|
<span className="font-semibold text-primary">COMPLETE</span>
|
||||||
</>
|
</>
|
||||||
|
) : feature.needs_human_input ? (
|
||||||
|
<>
|
||||||
|
<UserCircle size={24} className="text-amber-500" />
|
||||||
|
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Circle size={24} className="text-muted-foreground" />
|
<Circle size={24} className="text-muted-foreground" />
|
||||||
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Human Input Request */}
|
||||||
|
{feature.needs_human_input && feature.human_input_request && (
|
||||||
|
<HumanInputForm
|
||||||
|
request={feature.human_input_request}
|
||||||
|
onSubmit={async (fields) => {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await resolveHumanInput.mutateAsync({ featureId: feature.id, fields })
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to submit response')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isLoading={resolveHumanInput.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Previous Human Input Response */}
|
||||||
|
{feature.human_input_response && !feature.needs_human_input && (
|
||||||
|
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/20">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription>
|
||||||
|
<h4 className="font-semibold mb-1 text-green-700 dark:text-green-400">Human Input Provided</h4>
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-300">
|
||||||
|
Response submitted{feature.human_input_response.responded_at
|
||||||
|
? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}`
|
||||||
|
: ''}.
|
||||||
|
</p>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
||||||
|
|||||||
150
ui/src/components/HumanInputForm.tsx
Normal file
150
ui/src/components/HumanInputForm.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Loader2, UserCircle, Send } from 'lucide-react'
|
||||||
|
import type { HumanInputRequest } from '../lib/types'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
|
interface HumanInputFormProps {
|
||||||
|
request: HumanInputRequest
|
||||||
|
onSubmit: (fields: Record<string, string | boolean | string[]>) => Promise<void>
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) {
|
||||||
|
const [values, setValues] = useState<Record<string, string | boolean | string[]>>(() => {
|
||||||
|
const initial: Record<string, string | boolean | string[]> = {}
|
||||||
|
for (const field of request.fields) {
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
initial[field.id] = false
|
||||||
|
} else {
|
||||||
|
initial[field.id] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return initial
|
||||||
|
})
|
||||||
|
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validate required fields
|
||||||
|
for (const field of request.fields) {
|
||||||
|
if (field.required) {
|
||||||
|
const val = values[field.id]
|
||||||
|
if (val === undefined || val === null || val === '') {
|
||||||
|
setValidationError(`"${field.label}" is required`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValidationError(null)
|
||||||
|
await onSubmit(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert className="border-amber-500 bg-amber-50 dark:bg-amber-950/20">
|
||||||
|
<UserCircle className="h-5 w-5 text-amber-600" />
|
||||||
|
<AlertDescription className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-amber-700 dark:text-amber-400">Agent needs your help</h4>
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
|
||||||
|
{request.prompt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{request.fields.map((field) => (
|
||||||
|
<div key={field.id} className="space-y-1.5">
|
||||||
|
<Label htmlFor={`human-input-${field.id}`} className="text-sm font-medium text-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{field.type === 'text' && (
|
||||||
|
<Input
|
||||||
|
id={`human-input-${field.id}`}
|
||||||
|
value={values[field.id] as string}
|
||||||
|
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'textarea' && (
|
||||||
|
<Textarea
|
||||||
|
id={`human-input-${field.id}`}
|
||||||
|
value={values[field.id] as string}
|
||||||
|
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder || ''}
|
||||||
|
disabled={isLoading}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'select' && field.options && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{field.options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors
|
||||||
|
${values[field.id] === option.value
|
||||||
|
? 'border-primary bg-primary/10'
|
||||||
|
: 'border-border hover:border-primary/50'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`human-input-${field.id}`}
|
||||||
|
value={option.value}
|
||||||
|
checked={values[field.id] === option.value}
|
||||||
|
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.type === 'boolean' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id={`human-input-${field.id}`}
|
||||||
|
checked={values[field.id] as boolean}
|
||||||
|
onCheckedChange={(checked) => setValues(prev => ({ ...prev, [field.id]: checked }))}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`human-input-${field.id}`} className="text-sm">
|
||||||
|
{values[field.id] ? 'Yes' : 'No'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<p className="text-sm text-destructive">{validationError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={16} />
|
||||||
|
Submit Response
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,13 +13,16 @@ interface KanbanBoardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
|
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
|
||||||
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
|
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0
|
||||||
|
|
||||||
// Combine all features for dependency status calculation
|
// Combine all features for dependency status calculation
|
||||||
const allFeatures = features
|
const allFeatures = features
|
||||||
? [...features.pending, ...features.in_progress, ...features.done]
|
? [...features.pending, ...features.in_progress, ...features.done, ...(features.needs_human_input || [])]
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const needsInputCount = features?.needs_human_input?.length || 0
|
||||||
|
const showNeedsInput = needsInputCount > 0
|
||||||
|
|
||||||
if (!features) {
|
if (!features) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className={`grid grid-cols-1 ${showNeedsInput ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6`}>
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
title="Pending"
|
title="Pending"
|
||||||
count={features.pending.length}
|
count={features.pending.length}
|
||||||
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
color="progress"
|
color="progress"
|
||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
/>
|
/>
|
||||||
|
{showNeedsInput && (
|
||||||
|
<KanbanColumn
|
||||||
|
title="Needs Input"
|
||||||
|
count={needsInputCount}
|
||||||
|
features={features.needs_human_input}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgents={activeAgents}
|
||||||
|
color="human_input"
|
||||||
|
onFeatureClick={onFeatureClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
title="Done"
|
title="Done"
|
||||||
count={features.done.length}
|
count={features.done.length}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface KanbanColumnProps {
|
|||||||
features: Feature[]
|
features: Feature[]
|
||||||
allFeatures?: Feature[]
|
allFeatures?: Feature[]
|
||||||
activeAgents?: ActiveAgent[]
|
activeAgents?: ActiveAgent[]
|
||||||
color: 'pending' | 'progress' | 'done'
|
color: 'pending' | 'progress' | 'done' | 'human_input'
|
||||||
onFeatureClick: (feature: Feature) => void
|
onFeatureClick: (feature: Feature) => void
|
||||||
onAddFeature?: () => void
|
onAddFeature?: () => void
|
||||||
onExpandProject?: () => void
|
onExpandProject?: () => void
|
||||||
@@ -24,6 +24,7 @@ const colorMap = {
|
|||||||
pending: 'border-t-4 border-t-muted',
|
pending: 'border-t-4 border-t-muted',
|
||||||
progress: 'border-t-4 border-t-primary',
|
progress: 'border-t-4 border-t-primary',
|
||||||
done: 'border-t-4 border-t-primary',
|
done: 'border-t-4 border-t-primary',
|
||||||
|
human_input: 'border-t-4 border-t-amber-500',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanColumn({
|
export function KanbanColumn({
|
||||||
|
|||||||
@@ -4,14 +4,15 @@
|
|||||||
* Multi-step modal for creating new projects:
|
* Multi-step modal for creating new projects:
|
||||||
* 1. Enter project name
|
* 1. Enter project name
|
||||||
* 2. Select project folder
|
* 2. Select project folder
|
||||||
* 3. Choose spec method (Claude or manual)
|
* 3. Choose project template (blank or agentic starter)
|
||||||
* 4a. If Claude: Show SpecCreationChat
|
* 4. Choose spec method (Claude or manual)
|
||||||
* 4b. If manual: Create project and close
|
* 5a. If Claude: Show SpecCreationChat
|
||||||
|
* 5b. If manual: Create project and close
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder } from 'lucide-react'
|
import { Bot, FileEdit, ArrowRight, ArrowLeft, Loader2, CheckCircle2, Folder, Zap, FileCode2, AlertCircle, RotateCcw } from 'lucide-react'
|
||||||
import { useCreateProject } from '../hooks/useProjects'
|
import { useCreateProject } from '../hooks/useProjects'
|
||||||
import { SpecCreationChat } from './SpecCreationChat'
|
import { SpecCreationChat } from './SpecCreationChat'
|
||||||
import { FolderBrowser } from './FolderBrowser'
|
import { FolderBrowser } from './FolderBrowser'
|
||||||
@@ -32,8 +33,9 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
|
||||||
type InitializerStatus = 'idle' | 'starting' | 'error'
|
type InitializerStatus = 'idle' | 'starting' | 'error'
|
||||||
|
type ScaffoldStatus = 'idle' | 'running' | 'success' | 'error'
|
||||||
|
|
||||||
type Step = 'name' | 'folder' | 'method' | 'chat' | 'complete'
|
type Step = 'name' | 'folder' | 'template' | 'method' | 'chat' | 'complete'
|
||||||
type SpecMethod = 'claude' | 'manual'
|
type SpecMethod = 'claude' | 'manual'
|
||||||
|
|
||||||
interface NewProjectModalProps {
|
interface NewProjectModalProps {
|
||||||
@@ -57,6 +59,10 @@ export function NewProjectModal({
|
|||||||
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
|
const [initializerStatus, setInitializerStatus] = useState<InitializerStatus>('idle')
|
||||||
const [initializerError, setInitializerError] = useState<string | null>(null)
|
const [initializerError, setInitializerError] = useState<string | null>(null)
|
||||||
const [yoloModeSelected, setYoloModeSelected] = useState(false)
|
const [yoloModeSelected, setYoloModeSelected] = useState(false)
|
||||||
|
const [scaffoldStatus, setScaffoldStatus] = useState<ScaffoldStatus>('idle')
|
||||||
|
const [scaffoldOutput, setScaffoldOutput] = useState<string[]>([])
|
||||||
|
const [scaffoldError, setScaffoldError] = useState<string | null>(null)
|
||||||
|
const scaffoldLogRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Suppress unused variable warning - specMethod may be used in future
|
// Suppress unused variable warning - specMethod may be used in future
|
||||||
void _specMethod
|
void _specMethod
|
||||||
@@ -91,13 +97,84 @@ export function NewProjectModal({
|
|||||||
|
|
||||||
const handleFolderSelect = (path: string) => {
|
const handleFolderSelect = (path: string) => {
|
||||||
setProjectPath(path)
|
setProjectPath(path)
|
||||||
changeStep('method')
|
changeStep('template')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFolderCancel = () => {
|
const handleFolderCancel = () => {
|
||||||
changeStep('name')
|
changeStep('name')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTemplateSelect = async (choice: 'blank' | 'agentic-starter') => {
|
||||||
|
if (choice === 'blank') {
|
||||||
|
changeStep('method')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectPath) return
|
||||||
|
|
||||||
|
setScaffoldStatus('running')
|
||||||
|
setScaffoldOutput([])
|
||||||
|
setScaffoldError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/scaffold/run', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ template: 'agentic-starter', target_path: projectPath }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
setScaffoldStatus('error')
|
||||||
|
setScaffoldError(`Server error: ${res.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line.slice(6))
|
||||||
|
if (event.type === 'output') {
|
||||||
|
setScaffoldOutput(prev => {
|
||||||
|
const next = [...prev, event.line]
|
||||||
|
return next.length > 100 ? next.slice(-100) : next
|
||||||
|
})
|
||||||
|
// Auto-scroll
|
||||||
|
setTimeout(() => scaffoldLogRef.current?.scrollTo(0, scaffoldLogRef.current.scrollHeight), 0)
|
||||||
|
} else if (event.type === 'complete') {
|
||||||
|
if (event.success) {
|
||||||
|
setScaffoldStatus('success')
|
||||||
|
setTimeout(() => changeStep('method'), 1200)
|
||||||
|
} else {
|
||||||
|
setScaffoldStatus('error')
|
||||||
|
setScaffoldError(`Scaffold exited with code ${event.exit_code}`)
|
||||||
|
}
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
setScaffoldStatus('error')
|
||||||
|
setScaffoldError(event.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed SSE lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setScaffoldStatus('error')
|
||||||
|
setScaffoldError(err instanceof Error ? err.message : 'Failed to run scaffold')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleMethodSelect = async (method: SpecMethod) => {
|
const handleMethodSelect = async (method: SpecMethod) => {
|
||||||
setSpecMethod(method)
|
setSpecMethod(method)
|
||||||
|
|
||||||
@@ -188,13 +265,21 @@ export function NewProjectModal({
|
|||||||
setInitializerStatus('idle')
|
setInitializerStatus('idle')
|
||||||
setInitializerError(null)
|
setInitializerError(null)
|
||||||
setYoloModeSelected(false)
|
setYoloModeSelected(false)
|
||||||
|
setScaffoldStatus('idle')
|
||||||
|
setScaffoldOutput([])
|
||||||
|
setScaffoldError(null)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (step === 'method') {
|
if (step === 'method') {
|
||||||
changeStep('folder')
|
changeStep('template')
|
||||||
setSpecMethod(null)
|
setSpecMethod(null)
|
||||||
|
} else if (step === 'template') {
|
||||||
|
changeStep('folder')
|
||||||
|
setScaffoldStatus('idle')
|
||||||
|
setScaffoldOutput([])
|
||||||
|
setScaffoldError(null)
|
||||||
} else if (step === 'folder') {
|
} else if (step === 'folder') {
|
||||||
changeStep('name')
|
changeStep('name')
|
||||||
setProjectPath(null)
|
setProjectPath(null)
|
||||||
@@ -255,6 +340,7 @@ export function NewProjectModal({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{step === 'name' && 'Create New Project'}
|
{step === 'name' && 'Create New Project'}
|
||||||
|
{step === 'template' && 'Choose Project Template'}
|
||||||
{step === 'method' && 'Choose Setup Method'}
|
{step === 'method' && 'Choose Setup Method'}
|
||||||
{step === 'complete' && 'Project Created!'}
|
{step === 'complete' && 'Project Created!'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -294,7 +380,127 @@ export function NewProjectModal({
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Spec Method */}
|
{/* Step 2: Project Template */}
|
||||||
|
{step === 'template' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{scaffoldStatus === 'idle' && (
|
||||||
|
<>
|
||||||
|
<DialogDescription>
|
||||||
|
Start with a blank project or use a pre-configured template.
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:border-primary transition-colors"
|
||||||
|
onClick={() => handleTemplateSelect('blank')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-secondary rounded-lg">
|
||||||
|
<FileCode2 size={24} className="text-secondary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="font-semibold">Blank Project</span>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Start from scratch. AutoForge will scaffold your app based on the spec you define.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer hover:border-primary transition-colors"
|
||||||
|
onClick={() => handleTemplateSelect('agentic-starter')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Zap size={24} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Agentic Starter</span>
|
||||||
|
<Badge variant="secondary">Next.js</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Pre-configured Next.js app with BetterAuth, Drizzle ORM, Postgres, and AI capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-start">
|
||||||
|
<Button variant="ghost" onClick={handleBack}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scaffoldStatus === 'running' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 size={16} className="animate-spin text-primary" />
|
||||||
|
<span className="font-medium">Setting up Agentic Starter...</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scaffoldLogRef}
|
||||||
|
className="bg-muted rounded-lg p-3 max-h-60 overflow-y-auto font-mono text-xs leading-relaxed"
|
||||||
|
>
|
||||||
|
{scaffoldOutput.map((line, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scaffoldStatus === 'success' && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-primary/10 rounded-full mb-3">
|
||||||
|
<CheckCircle2 size={24} className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">Template ready!</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Proceeding to setup method...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scaffoldStatus === 'error' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<AlertDescription>
|
||||||
|
{scaffoldError || 'An unknown error occurred'}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{scaffoldOutput.length > 0 && (
|
||||||
|
<div className="bg-muted rounded-lg p-3 max-h-40 overflow-y-auto font-mono text-xs leading-relaxed">
|
||||||
|
{scaffoldOutput.slice(-10).map((line, i) => (
|
||||||
|
<div key={i} className="whitespace-pre-wrap break-all">{line}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="sm:justify-start gap-2">
|
||||||
|
<Button variant="ghost" onClick={handleBack}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => handleTemplateSelect('agentic-starter')}>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Spec Method */}
|
||||||
{step === 'method' && (
|
{step === 'method' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ function getStateAnimation(state: OrchestratorState): string {
|
|||||||
return 'animate-working'
|
return 'animate-working'
|
||||||
case 'monitoring':
|
case 'monitoring':
|
||||||
return 'animate-bounce-gentle'
|
return 'animate-bounce-gentle'
|
||||||
|
case 'draining':
|
||||||
|
return 'animate-thinking'
|
||||||
|
case 'paused':
|
||||||
|
return ''
|
||||||
case 'complete':
|
case 'complete':
|
||||||
return 'animate-celebrate'
|
return 'animate-celebrate'
|
||||||
default:
|
default:
|
||||||
@@ -121,6 +125,10 @@ function getStateGlow(state: OrchestratorState): string {
|
|||||||
return 'shadow-[0_0_16px_rgba(124,58,237,0.6)]'
|
return 'shadow-[0_0_16px_rgba(124,58,237,0.6)]'
|
||||||
case 'monitoring':
|
case 'monitoring':
|
||||||
return 'shadow-[0_0_8px_rgba(167,139,250,0.4)]'
|
return 'shadow-[0_0_8px_rgba(167,139,250,0.4)]'
|
||||||
|
case 'draining':
|
||||||
|
return 'shadow-[0_0_10px_rgba(251,191,36,0.5)]'
|
||||||
|
case 'paused':
|
||||||
|
return ''
|
||||||
case 'complete':
|
case 'complete':
|
||||||
return 'shadow-[0_0_20px_rgba(112,224,0,0.6)]'
|
return 'shadow-[0_0_20px_rgba(112,224,0,0.6)]'
|
||||||
default:
|
default:
|
||||||
@@ -141,6 +149,10 @@ function getStateDescription(state: OrchestratorState): string {
|
|||||||
return 'spawning agents'
|
return 'spawning agents'
|
||||||
case 'monitoring':
|
case 'monitoring':
|
||||||
return 'monitoring progress'
|
return 'monitoring progress'
|
||||||
|
case 'draining':
|
||||||
|
return 'draining active agents'
|
||||||
|
case 'paused':
|
||||||
|
return 'paused'
|
||||||
case 'complete':
|
case 'complete':
|
||||||
return 'all features complete'
|
return 'all features complete'
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ function getStateText(state: OrchestratorState): string {
|
|||||||
return 'Watching progress...'
|
return 'Watching progress...'
|
||||||
case 'complete':
|
case 'complete':
|
||||||
return 'Mission accomplished!'
|
return 'Mission accomplished!'
|
||||||
|
case 'draining':
|
||||||
|
return 'Draining agents...'
|
||||||
|
case 'paused':
|
||||||
|
return 'Paused'
|
||||||
default:
|
default:
|
||||||
return 'Orchestrating...'
|
return 'Orchestrating...'
|
||||||
}
|
}
|
||||||
@@ -42,6 +46,10 @@ function getStateColor(state: OrchestratorState): string {
|
|||||||
return 'text-primary'
|
return 'text-primary'
|
||||||
case 'initializing':
|
case 'initializing':
|
||||||
return 'text-yellow-600 dark:text-yellow-400'
|
return 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
case 'draining':
|
||||||
|
return 'text-amber-600 dark:text-amber-400'
|
||||||
|
case 'paused':
|
||||||
|
return 'text-muted-foreground'
|
||||||
default:
|
default:
|
||||||
return 'text-muted-foreground'
|
return 'text-muted-foreground'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function ProgressDashboard({
|
|||||||
|
|
||||||
const showThought = useMemo(() => {
|
const showThought = useMemo(() => {
|
||||||
if (!thought) return false
|
if (!thought) return false
|
||||||
if (agentStatus === 'running') return true
|
if (agentStatus === 'running' || agentStatus === 'pausing') return true
|
||||||
if (agentStatus === 'paused') {
|
if (agentStatus === 'paused') {
|
||||||
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
|
return Date.now() - lastLogTimestamp < IDLE_TIMEOUT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -63,6 +64,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTestingBatchSizeChange = (size: number) => {
|
||||||
|
if (!updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ testing_batch_size: size })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleProviderChange = (providerId: string) => {
|
const handleProviderChange = (providerId: string) => {
|
||||||
if (!updateSettings.isPending) {
|
if (!updateSettings.isPending) {
|
||||||
updateSettings.mutate({ api_provider: providerId })
|
updateSettings.mutate({ api_provider: providerId })
|
||||||
@@ -432,28 +439,34 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features per Agent */}
|
{/* Features per Coding Agent */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="font-medium">Features per Agent</Label>
|
<Label className="font-medium">Features per Coding Agent</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Number of features assigned to each coding agent
|
Number of features assigned to each coding agent session
|
||||||
</p>
|
</p>
|
||||||
<div className="flex rounded-lg border overflow-hidden">
|
<Slider
|
||||||
{[1, 2, 3].map((size) => (
|
min={1}
|
||||||
<button
|
max={15}
|
||||||
key={size}
|
value={settings.batch_size ?? 3}
|
||||||
onClick={() => handleBatchSizeChange(size)}
|
onChange={handleBatchSizeChange}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
className={`flex-1 py-2 px-3 text-sm font-medium transition-colors ${
|
/>
|
||||||
(settings.batch_size ?? 1) === size
|
</div>
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-background text-foreground hover:bg-muted'
|
{/* Features per Testing Agent */}
|
||||||
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
<div className="space-y-2">
|
||||||
>
|
<Label className="font-medium">Features per Testing Agent</Label>
|
||||||
{size}
|
<p className="text-sm text-muted-foreground">
|
||||||
</button>
|
Number of features assigned to each testing agent session
|
||||||
))}
|
</p>
|
||||||
</div>
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={15}
|
||||||
|
value={settings.testing_batch_size ?? 3}
|
||||||
|
onChange={handleTestingBatchSizeChange}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Update Error */}
|
{/* Update Error */}
|
||||||
|
|||||||
44
ui/src/components/ui/slider.tsx
Normal file
44
ui/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
value: number
|
||||||
|
onChange: (value: number) => void
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: SliderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-3", className)}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"slider-input h-2 w-full cursor-pointer appearance-none rounded-full bg-input transition-colors",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
disabled && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<span className="min-w-[2ch] text-center text-sm font-semibold tabular-nums">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -137,6 +137,7 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean {
|
|||||||
return (
|
return (
|
||||||
features.pending.length === 0 &&
|
features.pending.length === 0 &&
|
||||||
features.in_progress.length === 0 &&
|
features.in_progress.length === 0 &&
|
||||||
|
(features.needs_human_input?.length || 0) === 0 &&
|
||||||
features.done.length > 0
|
features.done.length > 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ export function useUpdateFeature(projectName: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useResolveHumanInput(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ featureId, fields }: { featureId: number; fields: Record<string, string | boolean | string[]> }) =>
|
||||||
|
api.resolveHumanInput(projectName, featureId, { fields }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent
|
// Agent
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -197,6 +209,28 @@ export function useResumeAgent(projectName: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGracefulPauseAgent(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.gracefulPauseAgent(projectName),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGracefulResumeAgent(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.gracefulResumeAgent(projectName),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Setup
|
// Setup
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -268,6 +302,7 @@ const DEFAULT_SETTINGS: Settings = {
|
|||||||
testing_agent_ratio: 1,
|
testing_agent_ratio: 1,
|
||||||
playwright_headless: true,
|
playwright_headless: true,
|
||||||
batch_size: 3,
|
batch_size: 3,
|
||||||
|
testing_batch_size: 3,
|
||||||
api_provider: 'claude',
|
api_provider: 'claude',
|
||||||
api_base_url: null,
|
api_base_url: null,
|
||||||
api_has_auth_token: false,
|
api_has_auth_token: false,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface WebSocketState {
|
|||||||
progress: {
|
progress: {
|
||||||
passing: number
|
passing: number
|
||||||
in_progress: number
|
in_progress: number
|
||||||
|
needs_human_input: number
|
||||||
total: number
|
total: number
|
||||||
percentage: number
|
percentage: number
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
|
|||||||
|
|
||||||
export function useProjectWebSocket(projectName: string | null) {
|
export function useProjectWebSocket(projectName: string | null) {
|
||||||
const [state, setState] = useState<WebSocketState>({
|
const [state, setState] = useState<WebSocketState>({
|
||||||
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
|
progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
|
||||||
agentStatus: 'loading',
|
agentStatus: 'loading',
|
||||||
logs: [],
|
logs: [],
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -107,6 +108,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
progress: {
|
progress: {
|
||||||
passing: message.passing,
|
passing: message.passing,
|
||||||
in_progress: message.in_progress,
|
in_progress: message.in_progress,
|
||||||
|
needs_human_input: message.needs_human_input ?? 0,
|
||||||
total: message.total,
|
total: message.total,
|
||||||
percentage: message.percentage,
|
percentage: message.percentage,
|
||||||
},
|
},
|
||||||
@@ -385,7 +387,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
// Reset state when project changes to clear stale data
|
// Reset state when project changes to clear stale data
|
||||||
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
|
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
|
||||||
setState({
|
setState({
|
||||||
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
|
progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
|
||||||
agentStatus: 'loading',
|
agentStatus: 'loading',
|
||||||
logs: [],
|
logs: [],
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
|
|||||||
@@ -181,6 +181,17 @@ export async function createFeaturesBulk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveHumanInput(
|
||||||
|
projectName: string,
|
||||||
|
featureId: number,
|
||||||
|
response: { fields: Record<string, string | boolean | string[]> }
|
||||||
|
): Promise<Feature> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/resolve-human-input`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Dependency Graph API
|
// Dependency Graph API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -271,6 +282,18 @@ export async function resumeAgent(projectName: string): Promise<AgentActionRespo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gracefulPauseAgent(projectName: string): Promise<AgentActionResponse> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/graceful-pause`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gracefulResumeAgent(projectName: string): Promise<AgentActionResponse> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/graceful-resume`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Spec Creation API
|
// Spec Creation API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -57,6 +57,26 @@ export interface ProjectPrompts {
|
|||||||
coding_prompt: string
|
coding_prompt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human input types
|
||||||
|
export interface HumanInputField {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'textarea' | 'select' | 'boolean'
|
||||||
|
required: boolean
|
||||||
|
placeholder?: string
|
||||||
|
options?: { value: string; label: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumanInputRequest {
|
||||||
|
prompt: string
|
||||||
|
fields: HumanInputField[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumanInputResponseData {
|
||||||
|
fields: Record<string, string | boolean | string[]>
|
||||||
|
responded_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Feature types
|
// Feature types
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
id: number
|
id: number
|
||||||
@@ -70,10 +90,13 @@ export interface Feature {
|
|||||||
dependencies?: number[] // Optional for backwards compat
|
dependencies?: number[] // Optional for backwards compat
|
||||||
blocked?: boolean // Computed by API
|
blocked?: boolean // Computed by API
|
||||||
blocking_dependencies?: number[] // Computed by API
|
blocking_dependencies?: number[] // Computed by API
|
||||||
|
needs_human_input?: boolean
|
||||||
|
human_input_request?: HumanInputRequest | null
|
||||||
|
human_input_response?: HumanInputResponseData | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status type for graph nodes
|
// Status type for graph nodes
|
||||||
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
|
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' | 'needs_human_input'
|
||||||
|
|
||||||
// Graph visualization types
|
// Graph visualization types
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
@@ -99,6 +122,7 @@ export interface FeatureListResponse {
|
|||||||
pending: Feature[]
|
pending: Feature[]
|
||||||
in_progress: Feature[]
|
in_progress: Feature[]
|
||||||
done: Feature[]
|
done: Feature[]
|
||||||
|
needs_human_input: Feature[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureCreate {
|
export interface FeatureCreate {
|
||||||
@@ -120,7 +144,7 @@ export interface FeatureUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Agent types
|
// Agent types
|
||||||
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' | 'loading'
|
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed' | 'loading' | 'pausing' | 'paused_graceful'
|
||||||
|
|
||||||
export interface AgentStatusResponse {
|
export interface AgentStatusResponse {
|
||||||
status: AgentStatus
|
status: AgentStatus
|
||||||
@@ -216,6 +240,8 @@ export type OrchestratorState =
|
|||||||
| 'spawning'
|
| 'spawning'
|
||||||
| 'monitoring'
|
| 'monitoring'
|
||||||
| 'complete'
|
| 'complete'
|
||||||
|
| 'draining'
|
||||||
|
| 'paused'
|
||||||
|
|
||||||
// Orchestrator event for recent activity
|
// Orchestrator event for recent activity
|
||||||
export interface OrchestratorEvent {
|
export interface OrchestratorEvent {
|
||||||
@@ -248,6 +274,7 @@ export interface WSProgressMessage {
|
|||||||
in_progress: number
|
in_progress: number
|
||||||
total: number
|
total: number
|
||||||
percentage: number
|
percentage: number
|
||||||
|
needs_human_input?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSFeatureUpdateMessage {
|
export interface WSFeatureUpdateMessage {
|
||||||
@@ -552,7 +579,8 @@ export interface Settings {
|
|||||||
ollama_mode: boolean
|
ollama_mode: boolean
|
||||||
testing_agent_ratio: number // Regression testing agents (0-3)
|
testing_agent_ratio: number // Regression testing agents (0-3)
|
||||||
playwright_headless: boolean
|
playwright_headless: boolean
|
||||||
batch_size: number // Features per coding agent batch (1-3)
|
batch_size: number // Features per coding agent batch (1-15)
|
||||||
|
testing_batch_size: number // Features per testing agent batch (1-15)
|
||||||
api_provider: string
|
api_provider: string
|
||||||
api_base_url: string | null
|
api_base_url: string | null
|
||||||
api_has_auth_token: boolean
|
api_has_auth_token: boolean
|
||||||
@@ -565,6 +593,7 @@ export interface SettingsUpdate {
|
|||||||
testing_agent_ratio?: number
|
testing_agent_ratio?: number
|
||||||
playwright_headless?: boolean
|
playwright_headless?: boolean
|
||||||
batch_size?: number
|
batch_size?: number
|
||||||
|
testing_batch_size?: number
|
||||||
api_provider?: string
|
api_provider?: string
|
||||||
api_base_url?: string
|
api_base_url?: string
|
||||||
api_auth_token?: string
|
api_auth_token?: string
|
||||||
|
|||||||
@@ -1472,3 +1472,53 @@
|
|||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--muted-foreground);
|
background: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Slider (range input) styling
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.slider-input::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 2px solid var(--primary-foreground);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms, box-shadow 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-input::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-input::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: 2px solid var(--primary-foreground);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms, box-shadow 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-input::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-input::-webkit-slider-runnable-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--input);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-input::-moz-range-track {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--input);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user