fix(dashboard): tolerate missing manifest
This commit is contained in:
@@ -368,8 +368,6 @@ def _picker_modal(
|
|||||||
"""Modal agent picker. Type to filter; j/k or arrows to
|
"""Modal agent picker. Type to filter; j/k or arrows to
|
||||||
navigate; Enter to confirm; Esc to abort (first press clears
|
navigate; Enter to confirm; Esc to abort (first press clears
|
||||||
filter if any, second press exits)."""
|
filter if any, second press exits)."""
|
||||||
if not names:
|
|
||||||
return None
|
|
||||||
selected = 0
|
selected = 0
|
||||||
query = ""
|
query = ""
|
||||||
while True:
|
while True:
|
||||||
@@ -455,9 +453,13 @@ def _draw_picker_modal(
|
|||||||
list_start_row = 3
|
list_start_row = 3
|
||||||
visible_rows = box_h - list_start_row - 1
|
visible_rows = box_h - list_start_row - 1
|
||||||
if not filtered:
|
if not filtered:
|
||||||
|
empty_message = (
|
||||||
|
"(no agents configured)"
|
||||||
|
if not all_names else "(no agents match filter)"
|
||||||
|
)
|
||||||
win.addnstr(
|
win.addnstr(
|
||||||
list_start_row, 2,
|
list_start_row, 2,
|
||||||
"(no agents match filter)",
|
empty_message,
|
||||||
box_w - 4, curses.A_DIM,
|
box_w - 4, curses.A_DIM,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1154,6 +1156,8 @@ def _new_agent_flow(
|
|||||||
names = sorted(manifest.agents.keys())
|
names = sorted(manifest.agents.keys())
|
||||||
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
|
picked = _picker_modal(stdscr, names, _running_counts(bottles, agents_now))
|
||||||
if picked is None:
|
if picked is None:
|
||||||
|
if not names:
|
||||||
|
return "no agents configured; create ~/.bot-bottle/agents/*.md"
|
||||||
return "agent start aborted"
|
return "agent start aborted"
|
||||||
|
|
||||||
# Backend picker (issue #77): operator chooses docker /
|
# Backend picker (issue #77): operator chooses docker /
|
||||||
@@ -1401,8 +1405,10 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None:
|
|||||||
|
|
||||||
def _get_manifest() -> Manifest:
|
def _get_manifest() -> Manifest:
|
||||||
if manifest_cache[0] is None:
|
if manifest_cache[0] is None:
|
||||||
manifest_cache[0] = Manifest.resolve(USER_CWD)
|
manifest_cache[0] = Manifest.resolve(USER_CWD, missing_ok=True)
|
||||||
return manifest_cache[0]
|
return manifest_cache[0]
|
||||||
|
if not _get_manifest().bottles and not _get_manifest().agents:
|
||||||
|
status_line = "warning: no bot-bottle config/agents found; new-agent picker is empty"
|
||||||
# First-tick guard: a brand-new dashboard finds any
|
# First-tick guard: a brand-new dashboard finds any
|
||||||
# pre-existing queue entries on its first poll; those
|
# pre-existing queue entries on its first poll; those
|
||||||
# shouldn't ring the bell as if they just arrived.
|
# shouldn't ring the bell as if they just arrived.
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ class Manifest:
|
|||||||
agents: Mapping[str, Agent]
|
agents: Mapping[str, Agent]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve(cls, cwd: str) -> "Manifest":
|
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
|
||||||
"""Walk the per-file manifest tree and build a Manifest.
|
"""Walk the per-file manifest tree and build a Manifest.
|
||||||
|
|
||||||
Layout (PRD 0011):
|
Layout (PRD 0011):
|
||||||
@@ -674,6 +674,11 @@ class Manifest:
|
|||||||
warning and ignored — the filesystem layout IS the trust
|
warning and ignored — the filesystem layout IS the trust
|
||||||
boundary.
|
boundary.
|
||||||
|
|
||||||
|
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||||
|
returns an empty manifest instead of dying. This is for
|
||||||
|
passive UI surfaces like the dashboard, which can still
|
||||||
|
monitor already-running agents without launch config.
|
||||||
|
|
||||||
If `bot-bottle.json` exists alongside a missing
|
If `bot-bottle.json` exists alongside a missing
|
||||||
`.bot-bottle/` directory at either side, dies with a
|
`.bot-bottle/` directory at either side, dies with a
|
||||||
clear pointer at the README's manifest section — the
|
clear pointer at the README's manifest section — the
|
||||||
@@ -689,6 +694,8 @@ class Manifest:
|
|||||||
_check_stale_json(cwd_dir, cwd_md, "$CWD")
|
_check_stale_json(cwd_dir, cwd_md, "$CWD")
|
||||||
|
|
||||||
if not home_md.is_dir():
|
if not home_md.is_dir():
|
||||||
|
if missing_ok:
|
||||||
|
return cls.from_json_obj({"bottles": {}, "agents": {}})
|
||||||
die(
|
die(
|
||||||
f"no manifest found: {home_md} does not exist. "
|
f"no manifest found: {home_md} does not exist. "
|
||||||
f"See README.md for the per-file Markdown layout "
|
f"See README.md for the per-file Markdown layout "
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.cli import dashboard
|
from bot_bottle.cli import dashboard
|
||||||
@@ -144,6 +145,18 @@ class TestFilterAgents(unittest.TestCase):
|
|||||||
self.assertEqual(["beta", "echo"], out)
|
self.assertEqual(["beta", "echo"], out)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardManifestLoading(unittest.TestCase):
|
||||||
|
def test_new_agent_flow_empty_manifest_has_no_picker_entries(self):
|
||||||
|
manifest = dashboard.Manifest.from_json_obj({"bottles": {}, "agents": {}})
|
||||||
|
with mock.patch("bot_bottle.cli.dashboard._picker_modal", return_value=None) as picker:
|
||||||
|
status = dashboard._new_agent_flow(
|
||||||
|
None, manifest, {}, [], tmux_state=None, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
picker.assert_called_once()
|
||||||
|
self.assertEqual([], picker.call_args.args[1])
|
||||||
|
self.assertIn("no agents configured", status)
|
||||||
|
|
||||||
|
|
||||||
class TestRunningCounts(unittest.TestCase):
|
class TestRunningCounts(unittest.TestCase):
|
||||||
"""Per-agent running-count surfaced in the picker so the
|
"""Per-agent running-count surfaced in the picker so the
|
||||||
operator sees `(N running)` before picking. Counts come from
|
operator sees `(N running)` before picking. Counts come from
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ class TestNoManifestDies(_ResolveCase):
|
|||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
self.resolve()
|
self.resolve()
|
||||||
|
|
||||||
|
def test_missing_ok_returns_empty_manifest(self):
|
||||||
|
m = Manifest.resolve(str(self.cwd_root), missing_ok=True)
|
||||||
|
self.assertEqual({}, dict(m.bottles))
|
||||||
|
self.assertEqual({}, dict(m.agents))
|
||||||
|
|
||||||
|
|
||||||
class TestUnknownBottleReferenceDies(_ResolveCase):
|
class TestUnknownBottleReferenceDies(_ResolveCase):
|
||||||
"""An agent file naming a bottle that doesn't exist on disk
|
"""An agent file naming a bottle that doesn't exist on disk
|
||||||
|
|||||||
Reference in New Issue
Block a user