memory.dashboard.launcher_core¶
memory.dashboard.launcher_core ¶
Shared launcher core for the memory and zettelkasten dashboard stacks.
Both dashboards spawn the same shape of process tree — a FastAPI backend and
(in a source checkout) a Vite dev server — and historically each had its own
near-identical __main__.py. This module holds that logic once; each
dashboard builds a :class:LauncherConfig and calls :func:run. The chat agent
now runs in-process in the backend (cursor-sdk's hermetic bridge ships a bundled
node binary), so there is no longer a separate Node sidecar to launch.
Beyond de-duplication it adds three pieces of robustness over the original copy-paste launchers:
- Health-confirmed port skip. A busy port is no longer assumed to be our
dashboard. The launcher hits
/api/healthand only skips when the live server identifies itself as our service; an unrelated process holding the port is reported as a conflict instead of silently masking a broken stack. - Wait-for-health. After spawning the backend the launcher polls its health endpoint and only prints the "open this URL" line once it actually answers, so users don't click through to a connection-refused page.
- Restart-with-backoff. A component that dies is respawned with exponential backoff (resetting after a stable run) instead of just being declared dead, so a transient crash self-heals without a manual relaunch.
LauncherConfig
dataclass
¶
Everything that differs between the two dashboards.
backend_port / frontend_port are the base defaults. The actual
ports are resolved per-workspace in :func:resolve_ports: an explicit env
override (backend_port_env / frontend_port_env) always wins, else the
base is shifted by a deterministic per-workspace offset so two checkouts of
the same dashboard (e.g. angelo and a second clone) land on different ports
and don't fight over one.
Source code in memory/dashboard/launcher_core.py
workspace_instance_id ¶
Stable short id for a workspace, derived from its resolved path.
Deterministic (not random) on purpose: the launcher and the backend each compute it independently, and a relaunch must recognise its own already running dashboard rather than spawning a duplicate. Two different checkouts therefore get two different ids and never claim each other's port.
Source code in memory/dashboard/launcher_core.py
current_instance_id ¶
The instance id for the running process.
Prefers the value the launcher exported (:data:INSTANCE_ENV); falls back to
deriving it from ANGELO_WORKSPACE (or cwd) so a backend started directly
by uvicorn — outside the launcher — still reports a consistent identity.
Source code in memory/dashboard/launcher_core.py
service_identity ¶
Workspace-scoped service name, e.g. zettelkasten-dashboard@a1b2c3d4.
This is what the health endpoint reports and what the launcher matches on, so a sibling workspace's dashboard reads as foreign instead of being adopted.
Source code in memory/dashboard/launcher_core.py
port_offset ¶
Deterministic 0..span-1 port shift for a workspace instance id.
resolve_ports ¶
resolve_ports(config: LauncherConfig, instance_id: str) -> tuple[int, int]
Resolve (backend, frontend) ports: explicit env override else base+offset.
Source code in memory/dashboard/launcher_core.py
resolved_ports ¶
resolved_ports(workspace: Path | str, base_backend: int, base_frontend: int, backend_port_env: str, frontend_port_env: str) -> tuple[int, int]
Resolve a workspace's (backend, frontend) ports without a LauncherConfig.
Same rule as :func:resolve_ports (explicit env override else a deterministic
per-workspace offset), but callable from places that don't build a full
:class:LauncherConfig — notably the launch_dashboard MCP tools and the
angelo CLI. Sharing this is what keeps the python -m launcher, the MCP
tool, and angelo kill agreeing on which port a given workspace's
dashboard actually uses (a past divergence spawned duplicate backends).
Source code in memory/dashboard/launcher_core.py
package_version ¶
Installed angelo package version, or a sentinel when unresolvable.
build_id ¶
Stable per-process identifier of the running code: <version>+<hash>.
Two processes from the same install report the same id; an upgrade (version bump) or any source edit (fingerprint change) yields a different id. Cached so the (cheap) fingerprint scan runs at most once per process.
Source code in memory/dashboard/launcher_core.py
ensure_node_deps ¶
Install a Node component's dependencies if missing. Returns readiness.
Source code in memory/dashboard/launcher_core.py
preflight ¶
preflight(config: LauncherConfig) -> dict[str, bool]
Decide which components can run for this install, prepping Node deps.
The backend always runs (and now hosts the chat agent in-process — no Node
sidecar). Vite is a dev-only hot-reload layer that needs the frontend
source plus a Node runtime. A plain pip install ships the built UI
(frontend/dist) but not the frontend source — so installed users get a
backend-served UI, while a source checkout gets the full live stack.
Returns {"dev", "vite"} flags.
Source code in memory/dashboard/launcher_core.py
dashboard_url ¶
Browser-facing dashboard URL. Uses 127.0.0.1 (not localhost) to match
the address uvicorn actually binds — localhost may resolve to IPv6 ::1
first and incur a failed hop before falling back to the IPv4-only backend.
Source code in memory/dashboard/launcher_core.py
http_ok ¶
GET url; True on HTTP 200 (and matching service, if requested).
Any connection/parse failure is False — a non-HTTP process squatting on the port, a different app, or a backend that hasn't finished booting all read as "not (our) healthy service".
Source code in memory/dashboard/launcher_core.py
fetch_health ¶
GET url and return the parsed health JSON, or None on any failure.
Unlike :func:http_ok (a bool), this surfaces the whole payload so callers
can read the service/build fields and decide ours-vs-stale-vs-foreign.
A non-200, non-JSON, or non-object response reads as None.
Source code in memory/dashboard/launcher_core.py
classify_port ¶
classify_port(port: int, *, url: str | None = None, expect_service: str | None = None, expect_build: str | None = None, probe_in_use=None, probe_health=None, probe_payload=None) -> str
Return 'free', 'ours', 'stale', or 'foreign' for a component's port.
- 'free' — nothing is listening.
- 'ours' — a healthy backend whose identity (
expect_service) matches and, whenexpect_buildis given, whose reportedbuildmatches. - 'stale' — our backend by identity, but running a different build than
expect_build(old code after an upgrade or source edit). Only returned whenexpect_buildis provided. - 'foreign' — a busy port with no/failed health check, or a different service.
Components with no health endpoint (url is None) can only be 'free' or
'foreign'. When expect_build is omitted the legacy http_ok bool path
is used, so existing callers/tests keep their two-way ours/foreign behaviour.
Probes default to the module-level helpers, resolved at call time so tests (and any future swap) can monkeypatch them.
Source code in memory/dashboard/launcher_core.py
kill_listeners ¶
SIGTERM every process LISTENing on a TCP port. Returns the count signalled.
Cross-platform (netstat on Windows, lsof elsewhere) and best-effort: any enumeration failure or unkillable pid is swallowed so callers can treat a 0 return as "nothing recycled" rather than an error.
Source code in memory/dashboard/launcher_core.py
wait_for_free ¶
wait_for_free(port: int, timeout: float = 5.0, interval: float = 0.25, *, probe=None, sleep=sleep, clock=monotonic) -> bool
Poll until port is no longer listening (e.g. after a recycle kill).
Source code in memory/dashboard/launcher_core.py
wait_for_health ¶
wait_for_health(url: str, timeout: float = HEALTH_WAIT_SECONDS, interval: float = 0.25, expect_service: str | None = None, *, probe=None, sleep=sleep, clock=monotonic) -> bool
Poll url until it answers healthy or timeout elapses.
Source code in memory/dashboard/launcher_core.py
backoff_delay ¶
backoff_delay(attempt: int, base: float = RESTART_BASE_SECONDS, cap: float = RESTART_CAP_SECONDS) -> float
Exponential backoff for restart attempt (1-based), capped.
Source code in memory/dashboard/launcher_core.py
next_restart ¶
next_restart(attempts: int, uptime: float, *, max_restarts: int = MAX_RESTARTS, stable_reset: float = STABLE_RESET_SECONDS, base: float = RESTART_BASE_SECONDS, cap: float = RESTART_CAP_SECONDS) -> tuple[str, float, int]
Decide what to do when a component exits.
Returns (action, delay_seconds, new_attempts) where action is "restart"
or "giveup". A component that had been up at least stable_reset seconds
has its failure counter reset first, so an occasional late crash self-heals
rather than counting toward the flap limit.
Source code in memory/dashboard/launcher_core.py
run ¶
run(config: LauncherConfig) -> None
Preflight, start everything, wait for health, then supervise.