Skip to content

zettelkasten.dashboard_launch

zettelkasten.dashboard_launch

Zettelkasten dashboard subprocess lifecycle.

Spawn/stop/monitor helpers for the zettelkasten knowledge-graph dashboard (backend + optional Vite frontend). Extracted from zettelkasten.server for clarity; server re-exports every name here so existing importers keep working unchanged.

launch_dashboard

launch_dashboard(force_restart: bool = False) -> str

Launch the zettelkasten knowledge-graph dashboard (backend + frontend).

Adapts to the install: the backend always starts and, in a plain (non-editable) install, serves the bundled UI itself on :8742. In a source checkout it also starts the Vite dev server on :5174 (live reload). The chat agent runs in-process in the backend (cursor-sdk's bundled bridge — no system Node.js needed). If already running, returns the URL immediately unless force_restart=True.

This is the zettelkasten visualizer — an interactive graph explorer for concept boxes. NOT the memory dashboard.

FEDERATED VIEW — browsing another repo's graphs inside this one: The dashboard is the only place that shows a related repo's zettelkasten alongside this one (the MCP tools stay local-repo only). It is a read-only overlay — external graphs/projects are projected in, never imported or mutated. To set it up, add a federated_repos list to .zettelkasten/config.yaml in THIS repo; each entry needs path (absolute, or relative to the repo root) pointing at the other repo, plus an id and name. The target repo must contain a .zettelkasten/ directory. Its graphs then appear in the picker under a <repo> (read-only) section, namespaced <repo_id>:<graph>. Misconfigured repos surface non-fatal errors at /api/federation.

Returns the dashboard URL and component status.

Source code in zettelkasten/dashboard_launch.py
def launch_dashboard(force_restart: bool = False) -> str:
    """Launch the zettelkasten knowledge-graph dashboard (backend + frontend).

    Adapts to the install: the backend always starts and, in a plain
    (non-editable) install, serves the bundled UI itself on :8742. In a source
    checkout it also starts the Vite dev server on :5174 (live reload). The chat
    agent runs in-process in the backend (cursor-sdk's bundled bridge — no
    system Node.js needed). If already running, returns the URL immediately
    unless force_restart=True.

    This is the zettelkasten visualizer — an interactive graph explorer
    for concept boxes. NOT the memory dashboard.

    FEDERATED VIEW — browsing another repo's graphs inside this one:
    The dashboard is the only place that shows a related repo's zettelkasten
    alongside this one (the MCP tools stay local-repo only). It is a read-only
    overlay — external graphs/projects are projected in, never imported or
    mutated. To set it up, add a `federated_repos` list to
    `.zettelkasten/config.yaml` in THIS repo; each entry needs `path` (absolute,
    or relative to the repo root) pointing at the other repo, plus an `id` and
    `name`. The target repo must contain a `.zettelkasten/` directory. Its graphs
    then appear in the picker under a `<repo> (read-only)` section, namespaced
    `<repo_id>:<graph>`. Misconfigured repos surface non-fatal errors at
    `/api/federation`.

    Returns the dashboard URL and component status.
    """
    import shutil

    dashboard_dir = Path(__file__).resolve().parent / "dashboard"
    frontend_dir = dashboard_dir / "frontend"
    logger.info("launching zettelkasten dashboard (force_restart=%s)", force_restart)

    from memory.dashboard.launcher_core import (
        build_id,
        fetch_health,
        health_url,
        resolved_ports,
    )

    workspace = Path.cwd()
    # Resolve ports the SAME way the `python -m zettelkasten.dashboard` launcher
    # does (per-workspace offset unless an env var overrides), so the MCP tool and
    # the launcher never land on different ports for one workspace and spawn dupes.
    backend_port, frontend_port = resolved_ports(
        workspace, 8742, 5174, "ZK_DASHBOARD_BACKEND_PORT", "ZK_DASHBOARD_FRONTEND_PORT"
    )

    # Capability detection — mode-aware, never exits the server.
    have_built_ui = (frontend_dir / "dist" / "index.html").is_file()
    have_frontend_src = (frontend_dir / "package.json").is_file()

    status: dict = {"actions": []}

    if not have_built_ui and not have_frontend_src:
        status["status"] = "error"
        status["error"] = (
            "no dashboard UI found (no built bundle and no frontend source) — "
            "reinstall angelo"
        )
        return json.dumps(status)

    # --- Backend (always; serves bundled UI when Vite isn't running) ---
    reload_flag = ["--reload"] if have_frontend_src else []
    backend_alive = _port_listening(backend_port)
    # Recycle a backend that's alive but running stale code (old build after an
    # upgrade or source edit), so every launch_dashboard call self-heals — not
    # just explicit force_restart=True.
    backend_stale = False
    if backend_alive:
        payload = fetch_health(health_url(backend_port))
        if payload and payload.get("build") and payload.get("build") != build_id():
            backend_stale = True
    if backend_alive and (force_restart or backend_stale):
        _kill_port(backend_port)
        time.sleep(1)
        backend_alive = False
        status["actions"].append(
            f"recycled stale backend on :{backend_port}"
            if backend_stale and not force_restart
            else f"killed stale backend on :{backend_port}"
        )

    if not backend_alive:
        proc = subprocess.Popen(
            [sys.executable, "-m", "uvicorn",
             "zettelkasten.dashboard.backend.main:app",
             *reload_flag, "--port", str(backend_port)],
            cwd=str(Path.cwd()),
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        _dashboard_procs.append(proc)
        if _wait_for_port(backend_port):
            status["actions"].append(f"started backend on :{backend_port}")
        else:
            status["actions"].append(f"backend failed to start on :{backend_port}")
            status["status"] = "error"
            return json.dumps(status)
    else:
        status["actions"].append(f"backend already running on :{backend_port}")

    # The chat agent runs in-process in the backend (cursor-sdk's bundled
    # bridge); the Cursor API key is read lazily on the first chat, so there is
    # no separate sidecar to launch and no Node.js requirement.

    # --- Frontend: live Vite only in a source checkout ---
    run_vite = have_frontend_src and _ensure_node_deps_quiet(frontend_dir)
    if run_vite:
        npm = shutil.which("npm") or "npm"
        frontend_alive = _port_listening(frontend_port)
        if frontend_alive and force_restart:
            _kill_port(frontend_port)
            time.sleep(1)
            frontend_alive = False
            status["actions"].append(f"killed stale frontend on :{frontend_port}")
        if not frontend_alive:
            proc = subprocess.Popen(
                [npm, "run", "dev"],
                cwd=str(frontend_dir),
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            _dashboard_procs.append(proc)
            if _wait_for_port(frontend_port, timeout=15.0):
                status["actions"].append(f"started frontend on :{frontend_port}")
            else:
                status["actions"].append(f"frontend failed to start on :{frontend_port}")
                run_vite = False
        else:
            status["actions"].append(f"frontend already running on :{frontend_port}")
    elif have_frontend_src:
        status["actions"].append("frontend (Vite) skipped: serving bundled UI from backend")
    else:
        status["actions"].append(f"serving bundled UI from backend on :{backend_port}")

    from memory.dashboard.launcher_core import dashboard_url

    ui_port = frontend_port if run_vite else backend_port
    status["url"] = dashboard_url(ui_port)
    status["status"] = "running"
    return json.dumps(status)