autoforge-ai 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -176,14 +176,14 @@ Authentication:
176
176
  "--testing-batch-size",
177
177
  type=int,
178
178
  default=3,
179
- help="Number of features per testing batch (1-5, default: 3)",
179
+ help="Number of features per testing batch (1-15, default: 3)",
180
180
  )
181
181
 
182
182
  parser.add_argument(
183
183
  "--batch-size",
184
184
  type=int,
185
185
  default=3,
186
- help="Max features per coding agent batch (1-3, default: 3)",
186
+ help="Max features per coding agent batch (1-15, default: 3)",
187
187
  )
188
188
 
189
189
  return parser.parse_args()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoforge-ai",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Autonomous coding agent with web UI - build complete apps with AI",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {
@@ -131,7 +131,7 @@ def _dump_database_state(feature_dicts: list[dict], label: str = ""):
131
131
  MAX_PARALLEL_AGENTS = 5
132
132
  MAX_TOTAL_AGENTS = 10
133
133
  DEFAULT_CONCURRENCY = 3
134
- DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-5)
134
+ DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-15)
135
135
  POLL_INTERVAL = 5 # seconds between checking for ready features
136
136
  MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
137
137
  INITIALIZER_TIMEOUT = 1800 # 30 minutes timeout for initializer
@@ -168,7 +168,7 @@ class ParallelOrchestrator:
168
168
  yolo_mode: Whether to run in YOLO mode (skip testing agents entirely)
169
169
  testing_agent_ratio: Number of regression testing agents to maintain (0-3).
170
170
  0 = disabled, 1-3 = maintain that many testing agents running independently.
171
- testing_batch_size: Number of features to include per testing session (1-5).
171
+ testing_batch_size: Number of features to include per testing session (1-15).
172
172
  Each testing agent receives this many features to regression test.
173
173
  on_output: Callback for agent output (feature_id, line)
174
174
  on_status: Callback for agent status changes (feature_id, status)
@@ -178,8 +178,8 @@ class ParallelOrchestrator:
178
178
  self.model = model
179
179
  self.yolo_mode = yolo_mode
180
180
  self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3
181
- self.testing_batch_size = min(max(testing_batch_size, 1), 5) # Clamp 1-5
182
- self.batch_size = min(max(batch_size, 1), 3) # Clamp 1-3
181
+ self.testing_batch_size = min(max(testing_batch_size, 1), 15) # Clamp 1-15
182
+ self.batch_size = min(max(batch_size, 1), 15) # Clamp 1-15
183
183
  self.on_output = on_output
184
184
  self.on_status = on_status
185
185
 
package/server/main.py CHANGED
@@ -36,6 +36,7 @@ from .routers import (
36
36
  features_router,
37
37
  filesystem_router,
38
38
  projects_router,
39
+ scaffold_router,
39
40
  schedules_router,
40
41
  settings_router,
41
42
  spec_creation_router,
@@ -169,6 +170,7 @@ app.include_router(filesystem_router)
169
170
  app.include_router(assistant_chat_router)
170
171
  app.include_router(settings_router)
171
172
  app.include_router(terminal_router)
173
+ app.include_router(scaffold_router)
172
174
 
173
175
 
174
176
  # ============================================================================
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
12
12
  from .features import router as features_router
13
13
  from .filesystem import router as filesystem_router
14
14
  from .projects import router as projects_router
15
+ from .scaffold import router as scaffold_router
15
16
  from .schedules import router as schedules_router
16
17
  from .settings import router as settings_router
17
18
  from .spec_creation import router as spec_creation_router
@@ -29,4 +30,5 @@ __all__ = [
29
30
  "assistant_chat_router",
30
31
  "settings_router",
31
32
  "terminal_router",
33
+ "scaffold_router",
32
34
  ]
@@ -17,11 +17,11 @@ from ..utils.project_helpers import get_project_path as _get_project_path
17
17
  from ..utils.validation import validate_project_name
18
18
 
19
19
 
20
- def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
20
+ def _get_settings_defaults() -> tuple[bool, str, int, bool, int, int]:
21
21
  """Get defaults from global settings.
22
22
 
23
23
  Returns:
24
- Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size)
24
+ Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size, testing_batch_size)
25
25
  """
26
26
  import sys
27
27
  root = Path(__file__).parent.parent.parent
@@ -47,7 +47,12 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
47
47
  except (ValueError, TypeError):
48
48
  batch_size = 3
49
49
 
50
- return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size
50
+ try:
51
+ testing_batch_size = int(settings.get("testing_batch_size", "3"))
52
+ except (ValueError, TypeError):
53
+ testing_batch_size = 3
54
+
55
+ return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size, testing_batch_size
51
56
 
52
57
 
53
58
  router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
@@ -96,7 +101,7 @@ async def start_agent(
96
101
  manager = get_project_manager(project_name)
97
102
 
98
103
  # Get defaults from global settings if not provided in request
99
- default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size = _get_settings_defaults()
104
+ default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size, default_testing_batch_size = _get_settings_defaults()
100
105
 
101
106
  yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
102
107
  model = request.model if request.model else default_model
@@ -104,6 +109,7 @@ async def start_agent(
104
109
  testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
105
110
 
106
111
  batch_size = default_batch_size
112
+ testing_batch_size = default_testing_batch_size
107
113
 
108
114
  success, message = await manager.start(
109
115
  yolo_mode=yolo_mode,
@@ -112,6 +118,7 @@ async def start_agent(
112
118
  testing_agent_ratio=testing_agent_ratio,
113
119
  playwright_headless=playwright_headless,
114
120
  batch_size=batch_size,
121
+ testing_batch_size=testing_batch_size,
115
122
  )
116
123
 
117
124
  # Notify scheduler of manual start (to prevent auto-stop during scheduled window)
@@ -0,0 +1,136 @@
1
+ """
2
+ Scaffold Router
3
+ ================
4
+
5
+ SSE streaming endpoint for running project scaffold commands.
6
+ Supports templated project creation (e.g., Next.js agentic starter).
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from fastapi import APIRouter, Request
18
+ from fastapi.responses import StreamingResponse
19
+ from pydantic import BaseModel
20
+
21
+ from .filesystem import is_path_blocked
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
26
+
27
+ # Hardcoded templates — no arbitrary commands allowed
28
+ TEMPLATES: dict[str, list[str]] = {
29
+ "agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
30
+ }
31
+
32
+
33
+ class ScaffoldRequest(BaseModel):
34
+ template: str
35
+ target_path: str
36
+
37
+
38
+ def _sse_event(data: dict) -> str:
39
+ """Format a dict as an SSE data line."""
40
+ return f"data: {json.dumps(data)}\n\n"
41
+
42
+
43
+ async def _stream_scaffold(template: str, target_path: str, request: Request):
44
+ """Run the scaffold command and yield SSE events."""
45
+ # Validate template
46
+ if template not in TEMPLATES:
47
+ yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
48
+ return
49
+
50
+ # Validate path
51
+ path = Path(target_path)
52
+ try:
53
+ path = path.resolve()
54
+ except (OSError, ValueError) as e:
55
+ yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
56
+ return
57
+
58
+ if is_path_blocked(path):
59
+ yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
60
+ return
61
+
62
+ if not path.exists() or not path.is_dir():
63
+ yield _sse_event({"type": "error", "message": "Target directory does not exist"})
64
+ return
65
+
66
+ # Check npx is available
67
+ npx_name = "npx"
68
+ if sys.platform == "win32":
69
+ npx_name = "npx.cmd"
70
+
71
+ if not shutil.which(npx_name):
72
+ yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
73
+ return
74
+
75
+ # Build command
76
+ argv = list(TEMPLATES[template])
77
+ if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
78
+ argv[0] = argv[0] + ".cmd"
79
+
80
+ process = None
81
+ try:
82
+ popen_kwargs: dict = {
83
+ "stdout": subprocess.PIPE,
84
+ "stderr": subprocess.STDOUT,
85
+ "stdin": subprocess.DEVNULL,
86
+ "cwd": str(path),
87
+ }
88
+ if sys.platform == "win32":
89
+ popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
90
+
91
+ process = subprocess.Popen(argv, **popen_kwargs)
92
+ logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
93
+
94
+ # Stream stdout lines
95
+ assert process.stdout is not None
96
+ for raw_line in iter(process.stdout.readline, b""):
97
+ # Check if client disconnected
98
+ if await request.is_disconnected():
99
+ logger.info("Client disconnected during scaffold, terminating process")
100
+ break
101
+
102
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
103
+ yield _sse_event({"type": "output", "line": line})
104
+ # Yield control to event loop so disconnect checks work
105
+ await asyncio.sleep(0)
106
+
107
+ process.wait()
108
+ exit_code = process.returncode
109
+ success = exit_code == 0
110
+ logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
111
+ yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
112
+
113
+ except Exception as e:
114
+ logger.error("Scaffold error: %s", e)
115
+ yield _sse_event({"type": "error", "message": str(e)})
116
+
117
+ finally:
118
+ if process and process.poll() is None:
119
+ try:
120
+ process.terminate()
121
+ process.wait(timeout=5)
122
+ except Exception:
123
+ process.kill()
124
+
125
+
126
+ @router.post("/run")
127
+ async def run_scaffold(body: ScaffoldRequest, request: Request):
128
+ """Run a scaffold template command with SSE streaming output."""
129
+ return StreamingResponse(
130
+ _stream_scaffold(body.template, body.target_path, request),
131
+ media_type="text/event-stream",
132
+ headers={
133
+ "Cache-Control": "no-cache",
134
+ "X-Accel-Buffering": "no",
135
+ },
136
+ )
@@ -113,6 +113,7 @@ async def get_settings():
113
113
  testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
114
114
  playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
115
115
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
116
+ testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
116
117
  api_provider=api_provider,
117
118
  api_base_url=all_settings.get("api_base_url"),
118
119
  api_has_auth_token=bool(all_settings.get("api_auth_token")),
@@ -138,6 +139,9 @@ async def update_settings(update: SettingsUpdate):
138
139
  if update.batch_size is not None:
139
140
  set_setting("batch_size", str(update.batch_size))
140
141
 
142
+ if update.testing_batch_size is not None:
143
+ set_setting("testing_batch_size", str(update.testing_batch_size))
144
+
141
145
  # API provider settings
142
146
  if update.api_provider is not None:
143
147
  old_provider = get_setting("api_provider", "claude")
@@ -177,6 +181,7 @@ async def update_settings(update: SettingsUpdate):
177
181
  testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
178
182
  playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
179
183
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
184
+ testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
180
185
  api_provider=api_provider,
181
186
  api_base_url=all_settings.get("api_base_url"),
182
187
  api_has_auth_token=bool(all_settings.get("api_auth_token")),
package/server/schemas.py CHANGED
@@ -444,7 +444,8 @@ class SettingsResponse(BaseModel):
444
444
  ollama_mode: bool = False # True when api_provider is "ollama"
445
445
  testing_agent_ratio: int = 1 # Regression testing agents (0-3)
446
446
  playwright_headless: bool = True
447
- batch_size: int = 3 # Features per coding agent batch (1-3)
447
+ batch_size: int = 3 # Features per coding agent batch (1-15)
448
+ testing_batch_size: int = 3 # Features per testing agent batch (1-15)
448
449
  api_provider: str = "claude"
449
450
  api_base_url: str | None = None
450
451
  api_has_auth_token: bool = False # Never expose actual token
@@ -463,7 +464,8 @@ class SettingsUpdate(BaseModel):
463
464
  model: str | None = None
464
465
  testing_agent_ratio: int | None = None # 0-3
465
466
  playwright_headless: bool | None = None
466
- batch_size: int | None = None # Features per agent batch (1-3)
467
+ batch_size: int | None = None # Features per agent batch (1-15)
468
+ testing_batch_size: int | None = None # Features per testing agent batch (1-15)
467
469
  api_provider: str | None = None
468
470
  api_base_url: str | None = Field(None, max_length=500)
469
471
  api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
@@ -500,8 +502,15 @@ class SettingsUpdate(BaseModel):
500
502
  @field_validator('batch_size')
501
503
  @classmethod
502
504
  def validate_batch_size(cls, v: int | None) -> int | None:
503
- if v is not None and (v < 1 or v > 3):
504
- raise ValueError("batch_size must be between 1 and 3")
505
+ if v is not None and (v < 1 or v > 15):
506
+ raise ValueError("batch_size must be between 1 and 15")
507
+ return v
508
+
509
+ @field_validator('testing_batch_size')
510
+ @classmethod
511
+ def validate_testing_batch_size(cls, v: int | None) -> int | None:
512
+ if v is not None and (v < 1 or v > 15):
513
+ raise ValueError("testing_batch_size must be between 1 and 15")
505
514
  return v
506
515
 
507
516
 
@@ -374,6 +374,7 @@ class AgentProcessManager:
374
374
  testing_agent_ratio: int = 1,
375
375
  playwright_headless: bool = True,
376
376
  batch_size: int = 3,
377
+ testing_batch_size: int = 3,
377
378
  ) -> tuple[bool, str]:
378
379
  """
379
380
  Start the agent as a subprocess.
@@ -440,6 +441,9 @@ class AgentProcessManager:
440
441
  # Add --batch-size flag for multi-feature batching
441
442
  cmd.extend(["--batch-size", str(batch_size)])
442
443
 
444
+ # Add --testing-batch-size flag for testing agent batching
445
+ cmd.extend(["--testing-batch-size", str(testing_batch_size)])
446
+
443
447
  # Apply headless setting to .playwright/cli.config.json so playwright-cli
444
448
  # picks it up (the only mechanism it supports for headless control)
445
449
  self._apply_playwright_headless(playwright_headless)