autoforge-ai 0.1.2 → 0.1.4
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.
- package/.env.example +16 -0
- package/README.md +2 -2
- package/env_constants.py +1 -0
- package/package.json +2 -1
- package/registry.py +117 -0
- package/server/routers/agent.py +1 -1
- package/server/routers/assistant_chat.py +10 -4
- package/server/routers/expand_project.py +8 -2
- package/server/routers/settings.py +79 -7
- package/server/routers/spec_creation.py +13 -7
- package/server/routers/terminal.py +14 -8
- package/server/schemas.py +41 -3
- package/server/services/assistant_chat_session.py +6 -10
- package/server/services/expand_chat_session.py +6 -11
- package/server/services/process_manager.py +57 -1
- package/server/services/spec_chat_session.py +6 -11
- package/server/websocket.py +8 -5
- package/temp_cleanup.py +148 -0
- package/ui/dist/assets/index-CCu7z6o1.css +1 -0
- package/ui/dist/assets/index-CWf0HODJ.js +97 -0
- package/ui/dist/assets/vendor-utils-ZeeSylek.js +2 -0
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/index-CNq40B6c.js +0 -97
- package/ui/dist/assets/index-InF2n2n-.css +0 -1
- package/ui/dist/assets/vendor-utils-Cj4T6W23.js +0 -2
|
@@ -25,7 +25,7 @@ from .assistant_database import (
|
|
|
25
25
|
create_conversation,
|
|
26
26
|
get_messages,
|
|
27
27
|
)
|
|
28
|
-
from .chat_constants import
|
|
28
|
+
from .chat_constants import ROOT_DIR
|
|
29
29
|
|
|
30
30
|
# Load environment variables from .env file if present
|
|
31
31
|
load_dotenv()
|
|
@@ -258,15 +258,11 @@ class AssistantChatSession:
|
|
|
258
258
|
system_cli = shutil.which("claude")
|
|
259
259
|
|
|
260
260
|
# Build environment overrides for API configuration
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
# Determine model from environment or use default
|
|
268
|
-
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
|
269
|
-
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
261
|
+
from registry import get_effective_sdk_env
|
|
262
|
+
sdk_env = get_effective_sdk_env()
|
|
263
|
+
|
|
264
|
+
# Determine model from SDK env (provider-aware) or fallback to env/default
|
|
265
|
+
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
270
266
|
|
|
271
267
|
try:
|
|
272
268
|
logger.info("Creating ClaudeSDKClient...")
|
|
@@ -22,7 +22,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
|
22
22
|
from dotenv import load_dotenv
|
|
23
23
|
|
|
24
24
|
from ..schemas import ImageAttachment
|
|
25
|
-
from .chat_constants import
|
|
25
|
+
from .chat_constants import ROOT_DIR, make_multimodal_message
|
|
26
26
|
|
|
27
27
|
# Load environment variables from .env file if present
|
|
28
28
|
load_dotenv()
|
|
@@ -154,16 +154,11 @@ class ExpandChatSession:
|
|
|
154
154
|
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
|
155
155
|
|
|
156
156
|
# Build environment overrides for API configuration
|
|
157
|
-
|
|
158
|
-
sdk_env
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
sdk_env[var] = value
|
|
163
|
-
|
|
164
|
-
# Determine model from environment or use default
|
|
165
|
-
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
|
166
|
-
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
157
|
+
from registry import get_effective_sdk_env
|
|
158
|
+
sdk_env = get_effective_sdk_env()
|
|
159
|
+
|
|
160
|
+
# Determine model from SDK env (provider-aware) or fallback to env/default
|
|
161
|
+
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
167
162
|
|
|
168
163
|
# Build MCP servers config for feature creation
|
|
169
164
|
mcp_servers = {
|
|
@@ -227,6 +227,46 @@ class AgentProcessManager:
|
|
|
227
227
|
"""Remove lock file."""
|
|
228
228
|
self.lock_file.unlink(missing_ok=True)
|
|
229
229
|
|
|
230
|
+
def _cleanup_stale_features(self) -> None:
|
|
231
|
+
"""Clear in_progress flag for all features when agent stops/crashes.
|
|
232
|
+
|
|
233
|
+
When the agent process exits (normally or crash), any features left
|
|
234
|
+
with in_progress=True were being worked on and didn't complete.
|
|
235
|
+
Reset them so they can be picked up on next agent start.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
from autoforge_paths import get_features_db_path
|
|
239
|
+
features_db = get_features_db_path(self.project_dir)
|
|
240
|
+
if not features_db.exists():
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
from sqlalchemy import create_engine
|
|
244
|
+
from sqlalchemy.orm import sessionmaker
|
|
245
|
+
|
|
246
|
+
from api.database import Feature
|
|
247
|
+
|
|
248
|
+
engine = create_engine(f"sqlite:///{features_db}")
|
|
249
|
+
Session = sessionmaker(bind=engine)
|
|
250
|
+
session = Session()
|
|
251
|
+
try:
|
|
252
|
+
stuck = session.query(Feature).filter(
|
|
253
|
+
Feature.in_progress == True, # noqa: E712
|
|
254
|
+
Feature.passes == False, # noqa: E712
|
|
255
|
+
).all()
|
|
256
|
+
if stuck:
|
|
257
|
+
for f in stuck:
|
|
258
|
+
f.in_progress = False
|
|
259
|
+
session.commit()
|
|
260
|
+
logger.info(
|
|
261
|
+
"Cleaned up %d stuck feature(s) for %s",
|
|
262
|
+
len(stuck), self.project_name,
|
|
263
|
+
)
|
|
264
|
+
finally:
|
|
265
|
+
session.close()
|
|
266
|
+
engine.dispose()
|
|
267
|
+
except Exception as e:
|
|
268
|
+
logger.warning("Failed to cleanup features for %s: %s", self.project_name, e)
|
|
269
|
+
|
|
230
270
|
async def _broadcast_output(self, line: str) -> None:
|
|
231
271
|
"""Broadcast output line to all registered callbacks."""
|
|
232
272
|
with self._callbacks_lock:
|
|
@@ -288,6 +328,7 @@ class AgentProcessManager:
|
|
|
288
328
|
self.status = "crashed"
|
|
289
329
|
elif self.status == "running":
|
|
290
330
|
self.status = "stopped"
|
|
331
|
+
self._cleanup_stale_features()
|
|
291
332
|
self._remove_lock()
|
|
292
333
|
|
|
293
334
|
async def start(
|
|
@@ -320,6 +361,9 @@ class AgentProcessManager:
|
|
|
320
361
|
if not self._check_lock():
|
|
321
362
|
return False, "Another agent instance is already running for this project"
|
|
322
363
|
|
|
364
|
+
# Clean up features stuck from a previous crash/stop
|
|
365
|
+
self._cleanup_stale_features()
|
|
366
|
+
|
|
323
367
|
# Store for status queries
|
|
324
368
|
self.yolo_mode = yolo_mode
|
|
325
369
|
self.model = model
|
|
@@ -359,12 +403,22 @@ class AgentProcessManager:
|
|
|
359
403
|
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
|
|
360
404
|
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
|
|
361
405
|
# PYTHONUNBUFFERED ensures output isn't delayed
|
|
406
|
+
# Build subprocess environment with API provider settings
|
|
407
|
+
from registry import get_effective_sdk_env
|
|
408
|
+
api_env = get_effective_sdk_env()
|
|
409
|
+
subprocess_env = {
|
|
410
|
+
**os.environ,
|
|
411
|
+
"PYTHONUNBUFFERED": "1",
|
|
412
|
+
"PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false",
|
|
413
|
+
**api_env,
|
|
414
|
+
}
|
|
415
|
+
|
|
362
416
|
popen_kwargs: dict[str, Any] = {
|
|
363
417
|
"stdin": subprocess.DEVNULL,
|
|
364
418
|
"stdout": subprocess.PIPE,
|
|
365
419
|
"stderr": subprocess.STDOUT,
|
|
366
420
|
"cwd": str(self.project_dir),
|
|
367
|
-
"env":
|
|
421
|
+
"env": subprocess_env,
|
|
368
422
|
}
|
|
369
423
|
if sys.platform == "win32":
|
|
370
424
|
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
@@ -425,6 +479,7 @@ class AgentProcessManager:
|
|
|
425
479
|
result.children_terminated, result.children_killed
|
|
426
480
|
)
|
|
427
481
|
|
|
482
|
+
self._cleanup_stale_features()
|
|
428
483
|
self._remove_lock()
|
|
429
484
|
self.status = "stopped"
|
|
430
485
|
self.process = None
|
|
@@ -502,6 +557,7 @@ class AgentProcessManager:
|
|
|
502
557
|
if poll is not None:
|
|
503
558
|
# Process has terminated
|
|
504
559
|
if self.status in ("running", "paused"):
|
|
560
|
+
self._cleanup_stale_features()
|
|
505
561
|
self.status = "crashed"
|
|
506
562
|
self._remove_lock()
|
|
507
563
|
return False
|
|
@@ -19,7 +19,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
|
19
19
|
from dotenv import load_dotenv
|
|
20
20
|
|
|
21
21
|
from ..schemas import ImageAttachment
|
|
22
|
-
from .chat_constants import
|
|
22
|
+
from .chat_constants import ROOT_DIR, make_multimodal_message
|
|
23
23
|
|
|
24
24
|
# Load environment variables from .env file if present
|
|
25
25
|
load_dotenv()
|
|
@@ -140,16 +140,11 @@ class SpecChatSession:
|
|
|
140
140
|
system_cli = shutil.which("claude")
|
|
141
141
|
|
|
142
142
|
# Build environment overrides for API configuration
|
|
143
|
-
|
|
144
|
-
sdk_env
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
sdk_env[var] = value
|
|
149
|
-
|
|
150
|
-
# Determine model from environment or use default
|
|
151
|
-
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
|
152
|
-
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
143
|
+
from registry import get_effective_sdk_env
|
|
144
|
+
sdk_env = get_effective_sdk_env()
|
|
145
|
+
|
|
146
|
+
# Determine model from SDK env (provider-aware) or fallback to env/default
|
|
147
|
+
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
153
148
|
|
|
154
149
|
try:
|
|
155
150
|
self.client = ClaudeSDKClient(
|
package/server/websocket.py
CHANGED
|
@@ -640,9 +640,7 @@ class ConnectionManager:
|
|
|
640
640
|
self._lock = asyncio.Lock()
|
|
641
641
|
|
|
642
642
|
async def connect(self, websocket: WebSocket, project_name: str):
|
|
643
|
-
"""
|
|
644
|
-
await websocket.accept()
|
|
645
|
-
|
|
643
|
+
"""Register a WebSocket connection for a project (must already be accepted)."""
|
|
646
644
|
async with self._lock:
|
|
647
645
|
if project_name not in self.active_connections:
|
|
648
646
|
self.active_connections[project_name] = set()
|
|
@@ -727,16 +725,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
727
725
|
- Agent status changes
|
|
728
726
|
- Agent stdout/stderr lines
|
|
729
727
|
"""
|
|
728
|
+
# Always accept WebSocket first to avoid opaque 403 errors
|
|
729
|
+
await websocket.accept()
|
|
730
|
+
|
|
730
731
|
if not validate_project_name(project_name):
|
|
732
|
+
await websocket.send_json({"type": "error", "content": "Invalid project name"})
|
|
731
733
|
await websocket.close(code=4000, reason="Invalid project name")
|
|
732
734
|
return
|
|
733
735
|
|
|
734
736
|
project_dir = _get_project_path(project_name)
|
|
735
737
|
if not project_dir:
|
|
738
|
+
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
|
|
736
739
|
await websocket.close(code=4004, reason="Project not found in registry")
|
|
737
740
|
return
|
|
738
741
|
|
|
739
742
|
if not project_dir.exists():
|
|
743
|
+
await websocket.send_json({"type": "error", "content": "Project directory not found"})
|
|
740
744
|
await websocket.close(code=4004, reason="Project directory not found")
|
|
741
745
|
return
|
|
742
746
|
|
|
@@ -879,8 +883,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
879
883
|
break
|
|
880
884
|
except json.JSONDecodeError:
|
|
881
885
|
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
|
882
|
-
except Exception
|
|
883
|
-
logger.warning(f"WebSocket error: {e}")
|
|
886
|
+
except Exception:
|
|
884
887
|
break
|
|
885
888
|
|
|
886
889
|
finally:
|
package/temp_cleanup.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Temp Cleanup Module
|
|
3
|
+
===================
|
|
4
|
+
|
|
5
|
+
Cleans up stale temporary files and directories created by AutoForge agents,
|
|
6
|
+
Playwright, Node.js, and other development tools.
|
|
7
|
+
|
|
8
|
+
Called at Maestro (orchestrator) startup to prevent temp folder bloat.
|
|
9
|
+
|
|
10
|
+
Why this exists:
|
|
11
|
+
- Playwright creates browser profiles and artifacts in %TEMP%
|
|
12
|
+
- Node.js creates .node cache files (~7MB each, can accumulate to GBs)
|
|
13
|
+
- MongoDB Memory Server downloads binaries to temp
|
|
14
|
+
- These are never cleaned up automatically
|
|
15
|
+
|
|
16
|
+
When cleanup runs:
|
|
17
|
+
- At Maestro startup (when you click Play or auto-restart after rate limits)
|
|
18
|
+
- Only files/folders older than 1 hour are deleted (safe for running processes)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import shutil
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Max age in seconds before a temp item is considered stale (1 hour)
|
|
30
|
+
MAX_AGE_SECONDS = 3600
|
|
31
|
+
|
|
32
|
+
# Directory patterns to clean up (glob patterns)
|
|
33
|
+
DIR_PATTERNS = [
|
|
34
|
+
"playwright_firefoxdev_profile-*", # Playwright Firefox profiles
|
|
35
|
+
"playwright-artifacts-*", # Playwright test artifacts
|
|
36
|
+
"playwright-transform-cache", # Playwright transform cache
|
|
37
|
+
"mongodb-memory-server*", # MongoDB Memory Server binaries
|
|
38
|
+
"ng-*", # Angular CLI temp directories
|
|
39
|
+
"scoped_dir*", # Chrome/Chromium temp directories
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# File patterns to clean up (glob patterns)
|
|
43
|
+
FILE_PATTERNS = [
|
|
44
|
+
".78912*.node", # Node.js native module cache (major space consumer, ~7MB each)
|
|
45
|
+
"claude-*-cwd", # Claude CLI working directory temp files
|
|
46
|
+
"mat-debug-*.log", # Material/Angular debug logs
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cleanup_stale_temp(max_age_seconds: int = MAX_AGE_SECONDS) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Clean up stale temporary files and directories.
|
|
53
|
+
|
|
54
|
+
Only deletes items older than max_age_seconds to avoid
|
|
55
|
+
interfering with currently running processes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
max_age_seconds: Maximum age in seconds before an item is deleted.
|
|
59
|
+
Defaults to 1 hour (3600 seconds).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dictionary with cleanup statistics:
|
|
63
|
+
- dirs_deleted: Number of directories deleted
|
|
64
|
+
- files_deleted: Number of files deleted
|
|
65
|
+
- bytes_freed: Approximate bytes freed
|
|
66
|
+
- errors: List of error messages (for debugging, not fatal)
|
|
67
|
+
"""
|
|
68
|
+
temp_dir = Path(tempfile.gettempdir())
|
|
69
|
+
cutoff_time = time.time() - max_age_seconds
|
|
70
|
+
|
|
71
|
+
stats = {
|
|
72
|
+
"dirs_deleted": 0,
|
|
73
|
+
"files_deleted": 0,
|
|
74
|
+
"bytes_freed": 0,
|
|
75
|
+
"errors": [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Clean up directories
|
|
79
|
+
for pattern in DIR_PATTERNS:
|
|
80
|
+
for item in temp_dir.glob(pattern):
|
|
81
|
+
if not item.is_dir():
|
|
82
|
+
continue
|
|
83
|
+
try:
|
|
84
|
+
mtime = item.stat().st_mtime
|
|
85
|
+
if mtime < cutoff_time:
|
|
86
|
+
size = _get_dir_size(item)
|
|
87
|
+
shutil.rmtree(item, ignore_errors=True)
|
|
88
|
+
if not item.exists():
|
|
89
|
+
stats["dirs_deleted"] += 1
|
|
90
|
+
stats["bytes_freed"] += size
|
|
91
|
+
logger.debug(f"Deleted temp directory: {item}")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
stats["errors"].append(f"Failed to delete {item}: {e}")
|
|
94
|
+
logger.debug(f"Failed to delete {item}: {e}")
|
|
95
|
+
|
|
96
|
+
# Clean up files
|
|
97
|
+
for pattern in FILE_PATTERNS:
|
|
98
|
+
for item in temp_dir.glob(pattern):
|
|
99
|
+
if not item.is_file():
|
|
100
|
+
continue
|
|
101
|
+
try:
|
|
102
|
+
mtime = item.stat().st_mtime
|
|
103
|
+
if mtime < cutoff_time:
|
|
104
|
+
size = item.stat().st_size
|
|
105
|
+
item.unlink(missing_ok=True)
|
|
106
|
+
if not item.exists():
|
|
107
|
+
stats["files_deleted"] += 1
|
|
108
|
+
stats["bytes_freed"] += size
|
|
109
|
+
logger.debug(f"Deleted temp file: {item}")
|
|
110
|
+
except Exception as e:
|
|
111
|
+
stats["errors"].append(f"Failed to delete {item}: {e}")
|
|
112
|
+
logger.debug(f"Failed to delete {item}: {e}")
|
|
113
|
+
|
|
114
|
+
# Log summary if anything was cleaned
|
|
115
|
+
if stats["dirs_deleted"] > 0 or stats["files_deleted"] > 0:
|
|
116
|
+
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
|
117
|
+
logger.info(
|
|
118
|
+
f"Temp cleanup: {stats['dirs_deleted']} dirs, "
|
|
119
|
+
f"{stats['files_deleted']} files, {mb_freed:.1f} MB freed"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return stats
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _get_dir_size(path: Path) -> int:
|
|
126
|
+
"""Get total size of a directory in bytes."""
|
|
127
|
+
total = 0
|
|
128
|
+
try:
|
|
129
|
+
for item in path.rglob("*"):
|
|
130
|
+
if item.is_file():
|
|
131
|
+
try:
|
|
132
|
+
total += item.stat().st_size
|
|
133
|
+
except (OSError, PermissionError):
|
|
134
|
+
pass
|
|
135
|
+
except (OSError, PermissionError):
|
|
136
|
+
pass
|
|
137
|
+
return total
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
# Allow running directly for testing/manual cleanup
|
|
142
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
143
|
+
print("Running temp cleanup...")
|
|
144
|
+
stats = cleanup_stale_temp()
|
|
145
|
+
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
|
146
|
+
print(f"Cleanup complete: {stats['dirs_deleted']} dirs, {stats['files_deleted']} files, {mb_freed:.1f} MB freed")
|
|
147
|
+
if stats["errors"]:
|
|
148
|
+
print(f"Errors (non-fatal): {len(stats['errors'])}")
|