fix windows numpy import and add node timestamps
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _apply_numpy_compat_aliases() -> None:
|
||||
"""Restore removed NumPy scalar aliases still used by some dependencies."""
|
||||
aliases = {
|
||||
"complex": complex,
|
||||
"float": float,
|
||||
"int": int,
|
||||
}
|
||||
for name, value in aliases.items():
|
||||
if not hasattr(np, name):
|
||||
setattr(np, name, value)
|
||||
|
||||
|
||||
_apply_numpy_compat_aliases()
|
||||
|
||||
@@ -23,6 +23,7 @@ The engine:
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from collections import defaultdict, deque
|
||||
from time import perf_counter
|
||||
from typing import Any, Callable
|
||||
|
||||
from backend.node_registry import NODE_CLASS_MAPPINGS
|
||||
@@ -45,7 +46,7 @@ class ExecutionEngine:
|
||||
self,
|
||||
prompt: dict[str, dict],
|
||||
on_node_start: Callable[[str], None] | None = None,
|
||||
on_node_done: Callable[[str], None] | None = None,
|
||||
on_node_done: Callable[[str, float], None] | None = None,
|
||||
on_preview: Callable[[str, str], None] | None = None,
|
||||
on_table: Callable[[str, list], None] | None = None,
|
||||
on_mesh: Callable[[str, dict], None] | None = None,
|
||||
@@ -60,7 +61,7 @@ class ExecutionEngine:
|
||||
----------
|
||||
prompt : workflow dict (node_id → {class_type, inputs})
|
||||
on_node_start : called with node_id just before a node executes
|
||||
on_node_done : called with node_id just after a node executes
|
||||
on_node_done : called with (node_id, elapsed_ms) just after a node executes
|
||||
on_preview : called with (node_id, data_uri) when a display node runs
|
||||
on_table : called with (node_id, table_list) when PrintTable runs
|
||||
on_overlay : called with (node_id, data_uri) for interactive overlays
|
||||
@@ -96,7 +97,9 @@ class ExecutionEngine:
|
||||
|
||||
instance = cls()
|
||||
func = getattr(instance, cls.FUNCTION)
|
||||
start_time = perf_counter()
|
||||
result = func(**inputs)
|
||||
elapsed_ms = (perf_counter() - start_time) * 1000.0
|
||||
|
||||
# Nodes must return a tuple; coerce single values just in case
|
||||
if not isinstance(result, tuple):
|
||||
@@ -110,7 +113,7 @@ class ExecutionEngine:
|
||||
self._auto_preview(cls, node_id, result, on_preview, on_table)
|
||||
|
||||
if on_node_done:
|
||||
on_node_done(node_id)
|
||||
on_node_done(node_id, elapsed_ms)
|
||||
|
||||
return node_outputs
|
||||
|
||||
|
||||
116
backend/frontend_build.py
Normal file
116
backend/frontend_build.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
_BUILD_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class FrontendBuildError(RuntimeError):
|
||||
"""Raised when the source frontend could not be rebuilt."""
|
||||
|
||||
|
||||
def _latest_mtime(root: Path) -> float:
|
||||
if not root.exists():
|
||||
return 0.0
|
||||
return max(
|
||||
(path.stat().st_mtime for path in root.rglob("*") if path.is_file()),
|
||||
default=0.0,
|
||||
)
|
||||
|
||||
|
||||
def source_frontend_mtime(frontend_dir: Path) -> float:
|
||||
tracked_files = (
|
||||
frontend_dir / "index.html",
|
||||
frontend_dir / "package.json",
|
||||
frontend_dir / "package-lock.json",
|
||||
frontend_dir / "vite.config.js",
|
||||
)
|
||||
mtimes = [path.stat().st_mtime for path in tracked_files if path.exists()]
|
||||
mtimes.append(_latest_mtime(frontend_dir / "src"))
|
||||
mtimes.append(_latest_mtime(frontend_dir / "public"))
|
||||
return max(mtimes, default=0.0)
|
||||
|
||||
|
||||
def dist_frontend_mtime(dist_dir: Path) -> float:
|
||||
if not (dist_dir / "index.html").exists():
|
||||
return 0.0
|
||||
return _latest_mtime(dist_dir)
|
||||
|
||||
|
||||
def frontend_dist_is_stale(frontend_dir: Path, dist_dir: Path) -> bool:
|
||||
return dist_frontend_mtime(dist_dir) < source_frontend_mtime(frontend_dir)
|
||||
|
||||
|
||||
def _npm_executable() -> str:
|
||||
return "npm.cmd" if os.name == "nt" else "npm"
|
||||
|
||||
|
||||
def _format_build_output(stdout: str, stderr: str) -> str:
|
||||
combined = "\n".join(part.strip() for part in (stdout, stderr) if part.strip())
|
||||
if not combined:
|
||||
return ""
|
||||
lines = combined.splitlines()
|
||||
if len(lines) > 40:
|
||||
lines = lines[-40:]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_failure_hint(detail: str) -> str:
|
||||
if "spawn EPERM" not in detail:
|
||||
return ""
|
||||
return (
|
||||
"Windows note: this build hit `spawn EPERM`, which usually means the current Node.js "
|
||||
"install is not cooperating with esbuild. Node 20 LTS is the safest option here. "
|
||||
"After switching, run `npm --prefix frontend install` and try again."
|
||||
)
|
||||
|
||||
|
||||
def ensure_frontend_dist_ready(
|
||||
project_root: Path,
|
||||
frontend_dir: Path,
|
||||
dist_dir: Path,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> bool:
|
||||
if not frontend_dist_is_stale(frontend_dir, dist_dir):
|
||||
return False
|
||||
|
||||
with _BUILD_LOCK:
|
||||
if not frontend_dist_is_stale(frontend_dir, dist_dir):
|
||||
return False
|
||||
|
||||
if logger is not None:
|
||||
logger.info("Frontend sources changed; rebuilding frontend/dist before serving the web app.")
|
||||
|
||||
result = subprocess.run(
|
||||
[_npm_executable(), "run", "build"],
|
||||
cwd=str(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = _format_build_output(result.stdout, result.stderr)
|
||||
message = (
|
||||
"Frontend build failed while refreshing frontend/dist. "
|
||||
"Run `npm run build` from the repo root to inspect the full error."
|
||||
)
|
||||
hint = _build_failure_hint(detail)
|
||||
if hint:
|
||||
message = f"{message}\n\n{hint}"
|
||||
if detail:
|
||||
message = f"{message}\n\n{detail}"
|
||||
raise FrontendBuildError(message)
|
||||
|
||||
if not (dist_dir / "index.html").exists():
|
||||
raise FrontendBuildError(
|
||||
"Frontend rebuild finished, but frontend/dist/index.html is still missing."
|
||||
)
|
||||
|
||||
if logger is not None:
|
||||
logger.info("Frontend rebuild complete.")
|
||||
|
||||
return True
|
||||
@@ -17,6 +17,7 @@ WebSocket message types sent to clients
|
||||
{"type": "preview", "data": {"node_id": "...", "image": "data:..."}}
|
||||
{"type": "table", "data": {"node_id": "...", "rows": [...]}}
|
||||
{"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}}
|
||||
{"type": "node_timing", "data": {"node_id": "...", "elapsed_ms": 12.34}}
|
||||
{"type": "execution_error", "data": {"node_id": "...", "message": "..."}}
|
||||
{"type": "execution_complete", "data": {"prompt_id": "..."}}
|
||||
"""
|
||||
@@ -25,15 +26,18 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web, WSMsgType
|
||||
from backend.frontend_build import FrontendBuildError, ensure_frontend_dist_ready
|
||||
from backend.runtime_paths import (
|
||||
ensure_runtime_dirs,
|
||||
frontend_dir,
|
||||
frontend_dist_dir,
|
||||
input_dir,
|
||||
output_dir,
|
||||
project_root,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -136,13 +140,30 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def index(request: web.Request) -> web.Response:
|
||||
# Serve Vite build output if available, else raw frontend
|
||||
if not getattr(sys, "frozen", False):
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: ensure_frontend_dist_ready(
|
||||
project_root(),
|
||||
FRONTEND_DIR,
|
||||
DIST_DIR,
|
||||
logger=log,
|
||||
),
|
||||
)
|
||||
except FrontendBuildError as exc:
|
||||
log.error("Unable to refresh frontend build: %s", exc)
|
||||
return web.Response(status=500, text=str(exc), content_type="text/plain")
|
||||
|
||||
if (DIST_DIR / "index.html").exists():
|
||||
return web.FileResponse(DIST_DIR / "index.html")
|
||||
if (FRONTEND_DIR / "index.html").exists():
|
||||
return web.FileResponse(FRONTEND_DIR / "index.html")
|
||||
raise web.HTTPInternalServerError(
|
||||
reason="Frontend build not found. Run `npm run build` before launching the packaged app."
|
||||
return web.Response(
|
||||
status=500,
|
||||
text=(
|
||||
"Frontend build not found. Run `npm run build` from the repo root, "
|
||||
"or use `npm run dev` for the Vite development server."
|
||||
),
|
||||
content_type="text/plain",
|
||||
)
|
||||
|
||||
async def get_nodes(request: web.Request) -> web.Response:
|
||||
@@ -264,12 +285,19 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
|
||||
def on_start(node_id: str) -> None:
|
||||
broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}})
|
||||
|
||||
def on_done(node_id: str, elapsed_ms: float) -> None:
|
||||
broadcast({
|
||||
"type": "node_timing",
|
||||
"data": {"node_id": node_id, "elapsed_ms": elapsed_ms},
|
||||
})
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: engine.execute(
|
||||
prompt,
|
||||
on_node_start=on_start,
|
||||
on_node_done=on_done,
|
||||
on_preview=on_preview,
|
||||
on_table=on_table,
|
||||
on_mesh=on_mesh,
|
||||
|
||||
Reference in New Issue
Block a user