feat: implement full arch design dashboard #1

Open
openclaw wants to merge 38 commits from feat/full-implementation into main
5 changed files with 215 additions and 1 deletions
Showing only changes of commit 09167cfe82 - Show all commits

View File

@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.shared.kernel.exceptions import NotFoundError, ValidationError from app.shared.kernel.exceptions import FileSystemError, NotFoundError, ValidationError
from app.shared.infrastructure.config import Settings from app.shared.infrastructure.config import Settings
from app.modules.project.infrastructure.json_repository import JsonProjectRepository from app.modules.project.infrastructure.json_repository import JsonProjectRepository
from app.modules.project.application.services import ProjectService from app.modules.project.application.services import ProjectService
@ -13,6 +13,8 @@ from app.modules.scanner.application.services import ScanService
from app.modules.scanner.interfaces.http.router import router as scanner_router, init_router as init_scanner_router from app.modules.scanner.interfaces.http.router import router as scanner_router, init_router as init_scanner_router
from app.modules.graph.application.services import GraphService from app.modules.graph.application.services import GraphService
from app.modules.graph.interfaces.http.router import router as graph_router, init_router as init_graph_router from app.modules.graph.interfaces.http.router import router as graph_router, init_router as init_graph_router
from app.modules.editor.application.services import EditorService
from app.modules.editor.interfaces.http.router import router as editor_router, init_router as init_editor_router
def create_app() -> FastAPI: def create_app() -> FastAPI:
@ -34,10 +36,15 @@ def create_app() -> FastAPI:
graph_service = GraphService() graph_service = GraphService()
init_graph_router(project_service, scan_service, graph_service) init_graph_router(project_service, scan_service, graph_service)
# Wire Editor module
editor_service = EditorService(scan_service)
init_editor_router(project_service, scan_service, editor_service)
# Register routers # Register routers
app.include_router(project_router, prefix="/api") app.include_router(project_router, prefix="/api")
app.include_router(scanner_router, prefix="/api") app.include_router(scanner_router, prefix="/api")
app.include_router(graph_router, prefix="/api") app.include_router(graph_router, prefix="/api")
app.include_router(editor_router, prefix="/api")
# Health check # Health check
@app.get("/api/health") @app.get("/api/health")
@ -53,6 +60,10 @@ def create_app() -> FastAPI:
async def validation_handler(request: Request, exc: ValidationError): async def validation_handler(request: Request, exc: ValidationError):
return JSONResponse(status_code=400, content={"detail": exc.message}) return JSONResponse(status_code=400, content={"detail": exc.message})
@app.exception_handler(FileSystemError)
async def filesystem_handler(request: Request, exc: FileSystemError):
return JSONResponse(status_code=500, content={"detail": str(exc)})
return app return app

View File

@ -0,0 +1,35 @@
from pathlib import Path
from app.modules.editor.domain.entities import AffectedFile, EditableFile, ImpactResult
from app.modules.editor.infrastructure.file_io import read_file, write_file
from app.modules.project.domain.entities import Project
from app.modules.scanner.application.services import ScanService
from app.modules.scanner.domain.entities import ScanResult
class EditorService:
def __init__(self, scan_service: ScanService) -> None:
self._scan_service = scan_service
def get_file(self, project: Project, relative_path: str) -> EditableFile:
return read_file(Path(project.design_dir), relative_path)
def save_file(self, project: Project, relative_path: str, content: str) -> ScanResult:
write_file(Path(project.design_dir), relative_path, content)
return self._scan_service.scan(project)
def get_impact(
self, project: Project, relative_path: str, scan_result: ScanResult,
) -> ImpactResult:
"""Walk DesignDocument.downstream + TraceabilityLink to find affected files."""
affected: list[AffectedFile] = []
# Find DesignDocument for this file
for doc in scan_result.design_documents:
if doc.file_path == relative_path or relative_path.endswith(doc.file_path):
for downstream in doc.downstream:
affected.append(
AffectedFile(path=downstream, reason=f"downstream of {doc.doc_id}")
)
return ImpactResult(source_file=relative_path, affected_files=affected)

View File

@ -0,0 +1,84 @@
"""Editor HTTP router — file read/write and impact analysis endpoints."""
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.modules.project.application.services import ProjectService
from app.modules.scanner.application.services import ScanService
from app.modules.editor.application.services import EditorService
router = APIRouter(prefix="/projects/{project_id}/files", tags=["editor"])
_project_service: ProjectService | None = None
_scan_service: ScanService | None = None
_editor_service: EditorService | None = None
def init_router(
project_service: ProjectService,
scan_service: ScanService,
editor_service: EditorService,
) -> None:
global _project_service, _scan_service, _editor_service
_project_service = project_service
_scan_service = scan_service
_editor_service = editor_service
class SaveFileRequest(BaseModel):
content: str
def _get_or_trigger_scan(project_id: str):
"""Get cached scan or trigger a new one."""
result = _scan_service.get_latest_scan(project_id)
if result is None:
project = _project_service.get_project(project_id)
result = _scan_service.scan(project)
return result
@router.get("/{path:path}/impact")
def get_impact(project_id: str, path: str):
"""Return impact analysis for a given file."""
project = _project_service.get_project(project_id)
scan_result = _get_or_trigger_scan(project_id)
impact = _editor_service.get_impact(project, path, scan_result)
return asdict(impact)
@router.get("/{path:path}")
def get_file(project_id: str, path: str):
"""Read a design file and return its content."""
project = _project_service.get_project(project_id)
editable = _editor_service.get_file(project, path)
return {
"path": editable.path,
"format": editable.format,
"content": editable.content,
"last_modified": editable.last_modified.isoformat(),
}
@router.put("/{path:path}")
def save_file(project_id: str, path: str, body: SaveFileRequest):
"""Write content to a design file and re-scan."""
project = _project_service.get_project(project_id)
scan_result = _editor_service.save_file(project, path, body.content)
return {
"project_id": scan_result.project_id,
"scanned_at": scan_result.scanned_at.isoformat(),
"summary": {
"total_files": scan_result.summary.total_files,
"ok": scan_result.summary.ok,
"sparse": scan_result.summary.sparse,
"missing": scan_result.summary.missing,
"placeholder_heavy": scan_result.summary.placeholder_heavy,
"template_residue": scan_result.summary.template_residue,
},
}

View File

@ -0,0 +1,32 @@
import pytest
DESIGN_DIR = "/workspace/arch-design-agent-skill-dashboard/design"
@pytest.fixture
def project_id(client):
r = client.post("/api/projects", json={"name": "test", "design_dir": DESIGN_DIR})
return r.json()["id"]
def test_get_file(client, project_id):
r = client.get(f"/api/projects/{project_id}/files/business-architecture/02-capability-map.csv")
assert r.status_code == 200
data = r.json()
assert data["format"] == "csv"
assert "content" in data
def test_get_file_not_found(client, project_id):
r = client.get(f"/api/projects/{project_id}/files/nonexistent.csv")
assert r.status_code == 500 # FileSystemError
def test_get_impact(client, project_id):
# Trigger scan first
client.post(f"/api/projects/{project_id}/scan")
r = client.get(f"/api/projects/{project_id}/files/business-architecture/01-scope-and-goals.md/impact")
assert r.status_code == 200
data = r.json()
assert "source_file" in data
assert "affected_files" in data

View File

@ -0,0 +1,52 @@
import pytest
from datetime import datetime
from pathlib import Path
from app.modules.project.domain.entities import Project
from app.modules.scanner.application.services import ScanService
from app.modules.editor.application.services import EditorService
@pytest.fixture
def editor_service():
return EditorService(ScanService())
@pytest.fixture
def test_project(tmp_path):
design = tmp_path / "design"
design.mkdir()
(design / "test.csv").write_text("col1,col2\nval1,val2\n")
(design / "test.md").write_text("---\ndoc_id: DOC-TEST\ntitle: Test\n---\n# Test\n")
return Project(
id="test", name="test",
design_dir=str(design), code_dir=None,
created_at=datetime(2026, 1, 1),
)
def test_get_file_csv(editor_service, test_project):
f = editor_service.get_file(test_project, "test.csv")
assert f.format == "csv"
assert "col1" in f.content
def test_get_file_md(editor_service, test_project):
f = editor_service.get_file(test_project, "test.md")
assert f.format == "md"
def test_save_file(editor_service, test_project):
result = editor_service.save_file(test_project, "test.csv", "a,b\n1,2\n")
assert result.project_id == "test"
# Verify file was actually written
content = (Path(test_project.design_dir) / "test.csv").read_text()
assert content == "a,b\n1,2\n"
def test_get_impact(editor_service, test_project):
scan_svc = ScanService()
scan_result = scan_svc.scan(test_project)
impact = editor_service.get_impact(test_project, "test.md", scan_result)
assert impact.source_file == "test.md"
assert isinstance(impact.affected_files, list)