Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified bin/autoforge.js
100644 → 100755
Empty file.
12 changes: 9 additions & 3 deletions server/routers/assistant_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,20 +217,26 @@ async def assistant_chat_websocket(websocket: WebSocket, project_name: str):
- {"type": "error", "content": "..."} - Error message
- {"type": "pong"} - Keep-alive pong
"""
if not validate_project_name(project_name):
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()

try:
project_name = validate_project_name(project_name)
except HTTPException:
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return

project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return

await websocket.accept()
logger.info(f"Assistant WebSocket connected for project: {project_name}")

session: Optional[AssistantChatSession] = None
Expand Down
10 changes: 8 additions & 2 deletions server/routers/expand_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,31 +104,37 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
- {"type": "error", "content": "..."} - Error message
- {"type": "pong"} - Keep-alive pong
"""
# Always accept the WebSocket first to avoid opaque 403 errors.
# Starlette returns 403 if we close before accepting.
await websocket.accept()

try:
project_name = validate_project_name(project_name)
except HTTPException:
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return

# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return

# Verify project has app_spec.txt
from autoforge_paths import get_prompts_dir
spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
if not spec_path.exists():
await websocket.send_json({"type": "error", "content": "Project has no spec. Create a spec first before expanding."})
await websocket.close(code=4004, reason="Project has no spec. Create spec first.")
return

await websocket.accept()

session: Optional[ExpandChatSession] = None

try:
Expand Down
12 changes: 9 additions & 3 deletions server/routers/spec_creation.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,22 +166,28 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
- {"type": "error", "content": "..."} - Error message
- {"type": "pong"} - Keep-alive pong
"""
if not validate_project_name(project_name):
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()

try:
project_name = validate_project_name(project_name)
except HTTPException:
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return

# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return

await websocket.accept()

session: Optional[SpecChatSession] = None

try:
Expand Down
10 changes: 8 additions & 2 deletions server/routers/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,20 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
- {"type": "pong"} - Keep-alive response
- {"type": "error", "message": "..."} - Error message
"""
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()

# Validate project name
if not validate_project_name(project_name):
await websocket.send_json({"type": "error", "message": "Invalid project name"})
await websocket.close(
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
)
return

# Validate terminal ID
if not validate_terminal_id(terminal_id):
await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
await websocket.close(
code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
)
Expand All @@ -238,13 +243,15 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
# Look up project directory from registry
project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "message": "Project not found in registry"})
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project not found in registry",
)
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "message": "Project directory not found"})
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Project directory not found",
Expand All @@ -254,14 +261,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
# Verify terminal exists in metadata
terminal_info = get_terminal_info(project_name, terminal_id)
if not terminal_info:
await websocket.send_json({"type": "error", "message": "Terminal not found"})
await websocket.close(
code=TerminalCloseCode.PROJECT_NOT_FOUND,
reason="Terminal not found",
)
return

await websocket.accept()

# Get or create terminal session for this project/terminal
session = get_terminal_session(project_name, project_dir, terminal_id)

Expand Down
13 changes: 8 additions & 5 deletions server/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,9 +640,7 @@ def __init__(self):
self._lock = asyncio.Lock()

async def connect(self, websocket: WebSocket, project_name: str):
"""Accept a WebSocket connection for a project."""
await websocket.accept()

"""Register a WebSocket connection for a project (must already be accepted)."""
async with self._lock:
if project_name not in self.active_connections:
self.active_connections[project_name] = set()
Expand Down Expand Up @@ -727,16 +725,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
- Agent status changes
- Agent stdout/stderr lines
"""
# Always accept WebSocket first to avoid opaque 403 errors
await websocket.accept()

if not validate_project_name(project_name):
await websocket.send_json({"type": "error", "content": "Invalid project name"})
await websocket.close(code=4000, reason="Invalid project name")
return

project_dir = _get_project_path(project_name)
if not project_dir:
await websocket.send_json({"type": "error", "content": "Project not found in registry"})
await websocket.close(code=4004, reason="Project not found in registry")
return

if not project_dir.exists():
await websocket.send_json({"type": "error", "content": "Project directory not found"})
await websocket.close(code=4004, reason="Project directory not found")
return

Expand Down Expand Up @@ -879,8 +883,7 @@ async def on_dev_status_change(status: str):
break
except json.JSONDecodeError:
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
except Exception as e:
logger.warning(f"WebSocket error: {e}")
except Exception:
break

finally:
Expand Down
8 changes: 4 additions & 4 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ function App() {
setShowAddFeature(true)
}

// E : Expand project with AI (when project selected and has features)
if ((e.key === 'e' || e.key === 'E') && selectedProject && features &&
// E : Expand project with AI (when project selected, has spec and has features)
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features &&
(features.pending.length + features.in_progress.length + features.done.length) > 0) {
e.preventDefault()
setShowExpandProject(true)
Expand Down Expand Up @@ -239,7 +239,7 @@ function App() {

window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus])
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode, showResetModal, wsState.agentStatus, hasSpec])

// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
Expand Down Expand Up @@ -490,7 +490,7 @@ function App() {
)}

{/* Expand Project Modal - AI-powered bulk feature creation */}
{showExpandProject && selectedProject && (
{showExpandProject && selectedProject && hasSpec && (
<ExpandProjectModal
isOpen={showExpandProject}
projectName={selectedProject}
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/KanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
onFeatureClick={onFeatureClick}
onAddFeature={onAddFeature}
onExpandProject={onExpandProject}
showExpandButton={hasFeatures}
showExpandButton={hasFeatures && hasSpec}
onCreateSpec={onCreateSpec}
showCreateSpec={!hasSpec && !hasFeatures}
/>
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/KeyboardShortcutsHelp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const shortcuts: Shortcut[] = [
{ key: 'D', description: 'Toggle debug panel' },
{ key: 'T', description: 'Toggle terminal tab' },
{ key: 'N', description: 'Add new feature', context: 'with project' },
{ key: 'E', description: 'Expand project with AI', context: 'with features' },
{ key: 'E', description: 'Expand project with AI', context: 'with spec & features' },
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
{ key: ',', description: 'Open settings' },
Expand Down
6 changes: 5 additions & 1 deletion ui/src/hooks/useExpandChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,20 @@ export function useExpandChat({
}, 30000)
}

ws.onclose = () => {
ws.onclose = (event) => {
setConnectionStatus('disconnected')
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
}

// Don't retry on application-level errors (4xxx codes won't resolve on retry)
const isAppError = event.code >= 4000 && event.code <= 4999

// Attempt reconnection if not intentionally closed
if (
!manuallyDisconnectedRef.current &&
!isAppError &&
reconnectAttempts.current < maxReconnectAttempts &&
!isCompleteRef.current
) {
Expand Down
7 changes: 5 additions & 2 deletions ui/src/hooks/useSpecChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,18 @@ export function useSpecChat({
}, 30000)
}

ws.onclose = () => {
ws.onclose = (event) => {
setConnectionStatus('disconnected')
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current)
pingIntervalRef.current = null
}

// Don't retry on application-level errors (4xxx codes won't resolve on retry)
const isAppError = event.code >= 4000 && event.code <= 4999

// Attempt reconnection if not intentionally closed
if (reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
if (!isAppError && reconnectAttempts.current < maxReconnectAttempts && !isCompleteRef.current) {
reconnectAttempts.current++
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 10000)
reconnectTimeoutRef.current = window.setTimeout(connect, delay)
Expand Down
6 changes: 5 additions & 1 deletion ui/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,10 +335,14 @@ export function useProjectWebSocket(projectName: string | null) {
}
}

ws.onclose = () => {
ws.onclose = (event) => {
setState(prev => ({ ...prev, isConnected: false }))
wsRef.current = null

// Don't retry on application-level errors (4xxx codes won't resolve on retry)
const isAppError = event.code >= 4000 && event.code <= 4999
if (isAppError) return

// Exponential backoff reconnection
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000)
reconnectAttempts.current++
Expand Down
Loading