autoforge-ai 0.1.0
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/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
package/registry.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project Registry Module
|
|
3
|
+
=======================
|
|
4
|
+
|
|
5
|
+
Cross-platform project registry for storing project name to path mappings.
|
|
6
|
+
Uses SQLite database stored at ~/.autoforge/registry.db.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from sqlalchemy import Column, DateTime, Integer, String, create_engine, text
|
|
20
|
+
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
|
21
|
+
|
|
22
|
+
# Module logger
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _migrate_registry_dir() -> None:
|
|
27
|
+
"""Migrate ~/.autocoder/ to ~/.autoforge/ if needed.
|
|
28
|
+
|
|
29
|
+
Provides backward compatibility by automatically renaming the old
|
|
30
|
+
config directory to the new location on first access.
|
|
31
|
+
"""
|
|
32
|
+
old_dir = Path.home() / ".autocoder"
|
|
33
|
+
new_dir = Path.home() / ".autoforge"
|
|
34
|
+
if old_dir.exists() and not new_dir.exists():
|
|
35
|
+
try:
|
|
36
|
+
old_dir.rename(new_dir)
|
|
37
|
+
logger.info("Migrated registry directory: ~/.autocoder/ -> ~/.autoforge/")
|
|
38
|
+
except Exception:
|
|
39
|
+
logger.warning("Failed to migrate ~/.autocoder/ to ~/.autoforge/", exc_info=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# Model Configuration (Single Source of Truth)
|
|
44
|
+
# =============================================================================
|
|
45
|
+
|
|
46
|
+
# Available models with display names
|
|
47
|
+
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
|
|
48
|
+
AVAILABLE_MODELS = [
|
|
49
|
+
{"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
|
|
50
|
+
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# List of valid model IDs (derived from AVAILABLE_MODELS)
|
|
54
|
+
VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
|
55
|
+
|
|
56
|
+
# Default model and settings
|
|
57
|
+
# Respect ANTHROPIC_DEFAULT_OPUS_MODEL env var for Foundry/custom deployments
|
|
58
|
+
# Guard against empty/whitespace values by trimming and falling back when blank
|
|
59
|
+
_env_default_model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
|
60
|
+
if _env_default_model is not None:
|
|
61
|
+
_env_default_model = _env_default_model.strip()
|
|
62
|
+
DEFAULT_MODEL = _env_default_model or "claude-opus-4-5-20251101"
|
|
63
|
+
|
|
64
|
+
# Ensure env-provided DEFAULT_MODEL is in VALID_MODELS for validation consistency
|
|
65
|
+
# (idempotent: only adds if missing, doesn't alter AVAILABLE_MODELS semantics)
|
|
66
|
+
if DEFAULT_MODEL and DEFAULT_MODEL not in VALID_MODELS:
|
|
67
|
+
VALID_MODELS.append(DEFAULT_MODEL)
|
|
68
|
+
DEFAULT_YOLO_MODE = False
|
|
69
|
+
|
|
70
|
+
# SQLite connection settings
|
|
71
|
+
SQLITE_TIMEOUT = 30 # seconds to wait for database lock
|
|
72
|
+
SQLITE_MAX_RETRIES = 3 # number of retry attempts on busy database
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# =============================================================================
|
|
76
|
+
# Exceptions
|
|
77
|
+
# =============================================================================
|
|
78
|
+
|
|
79
|
+
class RegistryError(Exception):
|
|
80
|
+
"""Base registry exception."""
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RegistryNotFound(RegistryError):
|
|
85
|
+
"""Registry file doesn't exist."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RegistryCorrupted(RegistryError):
|
|
90
|
+
"""Registry database is corrupted."""
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RegistryPermissionDenied(RegistryError):
|
|
95
|
+
"""Can't read/write registry file."""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# =============================================================================
|
|
100
|
+
# SQLAlchemy Model
|
|
101
|
+
# =============================================================================
|
|
102
|
+
|
|
103
|
+
class Base(DeclarativeBase):
|
|
104
|
+
"""SQLAlchemy 2.0 style declarative base."""
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Project(Base):
|
|
109
|
+
"""SQLAlchemy model for registered projects."""
|
|
110
|
+
__tablename__ = "projects"
|
|
111
|
+
|
|
112
|
+
name = Column(String(50), primary_key=True, index=True)
|
|
113
|
+
path = Column(String, nullable=False) # POSIX format for cross-platform
|
|
114
|
+
created_at = Column(DateTime, nullable=False)
|
|
115
|
+
default_concurrency = Column(Integer, nullable=False, default=3)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Settings(Base):
|
|
119
|
+
"""SQLAlchemy model for global settings (key-value store)."""
|
|
120
|
+
__tablename__ = "settings"
|
|
121
|
+
|
|
122
|
+
key = Column(String(50), primary_key=True)
|
|
123
|
+
value = Column(String(500), nullable=False)
|
|
124
|
+
updated_at = Column(DateTime, nullable=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# =============================================================================
|
|
128
|
+
# Database Connection
|
|
129
|
+
# =============================================================================
|
|
130
|
+
|
|
131
|
+
# Module-level singleton for database engine with thread-safe initialization
|
|
132
|
+
_engine = None
|
|
133
|
+
_SessionLocal = None
|
|
134
|
+
_engine_lock = threading.Lock()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_config_dir() -> Path:
|
|
138
|
+
"""
|
|
139
|
+
Get the config directory: ~/.autoforge/
|
|
140
|
+
|
|
141
|
+
Automatically migrates from ~/.autocoder/ if needed.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Path to ~/.autoforge/ (created if it doesn't exist)
|
|
145
|
+
"""
|
|
146
|
+
_migrate_registry_dir()
|
|
147
|
+
config_dir = Path.home() / ".autoforge"
|
|
148
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
return config_dir
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_registry_path() -> Path:
|
|
153
|
+
"""Get the path to the registry database."""
|
|
154
|
+
return get_config_dir() / "registry.db"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_engine():
|
|
158
|
+
"""
|
|
159
|
+
Get or create the database engine (thread-safe singleton pattern).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Tuple of (engine, SessionLocal)
|
|
163
|
+
"""
|
|
164
|
+
global _engine, _SessionLocal
|
|
165
|
+
|
|
166
|
+
# Double-checked locking for thread safety
|
|
167
|
+
if _engine is None:
|
|
168
|
+
with _engine_lock:
|
|
169
|
+
if _engine is None:
|
|
170
|
+
db_path = get_registry_path()
|
|
171
|
+
db_url = f"sqlite:///{db_path.as_posix()}"
|
|
172
|
+
_engine = create_engine(
|
|
173
|
+
db_url,
|
|
174
|
+
connect_args={
|
|
175
|
+
"check_same_thread": False,
|
|
176
|
+
"timeout": SQLITE_TIMEOUT,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
Base.metadata.create_all(bind=_engine)
|
|
180
|
+
_migrate_add_default_concurrency(_engine)
|
|
181
|
+
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
|
|
182
|
+
logger.debug("Initialized registry database at: %s", db_path)
|
|
183
|
+
|
|
184
|
+
return _engine, _SessionLocal
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _migrate_add_default_concurrency(engine) -> None:
|
|
188
|
+
"""Add default_concurrency column if missing (for existing databases)."""
|
|
189
|
+
with engine.connect() as conn:
|
|
190
|
+
result = conn.execute(text("PRAGMA table_info(projects)"))
|
|
191
|
+
columns = [row[1] for row in result.fetchall()]
|
|
192
|
+
if "default_concurrency" not in columns:
|
|
193
|
+
conn.execute(text(
|
|
194
|
+
"ALTER TABLE projects ADD COLUMN default_concurrency INTEGER DEFAULT 3"
|
|
195
|
+
))
|
|
196
|
+
conn.commit()
|
|
197
|
+
logger.info("Migrated projects table: added default_concurrency column")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@contextmanager
|
|
201
|
+
def _get_session():
|
|
202
|
+
"""
|
|
203
|
+
Context manager for database sessions with automatic commit/rollback.
|
|
204
|
+
|
|
205
|
+
Includes retry logic for SQLite busy database errors.
|
|
206
|
+
|
|
207
|
+
Yields:
|
|
208
|
+
SQLAlchemy session
|
|
209
|
+
"""
|
|
210
|
+
_, SessionLocal = _get_engine()
|
|
211
|
+
session = SessionLocal()
|
|
212
|
+
try:
|
|
213
|
+
yield session
|
|
214
|
+
session.commit()
|
|
215
|
+
except Exception:
|
|
216
|
+
session.rollback()
|
|
217
|
+
raise
|
|
218
|
+
finally:
|
|
219
|
+
session.close()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _with_retry(func, *args, **kwargs):
|
|
223
|
+
"""
|
|
224
|
+
Execute a database operation with retry logic for busy database.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
func: Function to execute
|
|
228
|
+
*args, **kwargs: Arguments to pass to the function
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Result of the function
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
Last exception if all retries fail
|
|
235
|
+
"""
|
|
236
|
+
last_error = None
|
|
237
|
+
for attempt in range(SQLITE_MAX_RETRIES):
|
|
238
|
+
try:
|
|
239
|
+
return func(*args, **kwargs)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
last_error = e
|
|
242
|
+
error_str = str(e).lower()
|
|
243
|
+
if "database is locked" in error_str or "sqlite_busy" in error_str:
|
|
244
|
+
if attempt < SQLITE_MAX_RETRIES - 1:
|
|
245
|
+
wait_time = (2 ** attempt) * 0.1 # Exponential backoff: 0.1s, 0.2s, 0.4s
|
|
246
|
+
logger.warning(
|
|
247
|
+
"Database busy, retrying in %.1fs (attempt %d/%d)",
|
|
248
|
+
wait_time, attempt + 1, SQLITE_MAX_RETRIES
|
|
249
|
+
)
|
|
250
|
+
time.sleep(wait_time)
|
|
251
|
+
continue
|
|
252
|
+
raise
|
|
253
|
+
raise last_error
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# =============================================================================
|
|
257
|
+
# Project CRUD Functions
|
|
258
|
+
# =============================================================================
|
|
259
|
+
|
|
260
|
+
def register_project(name: str, path: Path) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Register a new project in the registry.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
name: The project name (unique identifier).
|
|
266
|
+
path: The absolute path to the project directory.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: If project name is invalid or path is not absolute.
|
|
270
|
+
RegistryError: If a project with that name already exists.
|
|
271
|
+
"""
|
|
272
|
+
# Validate name
|
|
273
|
+
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
|
274
|
+
raise ValueError(
|
|
275
|
+
"Invalid project name. Use only letters, numbers, hyphens, "
|
|
276
|
+
"and underscores (1-50 chars)."
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Ensure path is absolute
|
|
280
|
+
path = Path(path).resolve()
|
|
281
|
+
|
|
282
|
+
with _get_session() as session:
|
|
283
|
+
existing = session.query(Project).filter(Project.name == name).first()
|
|
284
|
+
if existing:
|
|
285
|
+
logger.warning("Attempted to register duplicate project: %s", name)
|
|
286
|
+
raise RegistryError(f"Project '{name}' already exists in registry")
|
|
287
|
+
|
|
288
|
+
project = Project(
|
|
289
|
+
name=name,
|
|
290
|
+
path=path.as_posix(),
|
|
291
|
+
created_at=datetime.now()
|
|
292
|
+
)
|
|
293
|
+
session.add(project)
|
|
294
|
+
|
|
295
|
+
logger.info("Registered project '%s' at path: %s", name, path)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def unregister_project(name: str) -> bool:
|
|
299
|
+
"""
|
|
300
|
+
Remove a project from the registry.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
name: The project name to remove.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
True if removed, False if project wasn't found.
|
|
307
|
+
"""
|
|
308
|
+
with _get_session() as session:
|
|
309
|
+
project = session.query(Project).filter(Project.name == name).first()
|
|
310
|
+
if not project:
|
|
311
|
+
logger.debug("Attempted to unregister non-existent project: %s", name)
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
session.delete(project)
|
|
315
|
+
|
|
316
|
+
logger.info("Unregistered project: %s", name)
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def get_project_path(name: str) -> Path | None:
|
|
321
|
+
"""
|
|
322
|
+
Look up a project's path by name.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
name: The project name.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
The project Path, or None if not found.
|
|
329
|
+
"""
|
|
330
|
+
_, SessionLocal = _get_engine()
|
|
331
|
+
session = SessionLocal()
|
|
332
|
+
try:
|
|
333
|
+
project = session.query(Project).filter(Project.name == name).first()
|
|
334
|
+
if project is None:
|
|
335
|
+
return None
|
|
336
|
+
return Path(project.path)
|
|
337
|
+
finally:
|
|
338
|
+
session.close()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def list_registered_projects() -> dict[str, dict[str, Any]]:
|
|
342
|
+
"""
|
|
343
|
+
Get all registered projects.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Dictionary mapping project names to their info dictionaries.
|
|
347
|
+
"""
|
|
348
|
+
_, SessionLocal = _get_engine()
|
|
349
|
+
session = SessionLocal()
|
|
350
|
+
try:
|
|
351
|
+
projects = session.query(Project).all()
|
|
352
|
+
return {
|
|
353
|
+
p.name: {
|
|
354
|
+
"path": p.path,
|
|
355
|
+
"created_at": p.created_at.isoformat() if p.created_at else None,
|
|
356
|
+
"default_concurrency": getattr(p, 'default_concurrency', 3) or 3
|
|
357
|
+
}
|
|
358
|
+
for p in projects
|
|
359
|
+
}
|
|
360
|
+
finally:
|
|
361
|
+
session.close()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_project_info(name: str) -> dict[str, Any] | None:
|
|
365
|
+
"""
|
|
366
|
+
Get full info about a project.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
name: The project name.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Project info dictionary, or None if not found.
|
|
373
|
+
"""
|
|
374
|
+
_, SessionLocal = _get_engine()
|
|
375
|
+
session = SessionLocal()
|
|
376
|
+
try:
|
|
377
|
+
project = session.query(Project).filter(Project.name == name).first()
|
|
378
|
+
if project is None:
|
|
379
|
+
return None
|
|
380
|
+
return {
|
|
381
|
+
"path": project.path,
|
|
382
|
+
"created_at": project.created_at.isoformat() if project.created_at else None,
|
|
383
|
+
"default_concurrency": getattr(project, 'default_concurrency', 3) or 3
|
|
384
|
+
}
|
|
385
|
+
finally:
|
|
386
|
+
session.close()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def update_project_path(name: str, new_path: Path) -> bool:
|
|
390
|
+
"""
|
|
391
|
+
Update a project's path (for relocating projects).
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
name: The project name.
|
|
395
|
+
new_path: The new absolute path.
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
True if updated, False if project wasn't found.
|
|
399
|
+
"""
|
|
400
|
+
new_path = Path(new_path).resolve()
|
|
401
|
+
|
|
402
|
+
with _get_session() as session:
|
|
403
|
+
project = session.query(Project).filter(Project.name == name).first()
|
|
404
|
+
if not project:
|
|
405
|
+
return False
|
|
406
|
+
|
|
407
|
+
project.path = new_path.as_posix()
|
|
408
|
+
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def get_project_concurrency(name: str) -> int:
|
|
413
|
+
"""
|
|
414
|
+
Get project's default concurrency (1-5).
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
name: The project name.
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
The default concurrency value (defaults to 3 if not set or project not found).
|
|
421
|
+
"""
|
|
422
|
+
_, SessionLocal = _get_engine()
|
|
423
|
+
session = SessionLocal()
|
|
424
|
+
try:
|
|
425
|
+
project = session.query(Project).filter(Project.name == name).first()
|
|
426
|
+
if project is None:
|
|
427
|
+
return 3
|
|
428
|
+
return getattr(project, 'default_concurrency', 3) or 3
|
|
429
|
+
finally:
|
|
430
|
+
session.close()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def set_project_concurrency(name: str, concurrency: int) -> bool:
|
|
434
|
+
"""
|
|
435
|
+
Set project's default concurrency (1-5).
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
name: The project name.
|
|
439
|
+
concurrency: The concurrency value (1-5).
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
True if updated, False if project wasn't found.
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
ValueError: If concurrency is not between 1 and 5.
|
|
446
|
+
"""
|
|
447
|
+
if concurrency < 1 or concurrency > 5:
|
|
448
|
+
raise ValueError("concurrency must be between 1 and 5")
|
|
449
|
+
|
|
450
|
+
with _get_session() as session:
|
|
451
|
+
project = session.query(Project).filter(Project.name == name).first()
|
|
452
|
+
if not project:
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
project.default_concurrency = concurrency
|
|
456
|
+
|
|
457
|
+
logger.info("Set project '%s' default_concurrency to %d", name, concurrency)
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# =============================================================================
|
|
462
|
+
# Validation Functions
|
|
463
|
+
# =============================================================================
|
|
464
|
+
|
|
465
|
+
def validate_project_path(path: Path) -> tuple[bool, str]:
|
|
466
|
+
"""
|
|
467
|
+
Validate that a project path is accessible and writable.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
path: The path to validate.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
Tuple of (is_valid, error_message).
|
|
474
|
+
"""
|
|
475
|
+
path = Path(path).resolve()
|
|
476
|
+
|
|
477
|
+
# Check if path exists
|
|
478
|
+
if not path.exists():
|
|
479
|
+
return False, f"Path does not exist: {path}"
|
|
480
|
+
|
|
481
|
+
# Check if it's a directory
|
|
482
|
+
if not path.is_dir():
|
|
483
|
+
return False, f"Path is not a directory: {path}"
|
|
484
|
+
|
|
485
|
+
# Check read permissions
|
|
486
|
+
if not os.access(path, os.R_OK):
|
|
487
|
+
return False, f"No read permission: {path}"
|
|
488
|
+
|
|
489
|
+
# Check write permissions
|
|
490
|
+
if not os.access(path, os.W_OK):
|
|
491
|
+
return False, f"No write permission: {path}"
|
|
492
|
+
|
|
493
|
+
return True, ""
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def cleanup_stale_projects() -> list[str]:
|
|
497
|
+
"""
|
|
498
|
+
Remove projects from registry whose paths no longer exist.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
List of removed project names.
|
|
502
|
+
"""
|
|
503
|
+
removed = []
|
|
504
|
+
|
|
505
|
+
with _get_session() as session:
|
|
506
|
+
projects = session.query(Project).all()
|
|
507
|
+
for project in projects:
|
|
508
|
+
path = Path(project.path)
|
|
509
|
+
if not path.exists():
|
|
510
|
+
session.delete(project)
|
|
511
|
+
removed.append(project.name)
|
|
512
|
+
|
|
513
|
+
if removed:
|
|
514
|
+
logger.info("Cleaned up stale projects: %s", removed)
|
|
515
|
+
|
|
516
|
+
return removed
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def list_valid_projects() -> list[dict[str, Any]]:
|
|
520
|
+
"""
|
|
521
|
+
List all projects that have valid, accessible paths.
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
List of project info dicts with additional 'name' field.
|
|
525
|
+
"""
|
|
526
|
+
_, SessionLocal = _get_engine()
|
|
527
|
+
session = SessionLocal()
|
|
528
|
+
try:
|
|
529
|
+
projects = session.query(Project).all()
|
|
530
|
+
valid = []
|
|
531
|
+
for p in projects:
|
|
532
|
+
path = Path(p.path)
|
|
533
|
+
is_valid, _ = validate_project_path(path)
|
|
534
|
+
if is_valid:
|
|
535
|
+
valid.append({
|
|
536
|
+
"name": p.name,
|
|
537
|
+
"path": p.path,
|
|
538
|
+
"created_at": p.created_at.isoformat() if p.created_at else None
|
|
539
|
+
})
|
|
540
|
+
return valid
|
|
541
|
+
finally:
|
|
542
|
+
session.close()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# =============================================================================
|
|
546
|
+
# Settings CRUD Functions
|
|
547
|
+
# =============================================================================
|
|
548
|
+
|
|
549
|
+
def get_setting(key: str, default: str | None = None) -> str | None:
|
|
550
|
+
"""
|
|
551
|
+
Get a setting value by key.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
key: The setting key.
|
|
555
|
+
default: Default value if setting doesn't exist or on DB error.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
The setting value, or default if not found or on error.
|
|
559
|
+
"""
|
|
560
|
+
try:
|
|
561
|
+
_, SessionLocal = _get_engine()
|
|
562
|
+
session = SessionLocal()
|
|
563
|
+
try:
|
|
564
|
+
setting = session.query(Settings).filter(Settings.key == key).first()
|
|
565
|
+
return setting.value if setting else default
|
|
566
|
+
finally:
|
|
567
|
+
session.close()
|
|
568
|
+
except Exception as e:
|
|
569
|
+
logger.warning("Failed to read setting '%s': %s", key, e)
|
|
570
|
+
return default
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def set_setting(key: str, value: str) -> None:
|
|
574
|
+
"""
|
|
575
|
+
Set a setting value (creates or updates).
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
key: The setting key.
|
|
579
|
+
value: The setting value.
|
|
580
|
+
"""
|
|
581
|
+
with _get_session() as session:
|
|
582
|
+
setting = session.query(Settings).filter(Settings.key == key).first()
|
|
583
|
+
if setting:
|
|
584
|
+
setting.value = value
|
|
585
|
+
setting.updated_at = datetime.now()
|
|
586
|
+
else:
|
|
587
|
+
setting = Settings(
|
|
588
|
+
key=key,
|
|
589
|
+
value=value,
|
|
590
|
+
updated_at=datetime.now()
|
|
591
|
+
)
|
|
592
|
+
session.add(setting)
|
|
593
|
+
|
|
594
|
+
logger.debug("Set setting '%s' = '%s'", key, value)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def get_all_settings() -> dict[str, str]:
|
|
598
|
+
"""
|
|
599
|
+
Get all settings as a dictionary.
|
|
600
|
+
|
|
601
|
+
Returns:
|
|
602
|
+
Dictionary mapping setting keys to values.
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
_, SessionLocal = _get_engine()
|
|
606
|
+
session = SessionLocal()
|
|
607
|
+
try:
|
|
608
|
+
settings = session.query(Settings).all()
|
|
609
|
+
return {s.key: s.value for s in settings}
|
|
610
|
+
finally:
|
|
611
|
+
session.close()
|
|
612
|
+
except Exception as e:
|
|
613
|
+
logger.warning("Failed to read settings: %s", e)
|
|
614
|
+
return {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Production runtime dependencies only
|
|
2
|
+
# For development, use requirements.txt (includes ruff, mypy, pytest)
|
|
3
|
+
claude-agent-sdk>=0.1.0,<0.2.0
|
|
4
|
+
python-dotenv>=1.0.0
|
|
5
|
+
sqlalchemy>=2.0.0
|
|
6
|
+
fastapi>=0.115.0
|
|
7
|
+
uvicorn[standard]>=0.32.0
|
|
8
|
+
websockets>=13.0
|
|
9
|
+
python-multipart>=0.0.17
|
|
10
|
+
psutil>=6.0.0
|
|
11
|
+
aiofiles>=24.0.0
|
|
12
|
+
apscheduler>=3.10.0,<4.0.0
|
|
13
|
+
pywinpty>=2.0.0; sys_platform == "win32"
|
|
14
|
+
pyyaml>=6.0.0
|