Skip to content
Merged
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
54 changes: 54 additions & 0 deletions .github/workflows/pid-designer-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: P&ID Designer CI

'on':
push:
paths:
- 'pid-designer/**'
- '.github/workflows/pid-designer-ci.yml'
pull_request:
paths:
- 'pid-designer/**'
- '.github/workflows/pid-designer-ci.yml'
workflow_dispatch:

jobs:
frontend:
name: Frontend (TypeScript build)
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: pid-designer/frontend/package-lock.json

- name: Install dependencies
working-directory: pid-designer/frontend
run: npm ci || npm install

- name: TypeScript build
working-directory: pid-designer/frontend
run: npm run build

backend:
name: Backend (import check)
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: pip install "fastapi>=0.128" "uvicorn[standard]>=0.34" "pydantic>=2.12"

- name: Verify imports
working-directory: pid-designer
run: python3 -c "import backend.main; print('backend imports OK')"
6 changes: 6 additions & 0 deletions pid-designer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
__pycache__/
*.pyc
*.pyo
.vite/
dist/
Empty file.
33 changes: 33 additions & 0 deletions pid-designer/backend/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Standalone FastAPI backend for the P&ID Designer.

Run with:
cd pid-designer
uvicorn backend.main:app --reload --port 8001
"""

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from backend.routers import pid

app = FastAPI(title="P&ID Designer API", version="1.0.0")

app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:5174",
"http://127.0.0.1:5173",
"http://127.0.0.1:5174",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(pid.router)


@app.get("/api/health")
async def health():
return {"status": "healthy"}
Empty file.
141 changes: 141 additions & 0 deletions pid-designer/backend/routers/pid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""PID diagram persistence and git-backed version control."""

import json
import subprocess
from pathlib import Path

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Any

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

REPO_ROOT = Path(__file__).resolve().parents[2]
DIAGRAM_PATH = REPO_ROOT / "diagrams" / "pid_main.json"

def _git_root() -> Path:
r = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=REPO_ROOT,
capture_output=True, text=True)
return Path(r.stdout.strip())

GIT_ROOT = _git_root()
# Path used in `git show <hash>:<path>` — must be relative to the git repo root.
GIT_DIAGRAM_PATH = str(DIAGRAM_PATH.relative_to(GIT_ROOT))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Git root resolved at import

Medium Severity

_git_root() ignores git exit status and module-level GIT_DIAGRAM_PATH is computed at import; a failed rev-parse can make router import crash or break version restore paths.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d0e413f. Configure here.



def _run_git(*args: str) -> str:
"""Run a git command in the repo root, return stdout. Raises RuntimeError on failure."""
result = subprocess.run(
["git", *args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
return result.stdout


def _read_diagram() -> dict:
if not DIAGRAM_PATH.exists():
return {"nodes": [], "edges": []}
return json.loads(DIAGRAM_PATH.read_text())


def _write_diagram(data: dict) -> None:
DIAGRAM_PATH.parent.mkdir(parents=True, exist_ok=True)
DIAGRAM_PATH.write_text(json.dumps(data, indent=2))


# ── Request models ────────────────────────────────────────────────────────────

class DiagramPayload(BaseModel):
nodes: list[Any]
edges: list[Any]


class CheckpointPayload(BaseModel):
nodes: list[Any]
edges: list[Any]
title: str
description: str = ""


# ── Endpoints ─────────────────────────────────────────────────────────────────

@router.get("/load")
async def load_diagram():
"""Load the current diagram from disk."""
return _read_diagram()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Load crashes on bad JSON

Medium Severity

_read_diagram calls json.loads without handling decode errors, so a corrupted pid_main.json causes /api/pid/load to return an unhandled 500 instead of a safe fallback.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d0e413f. Configure here.



@router.post("/autosave")
async def autosave_diagram(payload: DiagramPayload):
"""Write diagram to disk silently — no git commit."""
_write_diagram({"nodes": payload.nodes, "edges": payload.edges})
return {"ok": True}


@router.post("/pull")
async def pull_latest():
"""Git pull then return the updated diagram."""
try:
_run_git("pull")
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"git pull failed: {e}")
return _read_diagram()


@router.post("/checkpoint")
async def checkpoint(payload: CheckpointPayload):
"""Write diagram, commit, and push with a user-provided title + description."""
_write_diagram({"nodes": payload.nodes, "edges": payload.edges})

commit_message = payload.title.strip()
if payload.description.strip():
commit_message += f"\n\n{payload.description.strip()}"

try:
rel_path = str(DIAGRAM_PATH.relative_to(REPO_ROOT))
_run_git("add", rel_path)
_run_git("commit", "-m", commit_message)
_run_git("push")
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"git operation failed: {e}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Checkpoint fails unchanged file

Medium Severity

After autosave has already written the same JSON, git commit often has nothing to commit and the checkpoint endpoint returns 500 even though the diagram on disk is current.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 64b27b5. Configure here.


commit_hash = _run_git("rev-parse", "--short", "HEAD").strip()
return {"ok": True, "commit": commit_hash}


@router.get("/history")
async def get_history():
"""Return the last 10 commits that touched diagrams/pid_main.json."""
rel_path = str(DIAGRAM_PATH.relative_to(REPO_ROOT))
try:
log = _run_git("log", "--format=%H|%s|%aI", "-10", "--", rel_path)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"git log failed: {e}")

entries = []
for line in log.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("|", 2)
if len(parts) == 3:
entries.append({"hash": parts[0], "title": parts[1], "timestamp": parts[2]})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

History breaks on pipe titles

Medium Severity

History lines join hash, subject, and ISO time with |, then split with split("|", 2). Checkpoint titles containing | produce wrong titles and timestamps in the History UI and confuse restore labels.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cd9402f. Configure here.

return entries


@router.get("/version/{commit_hash}")
async def get_version(commit_hash: str):
"""Return the diagram snapshot at a specific commit."""
try:
content = _run_git("show", f"{commit_hash}:{GIT_DIAGRAM_PATH}")
except RuntimeError as e:
raise HTTPException(status_code=404, detail=f"Version not found: {e}")
try:
return json.loads(content)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Snapshot at that commit is not valid JSON")
29 changes: 29 additions & 0 deletions pid-designer/dev.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

cleanup() {
echo ""
echo "Shutting down..."
kill "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null
wait "$BACKEND_PID" "$FRONTEND_PID" 2>/dev/null
}
trap cleanup EXIT INT TERM

# Install frontend deps if needed
if [ ! -d "frontend/node_modules" ]; then
echo "Installing frontend dependencies..."
(cd frontend && npm install)
fi

echo "Starting backend on http://localhost:8001"
uvicorn backend.main:app --reload --port 8001 &
BACKEND_PID=$!

echo "Starting frontend on http://localhost:5174"
(cd frontend && npm run dev) &
FRONTEND_PID=$!

wait "$BACKEND_PID" "$FRONTEND_PID"
Empty file added pid-designer/diagrams/.gitkeep
Empty file.
12 changes: 12 additions & 0 deletions pid-designer/frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P&ID Designer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading