Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
cc59355
feat(web-ui): implement Phase 3 workspace view with TDD
Feb 4, 2026
ce27326
fix(workspace): handle missing tables in schema upgrades
Feb 4, 2026
d4628c6
feat(web-ui): add workspace path selection
Feb 4, 2026
e2aa9c6
chore(web-ui): upgrade to Next.js 16 with React 19
Feb 4, 2026
faead7e
fix(workspace): add missing tasks columns to schema upgrades
Feb 4, 2026
150e129
fix(schema): run migrations before creating indexes
Feb 4, 2026
fabdc83
fix(server): use separate database for persistence layer
Feb 4, 2026
86f1299
refactor(server): remove v1 persistence layer and v1 routers
Feb 4, 2026
460a018
feat(web-ui): wire activity feed to events API
Feb 4, 2026
38d0eb6
fix(lint): remove unused Query import from v1 tasks router
Feb 4, 2026
a1c97e4
fix(tests): skip v1 legacy tests that depend on removed persistence l…
Feb 4, 2026
d76b303
fix(tests): move imports before collect_ignore to satisfy ruff E402
Feb 4, 2026
72b9927
fix(tests): skip additional v1 API tests that depend on app.state.db
Feb 4, 2026
ac91fac
test(events): add integration tests for events_v2 router
Feb 4, 2026
6b58aa2
ci: temporarily lower coverage threshold to 60%
Feb 4, 2026
3ff5cc5
fix(web-ui): resolve nested button hydration error and add tests
Feb 4, 2026
7ccccec
fix: address code review issues for PR #335
Feb 4, 2026
9ab544c
fix(web-ui): add missing MERGED status to frontend types
Feb 4, 2026
cd5f39d
fix(security): address high priority code review issues
Feb 4, 2026
0152c3a
fix(web-ui): normalize FastAPI validation errors to string
Feb 4, 2026
b77dda4
fix(api): add rate limiting to events endpoint
Feb 4, 2026
a59fcdc
feat(web-ui): add public assets and metadata configuration
Feb 4, 2026
d310e23
fix(web-ui): move icons to app directory for Next.js detection
Feb 4, 2026
c41fa61
ci: align .coveragerc threshold with test.yml (60%)
Feb 4, 2026
2f6f077
docs: add TODO for missing auth in events_v2 (#336)
Feb 4, 2026
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 removed codeframe.db
Empty file.
102 changes: 66 additions & 36 deletions codeframe/core/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,46 +298,76 @@ def _ensure_schema_upgrades(db_path: Path) -> None:
conn.commit()

# Add tech_stack column to workspace table if it doesn't exist
cursor.execute("PRAGMA table_info(workspace)")
columns = {row[1] for row in cursor.fetchall()}
if "tech_stack" not in columns:
cursor.execute("ALTER TABLE workspace ADD COLUMN tech_stack TEXT")
conn.commit()
# First check if workspace table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='workspace'"
)
if cursor.fetchone():
cursor.execute("PRAGMA table_info(workspace)")
columns = {row[1] for row in cursor.fetchall()}
if "tech_stack" not in columns:
cursor.execute("ALTER TABLE workspace ADD COLUMN tech_stack TEXT")
conn.commit()

# Add versioning columns to prds table if they don't exist
cursor.execute("PRAGMA table_info(prds)")
prd_columns = {row[1] for row in cursor.fetchall()}
if "version" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN version INTEGER DEFAULT 1")
conn.commit()
if "parent_id" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN parent_id TEXT")
conn.commit()
if "change_summary" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN change_summary TEXT")
conn.commit()
if "chain_id" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN chain_id TEXT")
# Backfill chain_id for existing PRDs (set to their own id if no parent)
cursor.execute("""
UPDATE prds SET chain_id = id
WHERE chain_id IS NULL AND parent_id IS NULL
""")
conn.commit()

# Add depends_on column to prds table if it doesn't exist
# Re-check prd_columns as it may have changed
cursor.execute("PRAGMA table_info(prds)")
prd_columns = {row[1] for row in cursor.fetchall()}
if "depends_on" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN depends_on TEXT")
# First check if prds table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='prds'"
)
if cursor.fetchone():
cursor.execute("PRAGMA table_info(prds)")
prd_columns = {row[1] for row in cursor.fetchall()}
if "version" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN version INTEGER DEFAULT 1")
conn.commit()
if "parent_id" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN parent_id TEXT")
conn.commit()
if "change_summary" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN change_summary TEXT")
conn.commit()
if "chain_id" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN chain_id TEXT")
# Backfill chain_id for existing PRDs (set to their own id if no parent)
cursor.execute("""
UPDATE prds SET chain_id = id
WHERE chain_id IS NULL AND parent_id IS NULL
""")
conn.commit()

# Add depends_on column to prds table if it doesn't exist
# Re-check prd_columns as it may have changed
cursor.execute("PRAGMA table_info(prds)")
prd_columns = {row[1] for row in cursor.fetchall()}
if "depends_on" not in prd_columns:
cursor.execute("ALTER TABLE prds ADD COLUMN depends_on TEXT")
conn.commit()

# Add indexes for PRD version chain queries
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_parent ON prds(parent_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_chain ON prds(chain_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_depends_on ON prds(depends_on)")
conn.commit()

# Add indexes for PRD version chain queries
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_parent ON prds(parent_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_chain ON prds(chain_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_prds_depends_on ON prds(depends_on)")
conn.commit()
# Add new columns to tasks table if they don't exist
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'"
)
if cursor.fetchone():
cursor.execute("PRAGMA table_info(tasks)")
task_columns = {row[1] for row in cursor.fetchall()}
if "depends_on" not in task_columns:
cursor.execute("ALTER TABLE tasks ADD COLUMN depends_on TEXT DEFAULT '[]'")
conn.commit()
if "estimated_hours" not in task_columns:
cursor.execute("ALTER TABLE tasks ADD COLUMN estimated_hours REAL")
conn.commit()
if "complexity_score" not in task_columns:
cursor.execute("ALTER TABLE tasks ADD COLUMN complexity_score INTEGER")
conn.commit()
if "uncertainty_level" not in task_columns:
cursor.execute("ALTER TABLE tasks ADD COLUMN uncertainty_level TEXT")
conn.commit()

# Ensure runs table exists before creating dependent tables (run_logs, diagnostic_reports)
cursor.execute(
Expand Down
72 changes: 68 additions & 4 deletions codeframe/persistence/schema_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,13 @@ def create_schema(self) -> None:
# Metrics and audit tables
self._create_metrics_audit_tables(cursor)

# Create all indexes
self._create_indexes(cursor)

# Apply schema migrations for existing databases
# Apply schema migrations for existing databases BEFORE creating indexes
# (indexes may reference columns added by migrations)
self._apply_migrations(cursor)

# Create all indexes (after migrations so all columns exist)
self._create_indexes(cursor)

self.conn.commit()

# Ensure default admin user exists
Expand All @@ -82,6 +83,69 @@ def _apply_migrations(self, cursor: sqlite3.Cursor) -> None:
cursor, "issues", "depends_on", "TEXT"
)

# Migration: Add missing columns to tasks table for older databases
# Core columns that may be missing
self._add_column_if_not_exists(
cursor, "tasks", "project_id", "INTEGER"
)
self._add_column_if_not_exists(
cursor, "tasks", "issue_id", "INTEGER"
)
self._add_column_if_not_exists(
cursor, "tasks", "parent_issue_number", "TEXT"
)
self._add_column_if_not_exists(
cursor, "tasks", "task_number", "TEXT"
)
self._add_column_if_not_exists(
cursor, "tasks", "description", "TEXT"
)
self._add_column_if_not_exists(
cursor, "tasks", "status", "TEXT", "'pending'"
)
self._add_column_if_not_exists(
cursor, "tasks", "priority", "INTEGER", "0"
)
self._add_column_if_not_exists(
cursor, "tasks", "workflow_step", "INTEGER"
)
self._add_column_if_not_exists(
cursor, "tasks", "depends_on", "TEXT"
)
self._add_column_if_not_exists(
cursor, "tasks", "created_at", "TIMESTAMP", "CURRENT_TIMESTAMP"
)
self._add_column_if_not_exists(
cursor, "tasks", "completed_at", "TIMESTAMP"
)
self._add_column_if_not_exists(
cursor, "tasks", "assigned_to", "TEXT"
)
self._add_column_if_not_exists(
cursor, "tasks", "can_parallelize", "BOOLEAN", "FALSE"
)
self._add_column_if_not_exists(
cursor, "tasks", "requires_mcp", "BOOLEAN", "FALSE"
)
self._add_column_if_not_exists(
cursor, "tasks", "estimated_tokens", "INTEGER"
)
self._add_column_if_not_exists(
cursor, "tasks", "actual_tokens", "INTEGER"
)
self._add_column_if_not_exists(
cursor, "tasks", "commit_sha", "TEXT"
)
self._add_column_if_not_exists(
cursor, "tasks", "quality_gate_status", "TEXT", "'pending'"
)
self._add_column_if_not_exists(
cursor, "tasks", "quality_gate_failures", "JSON"
)
self._add_column_if_not_exists(
cursor, "tasks", "requires_human_approval", "BOOLEAN", "FALSE"
)

Comment thread
frankbria marked this conversation as resolved.
# Migration: Add effort estimation columns to tasks table (Phase 1)
self._add_column_if_not_exists(
cursor, "tasks", "estimated_hours", "REAL"
Expand Down
49 changes: 4 additions & 45 deletions codeframe/ui/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,22 @@
"""FastAPI dependency injection providers.

This module provides dependency injection functions for accessing
shared application state (database, workspace manager, etc.) across
all API endpoints.
shared application state across all API endpoints.

Supports both v1 (Database, WorkspaceManager) and v2 (Workspace) patterns.
v2-only: All dependencies use codeframe.core modules.
"""

from pathlib import Path
from typing import Optional

from fastapi import HTTPException, Query, Request, WebSocket
from fastapi import HTTPException, Query, Request

from codeframe.persistence.database import Database
from codeframe.workspace import WorkspaceManager

# v2 imports
from codeframe.core.workspace import Workspace, get_workspace, workspace_exists


def get_db(request: Request) -> Database:
"""Get database connection from application state.

Args:
request: FastAPI request object

Returns:
Database instance from app.state.db

Usage:
@router.get("/endpoint")
async def endpoint(db: Database = Depends(get_db)):
# Use db here
...
"""
return request.app.state.db


def get_workspace_manager(request: Request) -> WorkspaceManager:
"""Get workspace manager from application state.

Expand All @@ -55,24 +35,6 @@ async def endpoint(workspace_mgr: WorkspaceManager = Depends(get_workspace_manag
return request.app.state.workspace_manager


def get_db_websocket(websocket: WebSocket) -> Database:
"""Get database connection from application state for WebSocket endpoints.

Args:
websocket: FastAPI WebSocket object

Returns:
Database instance from app.state.db

Usage:
@router.websocket("/ws/endpoint")
async def websocket_endpoint(websocket: WebSocket, db: Database = Depends(get_db_websocket)):
# Use db here
...
"""
return websocket.app.state.db


def get_v2_workspace(
workspace_path: Optional[str] = Query(
None,
Expand All @@ -82,8 +44,7 @@ def get_v2_workspace(
) -> Workspace:
"""Get v2 Workspace from path or server default.

This dependency bridges v1 routes to v2 core modules. It resolves
a Workspace from either:
This dependency resolves a Workspace from either:
1. An explicit workspace_path query parameter
2. The server's default workspace (from app.state.default_workspace_path)
3. The server's current working directory
Expand Down Expand Up @@ -140,8 +101,6 @@ async def endpoint(workspace: Workspace = Depends(get_v2_workspace)):


__all__ = [
"get_db",
"get_db_websocket",
"get_workspace_manager",
"get_v2_workspace",
]
89 changes: 89 additions & 0 deletions codeframe/ui/routers/events_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Events API router for CodeFRAME v2.

Provides endpoints for fetching workspace activity/event history.
Delegates to codeframe.core.events module.
"""

from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel

from codeframe.core.workspace import Workspace, get_workspace, workspace_exists
from codeframe.core import events
from pathlib import Path

router = APIRouter(prefix="/api/v2/events", tags=["events"])


class EventResponse(BaseModel):
"""Response model for a single event."""

id: int
workspace_id: str
event_type: str
payload: dict
created_at: str # ISO format


class EventListResponse(BaseModel):
"""Response model for event list."""

events: list[EventResponse]
total: int
Comment on lines +33 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading total field semantics for pagination.

The total field returns len(event_list) (count of returned items), but the name implies total count of all matching records—standard pagination semantics. With limit=20 and 100 events existing, total would be 20, not 100.

Either rename to count to reflect actual semantics, or query the true total for proper pagination support.

Option A: Rename to clarify semantics
 class EventListResponse(BaseModel):
     """Response model for event list."""
 
     events: list[EventResponse]
-    total: int
+    count: int  # Number of events in this response
Option B: Return true total for pagination
-    event_list = events.list_recent(workspace, limit=limit, since_id=since_id)
+    event_list, total_count = events.list_recent_with_count(workspace, limit=limit, since_id=since_id)
 
     return EventListResponse(
         events=[...],
-        total=len(event_list),
+        total=total_count,
     )

Also applies to: 71-71

🤖 Prompt for AI Agents
In `@codeframe/ui/routers/events_v2.py` around lines 30 - 34, The
EventListResponse model currently exposes a misleading total field that holds
the returned list length; update the API to use correct pagination semantics by
either (A) renaming the field to count (change EventListResponse.events:
list[EventResponse] and total -> count) and update all usages, or (B) return the
true total number of matching records by modifying the list-producing logic to
run a count query and set EventListResponse.total to that value (ensure any
helper functions that construct EventListResponse and any other occurrences
referenced around the same area (also mentioned at the second occurrence) are
updated accordingly). Ensure consistency across code paths and tests after the
change.



def _get_workspace_from_path(workspace_path: str) -> Workspace:
"""Get workspace from path, raising appropriate HTTP errors."""
path = Path(workspace_path).resolve()

if not workspace_exists(path):
raise HTTPException(
status_code=404,
detail=f"Workspace not found at {path}. Initialize with 'cf init {path}'",
)

workspace = get_workspace(path)
if not workspace:
raise HTTPException(
status_code=404,
detail=f"Workspace not found at {path}",
)
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

return workspace
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


@router.get("", response_model=EventListResponse)
async def list_events(
workspace_path: str = Query(..., description="Path to workspace directory"),
limit: int = Query(20, ge=1, le=100, description="Maximum events to return"),
since_id: Optional[int] = Query(None, description="Only return events after this ID"),
):
"""List recent events for a workspace.

Returns events in reverse chronological order (newest first).

Args:
workspace_path: Path to the workspace
limit: Maximum number of events (1-100, default 20)
since_id: Optional event ID for pagination

Returns:
List of events with total count
"""
workspace = _get_workspace_from_path(workspace_path)

event_list = events.list_recent(workspace, limit=limit, since_id=since_id)
Comment on lines +42 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the events module
find . -name "events.py" -o -name "events" -type d | head -20

Repository: frankbria/codeframe

Length of output: 89


🏁 Script executed:

# Search for list_recent function definition
rg -n -B 2 -A 15 'def list_recent' --type py

Repository: frankbria/codeframe

Length of output: 1020


🏁 Script executed:

# Also check for async list_recent
rg -n -B 2 -A 15 'async def list_recent' --type py

Repository: frankbria/codeframe

Length of output: 45


🏁 Script executed:

# Get the full implementation of list_recent
sed -n '219,260p' codeframe/core/events.py

Repository: frankbria/codeframe

Length of output: 1232


🏁 Script executed:

# Check if get_db_connection is async or sync
rg -n -B 2 -A 10 'def get_db_connection' codeframe/core/events.py --type py

Repository: frankbria/codeframe

Length of output: 45


🏁 Script executed:

# Check the endpoint implementation more fully to see if there's any special handling
sed -n '35,75p' codeframe/ui/routers/events_v2.py

Repository: frankbria/codeframe

Length of output: 1301


Move blocking database operation to async context or executor.

events.list_recent() is a synchronous function that performs blocking database I/O (cursor.execute() with SQL queries), but it's called directly in the async def list_events endpoint without using run_in_executor. This blocks the event loop under load.

Either make list_recent async or wrap the call with run_in_executor:

event_list = await asyncio.get_event_loop().run_in_executor(
    None, events.list_recent, workspace, limit, since_id
)

Alternatively, refactor list_recent to be async and use an async database client.

🤖 Prompt for AI Agents
In `@codeframe/ui/routers/events_v2.py` around lines 39 - 58, The endpoint
list_events is calling the synchronous blocking function events.list_recent
directly from an async def, which can block the event loop; either make
events.list_recent async and use an async DB client, or call the sync function
from a thread executor inside list_events (e.g., await
loop.run_in_executor(None, events.list_recent, workspace, limit, since_id));
update imports to include asyncio/get_event_loop if needed and ensure any return
value handling remains the same.


return EventListResponse(
events=[
EventResponse(
id=e.id,
workspace_id=e.workspace_id,
event_type=e.event_type,
payload=e.payload,
created_at=e.created_at.isoformat(),
)
for e in event_list
],
total=len(event_list),
)
2 changes: 1 addition & 1 deletion codeframe/ui/routers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from datetime import datetime, UTC
from typing import Any, List, Optional

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel, Field, ConfigDict

from codeframe.core.models import Task, TaskStatus
Expand Down
Loading
Loading