fix windows numpy import and add node timestamps

This commit is contained in:
matei jordache
2026-03-25 13:20:41 -07:00
parent e749d24cfe
commit 006fbc1dde
11 changed files with 263 additions and 8 deletions

View File

@@ -85,6 +85,7 @@ http://127.0.0.1:5173
Notes: Notes:
- The frontend dev server proxies API and WebSocket requests to the backend. - The frontend dev server proxies API and WebSocket requests to the backend.
- If you open the backend directly in a browser instead of the Vite dev server, argonode now refreshes `frontend/dist` automatically when checked-out frontend sources are newer, such as after a `git pull`.
- If you want the frontend accessible from other devices on your LAN, run: - If you want the frontend accessible from other devices on your LAN, run:
```powershell ```powershell

View File

@@ -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()

View File

@@ -23,6 +23,7 @@ The engine:
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from collections import defaultdict, deque from collections import defaultdict, deque
from time import perf_counter
from typing import Any, Callable from typing import Any, Callable
from backend.node_registry import NODE_CLASS_MAPPINGS from backend.node_registry import NODE_CLASS_MAPPINGS
@@ -45,7 +46,7 @@ class ExecutionEngine:
self, self,
prompt: dict[str, dict], prompt: dict[str, dict],
on_node_start: Callable[[str], None] | None = None, 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_preview: Callable[[str, str], None] | None = None,
on_table: Callable[[str, list], None] | None = None, on_table: Callable[[str, list], None] | None = None,
on_mesh: Callable[[str, dict], 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}) prompt : workflow dict (node_id → {class_type, inputs})
on_node_start : called with node_id just before a node executes 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_preview : called with (node_id, data_uri) when a display node runs
on_table : called with (node_id, table_list) when PrintTable runs on_table : called with (node_id, table_list) when PrintTable runs
on_overlay : called with (node_id, data_uri) for interactive overlays on_overlay : called with (node_id, data_uri) for interactive overlays
@@ -96,7 +97,9 @@ class ExecutionEngine:
instance = cls() instance = cls()
func = getattr(instance, cls.FUNCTION) func = getattr(instance, cls.FUNCTION)
start_time = perf_counter()
result = func(**inputs) result = func(**inputs)
elapsed_ms = (perf_counter() - start_time) * 1000.0
# Nodes must return a tuple; coerce single values just in case # Nodes must return a tuple; coerce single values just in case
if not isinstance(result, tuple): if not isinstance(result, tuple):
@@ -110,7 +113,7 @@ class ExecutionEngine:
self._auto_preview(cls, node_id, result, on_preview, on_table) self._auto_preview(cls, node_id, result, on_preview, on_table)
if on_node_done: if on_node_done:
on_node_done(node_id) on_node_done(node_id, elapsed_ms)
return node_outputs return node_outputs

116
backend/frontend_build.py Normal file
View 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

View File

@@ -17,6 +17,7 @@ WebSocket message types sent to clients
{"type": "preview", "data": {"node_id": "...", "image": "data:..."}} {"type": "preview", "data": {"node_id": "...", "image": "data:..."}}
{"type": "table", "data": {"node_id": "...", "rows": [...]}} {"type": "table", "data": {"node_id": "...", "rows": [...]}}
{"type": "scalar", "data": {"node_id": "...", "value": 1.23, "unit": "nm"}} {"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_error", "data": {"node_id": "...", "message": "..."}}
{"type": "execution_complete", "data": {"prompt_id": "..."}} {"type": "execution_complete", "data": {"prompt_id": "..."}}
""" """
@@ -25,15 +26,18 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import sys
from pathlib import Path from pathlib import Path
from aiohttp import web, WSMsgType from aiohttp import web, WSMsgType
from backend.frontend_build import FrontendBuildError, ensure_frontend_dist_ready
from backend.runtime_paths import ( from backend.runtime_paths import (
ensure_runtime_dirs, ensure_runtime_dirs,
frontend_dir, frontend_dir,
frontend_dist_dir, frontend_dist_dir,
input_dir, input_dir,
output_dir, output_dir,
project_root,
) )
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -136,13 +140,30 @@ def create_app(loop: asyncio.AbstractEventLoop) -> web.Application:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def index(request: web.Request) -> web.Response: 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(): if (DIST_DIR / "index.html").exists():
return web.FileResponse(DIST_DIR / "index.html") return web.FileResponse(DIST_DIR / "index.html")
if (FRONTEND_DIR / "index.html").exists(): return web.Response(
return web.FileResponse(FRONTEND_DIR / "index.html") status=500,
raise web.HTTPInternalServerError( text=(
reason="Frontend build not found. Run `npm run build` before launching the packaged app." "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: 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: def on_start(node_id: str) -> None:
broadcast({"type": "executing", "data": {"node": node_id, "prompt_id": prompt_id}}) 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: try:
await loop.run_in_executor( await loop.run_in_executor(
None, None,
lambda: engine.execute( lambda: engine.execute(
prompt, prompt,
on_node_start=on_start, on_node_start=on_start,
on_node_done=on_done,
on_preview=on_preview, on_preview=on_preview,
on_table=on_table, on_table=on_table,
on_mesh=on_mesh, on_mesh=on_mesh,

View File

@@ -492,6 +492,10 @@ function Flow() {
console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || ''); console.log('[argonode] WS:', msg.type, msg.data?.node_id || msg.data?.node || '');
switch (msg.type) { switch (msg.type) {
case 'execution_start': case 'execution_start':
setNodes((ns) => ns.map((n) => ({
...n,
data: { ...n.data, processingTimeMs: null },
})));
setStatus({ text: 'Running workflow…', level: 'info' }); setStatus({ text: 'Running workflow…', level: 'info' });
break; break;
case 'executing': case 'executing':
@@ -518,6 +522,9 @@ function Flow() {
}, },
}); });
break; break;
case 'node_timing':
updateNodeData(msg.data.node_id, { processingTimeMs: msg.data.elapsed_ms });
break;
case 'mesh3d': case 'mesh3d':
updateNodeData(msg.data.node_id, { meshData: msg.data.mesh }); updateNodeData(msg.data.node_id, { meshData: msg.data.mesh });
break; break;
@@ -696,6 +703,7 @@ function Flow() {
meshData: null, meshData: null,
overlay: null, overlay: null,
scalarValue: null, scalarValue: null,
processingTimeMs: null,
}, },
}; };

View File

@@ -326,6 +326,16 @@ function formatScalarDisplay(scalarValue) {
}; };
} }
function formatProcessingTime(value) {
const ms = Number(value);
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1) return `${ms.toFixed(2)} ms`;
if (ms < 10) return `${ms.toFixed(1)} ms`;
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 10000) return `${(ms / 1000).toFixed(2)} s`;
return `${(ms / 1000).toFixed(1)} s`;
}
function getSourceTypeForInput(store, nodeId, inputName) { function getSourceTypeForInput(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`; const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle)); const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
@@ -428,6 +438,7 @@ function CustomNode({ id, data }) {
const ctx = useContext(NodeContext); const ctx = useContext(NodeContext);
const def = data.definition; const def = data.definition;
const scalarDisplay = formatScalarDisplay(data.scalarValue); const scalarDisplay = formatScalarDisplay(data.scalarValue);
const processingTimeText = formatProcessingTime(data.processingTimeMs);
// Parse inputs into data handles and widgets // Parse inputs into data handles and widgets
const required = def.input.required || {}; const required = def.input.required || {};
@@ -699,6 +710,11 @@ function CustomNode({ id, data }) {
<NodeTable rows={data.tableRows} /> <NodeTable rows={data.tableRows} />
</CollapsibleSection> </CollapsibleSection>
)} )}
{processingTimeText && (
<div className="node-benchmark" title={`Processed in ${processingTimeText}`}>
{processingTimeText}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -150,6 +150,8 @@ html, body, #root {
.node-body { .node-body {
padding: 4px 0; padding: 4px 0;
display: flex;
flex-direction: column;
} }
.node-warning { .node-warning {
@@ -628,6 +630,20 @@ html, body, #root {
text-align: right !important; text-align: right !important;
} }
.node-benchmark {
align-self: flex-end;
margin: 8px 10px 4px;
padding: 3px 7px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 999px;
background: rgba(15, 23, 42, 0.92);
color: #94a3b8;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 10px;
line-height: 1;
font-variant-numeric: tabular-nums lining-nums;
}
/* ── Node resize handles ───────────────────────────────────────────── */ /* ── Node resize handles ───────────────────────────────────────────── */
.node-resize-line { .node-resize-line {
border-color: #90caf9 !important; border-color: #90caf9 !important;

View File

@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: true,
port: 5173, port: 5173,
proxy: { proxy: {
'/nodes': 'http://127.0.0.1:8188', '/nodes': 'http://127.0.0.1:8188',

View File

@@ -0,0 +1,40 @@
import os
from pathlib import Path
from backend.frontend_build import frontend_dist_is_stale
def _write_with_mtime(path: Path, content: str, timestamp: float) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
os.utime(path, (timestamp, timestamp))
def test_frontend_dist_is_stale_when_dist_is_missing(tmp_path: Path):
frontend_dir = tmp_path / "frontend"
dist_dir = frontend_dir / "dist"
_write_with_mtime(frontend_dir / "src" / "main.jsx", "export default 1;", 200.0)
assert frontend_dist_is_stale(frontend_dir, dist_dir) is True
def test_frontend_dist_is_stale_when_source_is_newer(tmp_path: Path):
frontend_dir = tmp_path / "frontend"
dist_dir = frontend_dir / "dist"
_write_with_mtime(dist_dir / "index.html", "<html></html>", 100.0)
_write_with_mtime(frontend_dir / "src" / "App.jsx", "export default 1;", 200.0)
assert frontend_dist_is_stale(frontend_dir, dist_dir) is True
def test_frontend_dist_is_current_when_dist_is_newer(tmp_path: Path):
frontend_dir = tmp_path / "frontend"
dist_dir = frontend_dir / "dist"
_write_with_mtime(frontend_dir / "src" / "App.jsx", "export default 1;", 100.0)
_write_with_mtime(dist_dir / "assets" / "app.js", "console.log('ok');", 200.0)
_write_with_mtime(dist_dir / "index.html", "<html></html>", 200.0)
assert frontend_dist_is_stale(frontend_dir, dist_dir) is False

View File

@@ -0,0 +1,8 @@
import backend # noqa: F401
import numpy as np
def test_numpy_compat_aliases_are_available_after_backend_import():
assert np.complex is complex
assert np.float is float
assert np.int is int