Skip to content

Commit 6cc50f7

Browse files
committed
Merge branch 'worktree-mobile-redesign'
2 parents ed76bb6 + 6ffd5fa commit 6cc50f7

45 files changed

Lines changed: 3520 additions & 230 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ Smoothed values are rendered, not persisted: raw entries in the store remain unt
395395

396396
### Alerts (code-fired + condition rules)
397397

398-
`nb.alert(title, text, level)` fires a webhook (if configured) and emits an `alert` wire event stamped `triggered_by: "code"`; the daemon appends it to `run.alerts`. **Alert rules** are set without code changes (`nebo alerts set --condition "train/loss > 5"`, MCP `nebo_set_alert`): they live in `DaemonState.alert_rules` (in-memory) and are evaluated in `_process_event`'s metric branch (`_evaluate_alert_rules`) against numeric metric values only. A rule fires at most once per run; the fired alert lands in `run.alerts` with `triggered_by: "cli"` plus a `condition` display string (and is cache-persisted via `_fire_rule_alert`), so `/runs/{id}/alerts/wait` (`nebo runs wait`, `nebo_wait_for_alert`) wakes on it with no extra wiring. Rule CRUD: `GET/POST /alerts`, `GET/DELETE /alerts/{id}`; condition strings (`<metric> <op> <number>`) are parsed by `nebo/client.py:parse_condition`. The metric name `last_event` is **reserved for heartbeat rules** (`"last_event > 60"` = fire once the run has been idle 60 s — nebo's run-completion signal): evaluated by an always-on ~1 s lifespan task (`evaluate_heartbeat_rules`, `HEARTBEAT_TICK_S`) that notifies `_event_notify` itself (a quiet run has no ingest to wake waiters); ops `>`/`>=` only, no `loggable_id`; run-scoped rules fall back to the cache for RAM-evicted runs and fire immediately for already-idle runs, global rules skip runs whose last activity predates the rule. File-mode `alert` entries are intentionally **not** in `ENTRY_TYPES` (byte 255); the watcher's payload-type recovery ingests them — don't make the type byte authoritative.
398+
`nb.alert(title, text, level)` fires a webhook (if configured) and emits an `alert` wire event stamped `triggered_by: "code"`; the daemon appends it to `run.alerts`. **Alert rules** are set without code changes (`nebo alerts set --condition "train/loss > 5"`, MCP `nebo_set_alert`): they live in `DaemonState.alert_rules` (in-memory) and are evaluated in `_process_event`'s metric branch (`_evaluate_alert_rules`) against numeric metric values only. A rule fires at most once per run; the fired alert lands in `run.alerts` with `triggered_by: "cli"` plus a `condition` display string (and is cache-persisted via `_fire_rule_alert`), so `/runs/{id}/alerts/wait` (`nebo runs wait`, `nebo_wait_for_alert`) wakes on it with no extra wiring. Rule CRUD: `GET/POST /alerts`, `GET/DELETE /alerts/{id}`; condition strings (`<metric> <op> <number>`) are parsed by `nebo/client.py:parse_condition`. `GET /runs/{id}/alerts` lists one run's fired alerts (code- and rule-fired) — the mobile alerts sheet hydrates from it, and live `alert` WS events append to the store's per-run `alerts` slice. The metric name `last_event` is **reserved for heartbeat rules** (`"last_event > 60"` = fire once the run has been idle 60 s — nebo's run-completion signal): evaluated by an always-on ~1 s lifespan task (`evaluate_heartbeat_rules`, `HEARTBEAT_TICK_S`) that notifies `_event_notify` itself (a quiet run has no ingest to wake waiters); ops `>`/`>=` only, no `loggable_id`; run-scoped rules fall back to the cache for RAM-evicted runs and fire immediately for already-idle runs, global rules skip runs whose last activity predates the rule. File-mode `alert` entries are intentionally **not** in `ENTRY_TYPES` (byte 255); the watcher's payload-type recovery ingests them — don't make the type byte authoritative.
399399

400400
### Global state singleton
401401

@@ -419,6 +419,8 @@ Smoothed values are rendered, not persisted: raw entries in the store remain unt
419419

420420
React 19 + Vite 7 + TypeScript + Tailwind v4 + shadcn-style components. State via `zustand` (`src/store/index.ts`). WebSocket handled in `src/hooks/useWebSocket.ts`, connecting to the daemon's `/stream` endpoint. Graph rendering uses `@xyflow/react` with `@dagrejs/dagre` layout (`src/components/graph/DagGraph.tsx`). Metrics charts use **Chart.js 4** (registered in `src/components/charts/registerChartJs.ts`) with `chartjs-plugin-zoom` for pan/zoom; the shared lifecycle hook is `src/components/charts/useChartJs.ts`. The bottom panel is the **Tracker** (`src/components/timeline/`); see "Tracker" under the Metrics model section. The default view is "Flat" (store key `'flat'`, wire value `nb.ui(view="flat")`); the DAG view is opt-in. The `@/` import alias maps to `ui/src/`. shadcn registry is configured via `.mcp.json` (the `shadcn` MCP server).
421421

422+
**Mobile experience** (`src/components/mobile/`, <768px via `useIsDesktop`): a dedicated touch UI rendered by `MobileApp` from App.tsx's mobile branch — the desktop layout is untouched. Screens: `MobileRunList` (uniform cards; group tap opens the group page; the search field segues to a flat search screen) → `MobileRunView` (header: group crumb / title → `MobileRunInfoSheet` with notes + config + copyable identifiers; bell → `MobileAlertsSheet`, severity-filterable, tap jumps to the node sheet; gear → `MobileSettingsSheet` sliders over the shared `Settings` keys). Body toggles DAG ⇄ Feed via the shared `viewMode` store key ('graph'/'flat', so `nb.ui(view=)` still applies): `MobileDagCanvas` is a custom dagre + pan/pinch canvas (not ReactFlow; no explicit `setPointerCapture` — it would retarget the derived click and swallow node taps), `MobileFeed` is a stage-rail + type-filter card feed whose expanded charts reuse `SingleRunChart` (exported from `NodeMetrics`). `MobileTracker` renders the persistent heat-strip bar + scrub sheet over `useStreams`. Node taps and alerts open `MobileNodeSheet`, which wraps the desktop `LoggableTabContainer` for full tab parity. Store invariant: **every run mutator clones the run object** (`runs.set(id, { ...run, field })` — see the comment above `setRuns`), so `s.runs.get(id)` selectors are sound and fire only for that run; never mutate a stored run in place. Components that read one slice should select the leaf field (`s.runs.get(id)?.logs`) so unrelated mutations don't re-render them. Phone-width embeds (`?run=`, `&dag`, `&flat`) render the mobile layout via `EmbeddedMobileLayout`.
423+
422424
### Tests
423425

424426
Plain `pytest` + `pytest-asyncio`. Tests are self-contained and exercise the public surface (`test_decorators.py`, `test_client.py`, `test_daemon.py`, `test_mcp_tools.py`, `test_fileformat.py`, …). There is no separate lint/type-check step in CI — only the pytest matrix in `.github/workflows/ci.yml`.

nebo/server/daemon.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,6 +1881,16 @@ async def get_run_audio(run_id: str):
18811881
return JSONResponse(status_code=404, content={"error": f"Run '{run_id}' not found"})
18821882
return {"audio": audio}
18831883

1884+
@app.get("/runs/{run_id}/alerts")
1885+
async def get_run_alerts(run_id: str):
1886+
"""Fired alerts for one run — both code-fired (`nb.alert`) and
1887+
rule-fired (`triggered_by: "cli"`) entries, in firing order."""
1888+
await state.ensure_deep(run_id)
1889+
alerts = state.run_alerts(run_id)
1890+
if alerts is None:
1891+
return JSONResponse(status_code=404, content={"error": f"Run '{run_id}' not found"})
1892+
return {"alerts": alerts}
1893+
18841894
@app.get("/runs/{run_id}/media/{media_id}")
18851895
async def get_media(run_id: str, media_id: str, request: Request):
18861896
await state.ensure_deep(run_id)

tests/test_daemon.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,16 @@ def test_alert_event_is_appended_to_run():
805805
assert run.alerts[0]["level_name"] == "WARN"
806806
assert run.alerts[0]["loggable_id"] == "node_a"
807807

808+
# The per-run listing endpoint returns the same fired alerts.
809+
resp = client.get("/runs/r_alert_1/alerts")
810+
assert resp.status_code == 200
811+
alerts = resp.json()["alerts"]
812+
assert len(alerts) == 1
813+
assert alerts[0]["title"] == "Loss went up"
814+
815+
resp = client.get("/runs/no_such_run/alerts")
816+
assert resp.status_code == 404
817+
808818

809819
def test_alerts_wait_returns_alert():
810820
"""Wait endpoint should unblock and return alert when one arrives at or above min_level."""

0 commit comments

Comments
 (0)