The status page's overview (M6.6c) #182

Merged
dongar merged 1 commits from overview into main 2026-10-26 16:11:57 +00:00
17 changed files with 1184 additions and 42 deletions

No files matched your search

+4 -1
View File
@@ -1185,7 +1185,10 @@ and memory work in practice), more than for daily use.
given again and again, so they're seen and fixed (L2).
- **It reads, never writes:** each agent's state store and call record through a
read-only mount, opened read-only in short transactions, and the agent's memory
folder read-only, as SilverBullet does (L10). Each agent stays the only writer
folder read-only, as SilverBullet does (L10). For its links it reads a few
settings from each agent's config file: the Gitea address, the Matrix room,
and each project's repository and build VM. It reads nothing else there, and
never resolves a secret reference. Each agent stays the only writer
of its state (L8), and agent code doesn't change for the page. It runs no
model, holds no credentials and reaches nothing but the mounts and Caddy.
- **Versions:** it is released with the agents, from the same tag. It reads the
+10 -3
View File
@@ -40,7 +40,7 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
| **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) |
| **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) |
| **D7–D8** Deployment, projects | partial | Bring-up runs `nisse-dev`'s and `nisse-review`'s images under systemd on this box; project entries in config (M1.4); egress not enforced |
| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host; the status page's walkthrough runs on this box (M6.6b) |
| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host; the status page's walkthrough and overview run on this box (M6.6b, c) |
| `specs/` | done for now | `loop.qnt` (the architecture, #91); `orchestrator.qnt` went with `control.py` (M2.8) |
---
@@ -587,10 +587,17 @@ bring-up, end-to-end and benchmark runs; only d waits for M6.1.
fitting; wired in each agent's build; deleted at start. Each system prompt
says the calls are recorded, while they are. **Done** (#179).
- **b. The walkthrough:** an item's steps, each run's turns, and each request read
against the log it was made from; runs going in circles flagged. **Done** (this
PR): the `nisse-ui` package, on 127.0.0.1 behind an SSH tunnel until d. The
against the log it was made from; runs going in circles flagged. **Done**
(#181): the `nisse-ui` package, on 127.0.0.1 behind an SSH tunnel until d. The
loop rules are `nisse_core.loop.circles`, for M7.8 to reuse.
- **c. The overview:** each agent's active, queued, parked and blocked items.
**Done** (this PR): the home page. Per agent: the run under way (or idle since
when; a run open with no call for 15 minutes shows as maybe interrupted), the
queue in nisse-dev's own order with items on a blocked build VM held out, the
parked items with their wait and note, blocked items and VM blocks, the review
PRs by position, and the last ten closed. Links to issues, PRs and threads come
from `--config NAME=YAML`, the agent's own file (X6). It reloads every minute
with no script (`?refresh=0` stops it).
- **d. Deployment:** its image, the read-only mounts and a Caddy route behind
Authelia (with #166).
@@ -484,6 +484,12 @@ class CallReader:
)
return self._decoded(rows)
def last_call(self) -> dict[str, Any] | None:
"""The latest call, of any run: when the agent last asked a model."""
rows = self._conn.execute("SELECT * FROM calls ORDER BY id DESC LIMIT 1")
found = self._decoded(rows)
return found[0] if found else None
def call(self, call_id: int) -> dict[str, Any] | None:
rows = self._conn.execute("SELECT * FROM calls WHERE id = ?", (call_id,))
found = self._decoded(rows)
+14
View File
@@ -392,3 +392,17 @@ def test_the_page_reads_one_items_calls_and_when_recording_began(
assert reader.call(999) is None
assert reader.started_at is not None
assert reader.started_at <= mine["at"]
def test_the_page_reads_the_latest_call_of_any_run(
recorder: calls.CallRecorder,
) -> None:
assert read(recorder).last_call() is None
client = budgeted(Server(), recorder)
with calls.run(recorder, work_item="w", role="driver"):
client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
client.complete(stored([UserMessage(text="hi", source="t")]), None, "local")
reader = read(recorder)
last = reader.last_call()
assert last is not None and last["id"] == reader.calls()[-1]["id"]
assert last["work_item"] is None
+1 -1
View File
@@ -3,7 +3,7 @@ name = "nisse-ui"
version = "0.1.0"
description = "Status page: a read-only view of what the agents did and how (X6)"
requires-python = ">=3.12"
dependencies = ["nisse-core", "jinja2>=3.1"]
dependencies = ["nisse-core", "jinja2>=3.1", "pyyaml>=6"]
[project.scripts]
nisse-ui = "nisse_ui.main:main"
+63 -8
View File
@@ -1,7 +1,9 @@
"""The page's routes, as a plain function from a path to a response, so the
tests drive it without a socket (X6).
/ the sources: agents and kept runs
/ the overview: what each agent is doing, and
the kept runs (reloads every minute;
?refresh=0 stops it)
/s/<source>/ a source's work items
/s/<source>/item/<id> an item's walkthrough ("-": the agent-level log)
/s/<source>/call/<n> one turn, read against its log
@@ -10,18 +12,22 @@ tests drive it without a socket (X6).
import json
import sqlite3
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from importlib import resources
from typing import Any
from urllib.parse import quote, unquote, urlsplit
from urllib.parse import parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, PackageLoader, StrictUndefined
from nisse_core.models import calls
from nisse_ui import walk
from nisse_ui import overview, walk
from nisse_ui.links import Links
from nisse_ui.sources import Source, StateReader, UnknownSchema, call_record
AGENT_LEVEL = "-" # the item id that stands for the agent-level log
REFRESH_S = 60 # the overview reloads itself, with no script (CSP)
# Everything shown is untrusted text (L11): escaped by the templates, and
# the browser runs no script at all, loads nothing from elsewhere, and
@@ -109,10 +115,30 @@ def _reply_line(reply: walk.Reply) -> str:
return " · ".join(p for p in (text, calls_made) if p) or "(no reply)"
def _ago(ts: Any, now: datetime) -> str:
when = overview.parse_time(ts) if ts else None
if when is None:
return ""
seconds = int((now - when).total_seconds())
if seconds < 0:
return "just now"
for size, unit in ((86400, "d"), (3600, "h"), (60, "min")):
if seconds >= size:
return f"{seconds // size} {unit} ago"
return f"{seconds} s ago"
class App:
def __init__(self, sources: list[Source]) -> None:
def __init__(
self,
sources: list[Source],
links: dict[str, Links] | None = None,
clock: Callable[[], datetime] = lambda: datetime.now(UTC),
) -> None:
self.sources = list(sources)
self.by_name = {s.name: s for s in sources}
self.links = dict(links or {})
self.clock = clock
env = Environment(
loader=PackageLoader("nisse_ui", "templates"),
autoescape=True, # always: every page shows untrusted text
@@ -127,6 +153,7 @@ class App:
pretty=_pretty,
clip=_clip,
reply_line=_reply_line,
ago=lambda ts: _ago(ts, self.clock()),
)
env.globals.update(summary=summary, AGENT_LEVEL=AGENT_LEVEL)
self.env = env
@@ -139,10 +166,12 @@ class App:
return self.page("problem.html", status, title=title, detail=detail)
def handle(self, raw_path: str) -> Response:
path = urlsplit(raw_path).path
split = urlsplit(raw_path)
path = split.path
parts = [unquote(p) for p in path.split("/")[1:]]
if parts in ([], [""]):
return self.home()
refresh = parse_qs(split.query).get("refresh", [""])[-1] != "0"
return self.home(refresh)
if parts == ["static", "style.css"]:
css = resources.files("nisse_ui").joinpath("static/style.css").read_bytes()
return Response(200, css, "text/css; charset=utf-8")
@@ -179,9 +208,13 @@ class App:
# --- pages ---------------------------------------------------------------------
def home(self) -> Response:
def home(self, refresh: bool = True) -> Response:
now = self.clock()
agents = [self._agent(s, now) for s in self.sources if s.kind == "agent"]
rows = []
for source in self.sources:
if source.kind == "agent":
continue
row: dict[str, Any] = {"source": source, "count": None, "note": ""}
try:
with StateReader(source.state_db) as state:
@@ -191,7 +224,29 @@ class App:
row["note"] = str(exc)
row["record"] = source.calls_db.is_file()
rows.append(row)
return self.page("sources.html", rows=rows)
return self.page(
"overview.html",
agents=agents,
rows=rows,
refresh=REFRESH_S if refresh else 0,
now=now,
)
def _agent(self, source: Source, now: datetime) -> overview.AgentView:
"""One agent's part of the overview, or what kept it from showing:
one agent's unreadable store doesn't take the page down."""
links = self.links.get(source.name, Links())
try:
with StateReader(source.state_db) as state:
try:
with call_record(source) as reader:
return overview.agent(source, state, reader, links, now)
except calls.UnknownLayout as exc:
return overview.agent(
source, state, None, links, now, record_note=str(exc)
)
except (UnknownSchema, sqlite3.Error) as exc:
return overview.AgentView(source, "", problem=str(exc))
def items(self, source: Source) -> Response:
with StateReader(source.state_db) as state:
+99
View File
@@ -0,0 +1,99 @@
"""Links out of the page, to Gitea and Matrix (X6, M6.6c), built from an
agent's own config file.
The page reads exactly these settings and nothing else: ``gitea.url``,
``matrix.room_id``, and per project ``repo`` and ``build_host.address`` and
``.user`` (the build VM's name, as a VM block names it). It never resolves a
``${VAR}`` reference (a setting given as one is treated as unset) and never
shows the file. Configs hold no secrets, only references to them, but the
page has no use for the rest, so it never holds them.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import quote
from nisse_core.config import flatten, read_file
class ConfigProblem(ValueError):
pass
@dataclass(frozen=True)
class Links:
gitea: str = "" # the Gitea address, no trailing slash
room: str = "" # the agent's Matrix room
repos: dict[str, str] = field(default_factory=dict) # project -> owner/name
hosts: dict[str, str] = field(default_factory=dict) # project -> user@address
def repo_url(self, repo: str) -> str:
return f"{self.gitea}/{repo}" if self.gitea and _repo(repo) else ""
def issue(self, repo: str, number: int) -> str:
base = self.repo_url(repo)
return f"{base}/issues/{number}" if base and number > 0 else ""
def pull(self, repo: str, number: int) -> str:
base = self.repo_url(repo)
return f"{base}/pulls/{number}" if base and number > 0 else ""
def dependency(self, ref: str) -> str:
"""An issue named as ``owner/repo#N``, as blocked items record them."""
repo, sep, number = ref.rpartition("#")
return self.issue(repo, int(number)) if sep and number.isdigit() else ""
def thread(self, event: str) -> str:
"""A Matrix thread's root, opened in the operator's own client."""
if not (self.room and event):
return ""
return (
f"https://matrix.to/#/{quote(self.room, safe='!:')}/"
f"{quote(event, safe='$:')}"
)
def web_address(value: Any) -> str:
"""An http(s) address, or "" for anything else: whatever reaches an href
goes through here, so a stored ``javascript:`` never becomes a link."""
text = str(value or "").strip()
return text if text.lower().startswith(("https://", "http://")) else ""
def _repo(text: str) -> bool:
owner, sep, name = text.partition("/")
return bool(sep and owner and name and "/" not in name)
def _plain(value: Any) -> str:
"""A setting's text, or "" when it's a ${VAR} reference or not text."""
if not isinstance(value, str) or "${" in value:
return ""
return value.strip()
def load(path: Path) -> Links:
errors: list[str] = []
tree = read_file(path, errors) if path.is_file() else {}
if errors or not path.is_file():
raise ConfigProblem(errors[0] if errors else f"{path}: no such file")
top = flatten({k: v for k, v in tree.items() if k in ("gitea", "matrix")})
repos: dict[str, str] = {}
hosts: dict[str, str] = {}
projects = tree.get("projects")
for name, entry in (projects if isinstance(projects, dict) else {}).items():
if not isinstance(entry, dict):
continue
flat = flatten(entry)
if _repo(repo := _plain(flat.get("repo"))):
repos[str(name)] = repo
if address := _plain(flat.get("build_host.address")):
user = _plain(flat.get("build_host.user")) or "root"
hosts[str(name)] = f"{user}@{address}"
return Links(
gitea=web_address(_plain(top.get("gitea.url"))).rstrip("/"),
room=_plain(top.get("matrix.room_id")),
repos=repos,
hosts=hosts,
)
+32 -7
View File
@@ -1,8 +1,11 @@
"""Serve the status page (X6), read-only, on this machine only.
nisse-ui --source dev=/var/lib/nisse-dev/state.db --runs bench-kept/
nisse-ui --source dev=/var/lib/nisse-dev/state.db \
--config dev=/etc/nisse-dev/nisse-dev.yaml --runs bench-kept/
Each source is an agent's state store; its call record is found beside it.
``--config`` gives an agent's own config file, for links to Gitea and Matrix:
the page reads only the few settings that build them (``nisse_ui.links``).
``--runs`` names folders of runs the benchmark kept (``--keep-runs``). It
listens on 127.0.0.1: until it sits behind Authelia (M6.6d), reach it
through an SSH tunnel, since it shows everything the models saw.
@@ -13,15 +16,24 @@ import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from nisse_ui import links
from nisse_ui.app import App
from nisse_ui.sources import discover
from nisse_ui.sources import discover, safe_name
def _pair(text: str, what: str) -> tuple[str, Path]:
name, sep, path = text.partition("=")
if not sep or not name or not path:
raise argparse.ArgumentTypeError(f"expected NAME={what}")
return name, Path(path)
def _source(text: str) -> tuple[str, Path]:
name, sep, path = text.partition("=")
if not sep or not name or not path:
raise argparse.ArgumentTypeError("expected NAME=STATE_DB")
return name, Path(path)
return _pair(text, "STATE_DB")
def _config(text: str) -> tuple[str, Path]:
return _pair(text, "CONFIG")
def handler_for(app: App) -> type[BaseHTTPRequestHandler]:
@@ -50,11 +62,24 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--source", action="append", type=_source, default=[], metavar="NAME=STATE_DB"
)
parser.add_argument(
"--config", action="append", type=_config, default=[], metavar="NAME=CONFIG"
)
parser.add_argument("--runs", action="append", type=Path, default=[], metavar="DIR")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args(argv)
app = App(discover(args.source, args.runs))
sources = discover(args.source, args.runs)
agents = {s.name for s in sources if s.kind == "agent"}
found: dict[str, links.Links] = {}
for name, path in args.config:
if safe_name(name) not in agents:
parser.error(f"--config {name}: no --source of that name")
try:
found[safe_name(name)] = links.load(path)
except links.ConfigProblem as exc:
parser.error(f"--config {name}: {exc}")
app = App(sources, found)
server = ThreadingHTTPServer((args.host, args.port), handler_for(app))
print(f"nisse-ui on http://{args.host}:{args.port}/", file=sys.stderr)
try:
+344
View File
@@ -0,0 +1,344 @@
"""The overview (X6, M6.6c): what each agent is doing now, what waits, and
why, read from its state store and call record.
Each agent keeps what it knows about an item in the item's data: nisse-dev
as ``data["loop"]``, nisse-review as ``data["pr"]``. The page reads each
tolerantly, never with the agents' own parsers (nisse-dev's refuses a field
it doesn't know): a missing field reads as empty, an unknown one is ignored,
and an unknown position is shown by its name. The contract tests write items
with the agents' own code, so renaming a field the page reads fails there.
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from nisse_core.models import calls
from nisse_ui.links import Links, web_address
from nisse_ui.sources import Item, Source, StateReader
# An open run with no call for this long is probably one a crash left open:
# a run that never ended has no end to record.
STALE = timedelta(minutes=15)
CLOSED_SHOWN = 10
INTERRUPTED_SHOWN = 3
DEV = "dev"
REVIEW = "review"
# The groups each agent's open items fall into, in the order shown: the
# position, and what the page calls it. Anything else goes under "Other".
GROUPS = {
DEV: (
("running", "Running"),
("ready", "Queue"),
("waiting", "Parked"),
("blocked", "Blocked"),
),
REVIEW: (
("running", "Running"),
("ready", "Due"),
("waiting", "Draft or work in progress"),
("reviewed", "Watching"),
("capped", "At the round cap"),
("failed", "Failed"),
("stopped", "Stopped"),
),
"": (),
}
@dataclass(frozen=True)
class Link:
text: str
href: str
@dataclass(frozen=True)
class Row:
item: Item
what: str # where it is, in words
details: tuple[str, ...] = () # why: its note, its dependencies, ...
links: tuple[Link, ...] = ()
mark: str = "" # its place in line, or why it's held out of it
held: bool = False # in the queue's position, but not in line
@dataclass(frozen=True)
class Group:
key: str
title: str
rows: tuple[Row, ...]
@dataclass(frozen=True)
class OpenRun:
run: dict[str, Any]
turns: int
last_call: int | None # the latest turn's call, to link to
last_at: str # when it last asked a model, or began
stale: bool
@dataclass
class AgentView:
source: Source
kind: str # DEV, REVIEW, or "" for items in neither shape
active: Row | None = None
running: list[OpenRun] = field(default_factory=list)
interrupted: list[OpenRun] = field(default_factory=list)
last_activity: str | None = None
groups: list[Group] = field(default_factory=list)
blocks: list[dict[str, Any]] = field(default_factory=list)
closed: list[Row] = field(default_factory=list)
record_note: str = ""
problem: str = "" # why the store couldn't be read
# --- reading item data tolerantly ------------------------------------------------
def _section(item: Item, key: str) -> dict[str, Any]:
found = item.data.get(key)
return found if isinstance(found, dict) else {}
def _text(data: dict[str, Any], key: str) -> str:
value = data.get(key)
return value if isinstance(value, str) else ""
def _int(data: dict[str, Any], key: str) -> int:
value = data.get(key)
return value if isinstance(value, int) and not isinstance(value, bool) else 0
def _number(data: dict[str, Any], key: str) -> float:
value = data.get(key)
if isinstance(value, bool) or not isinstance(value, int | float):
return 0.0
return float(value)
def _texts(data: dict[str, Any], key: str) -> tuple[str, ...]:
value = data.get(key)
if not isinstance(value, list | tuple):
return ()
return tuple(v for v in value if isinstance(v, str))
def kind_of(items: list[Item]) -> str:
"""Which agent's items these are, by their data's shape: the store
doesn't record the agent's name."""
if any(_section(i, "loop") for i in items):
return DEV
if any(_section(i, "pr") for i in items):
return REVIEW
return ""
def parse_time(text: Any) -> datetime | None:
try:
found = datetime.fromisoformat(str(text))
except ValueError:
return None
return found if found.tzinfo else found.replace(tzinfo=UTC)
def _stamp(seconds: float) -> str:
return datetime.fromtimestamp(seconds, UTC).isoformat()
# --- one item, as a row ----------------------------------------------------------
def dev_row(item: Item, links: Links, place: str = "", held: str = "") -> Row:
loop = _section(item, "loop")
wait, note = _text(loop, "wait"), _text(loop, "note")
details: list[str] = []
what = item.position
if item.position == "waiting" and wait == "conversation":
what = "talking; your turn"
elif item.position == "waiting":
what = f"waiting for {wait}" if wait else "waiting"
if grace := _number(loop, "grace_until"):
details.append(f"goes ahead at {_stamp(grace)[:16]} unless you reply")
elif item.position == "blocked":
what = "blocked"
if note:
details.append(note)
if item.position == "blocked" and not _texts(loop, "blocked_by"):
details.append("waits for its dependencies") # shown as links when known
if _section(item, "loop").get("question") is True:
details.append("answering your question")
if action := _text(loop, "pending_action"):
details.append(f"asks you to allow: {action}")
if held_merge := _text(loop, "merge_held"):
details.append(f"merge held: {held_merge}")
if _int(loop, "first"):
details.append("you asked for it first")
repo = links.repos.get(item.scope or "", "")
found: list[Link] = []
if issue := _int(loop, "issue"):
found.append(Link(f"#{issue}", links.issue(repo, issue)))
if pr := _int(loop, "pr"):
href = web_address(_text(loop, "pr_url")) or links.pull(repo, pr)
found.append(Link(f"PR #{pr}", href))
for dep in _texts(loop, "blocked_by") if item.position == "blocked" else ():
found.append(Link(dep, links.dependency(dep)))
if thread := _text(loop, "thread"):
found.append(Link("thread", links.thread(thread)))
return Row(
item,
what,
tuple(details),
tuple(found),
mark=held or place,
held=bool(held),
)
def review_row(item: Item, links: Links) -> Row:
pr = _section(item, "pr")
details: list[str] = []
if rounds := _int(pr, "rounds"):
details.append(f"{rounds} round{'s' if rounds != 1 else ''}")
if attempts := _int(pr, "attempts"):
details.append(
f"{attempts} failed run{'s' if attempts != 1 else ''} at this head"
)
if pr.get("happy") is True:
details.append("signed off")
if pr.get("full") is True:
details.append("a fresh review asked for")
if run := _text(pr, "run"):
details.append(f"run: {run}")
if head := _text(pr, "head"):
details.append(f"head {head[:10]}")
found: list[Link] = []
repo, number = _text(pr, "repo"), _int(pr, "number")
if number:
found.append(Link(f"{repo}#{number}", links.pull(repo, number)))
return Row(item, item.position, tuple(details), tuple(found))
def row_of(item: Item, links: Links) -> Row:
if _section(item, "loop"):
return dev_row(item, links)
if _section(item, "pr"):
return review_row(item, links)
return Row(item, item.position)
def closed_row(item: Item, links: Links) -> Row:
found = row_of(item, links)
return Row(item, item.outcome or item.status, (), found.links)
# --- the queue -------------------------------------------------------------------
def dev_queue(
ready: list[Item], blocks: list[dict[str, Any]], links: Links
) -> list[Row]:
"""nisse-dev's ready items in the order it runs them (its ``_queue``):
the operator's "do #N next" first, then the oldest. An item whose build
VM is blocked is held out of line, unless it's a question (L5). Which VM
a project builds on comes from its config: without one, none is held."""
blocked = {str(b.get("host")): b for b in blocks}
def block_of(item: Item) -> dict[str, Any] | None:
host = links.hosts.get(item.scope or "")
if host is None or _section(item, "loop").get("question") is True:
return None
return blocked.get(host)
in_line = sorted(
(i for i in ready if block_of(i) is None),
key=lambda i: -_int(_section(i, "loop"), "first"),
)
rows = [
dev_row(item, links, place=f"{n} of {len(in_line)}")
for n, item in enumerate(in_line, 1)
]
for item in ready:
if (block := block_of(item)) is not None:
held = f"held: its build VM is blocked ({block.get('reason', '')})"
rows.append(dev_row(item, links, held=held))
return rows
# --- an agent --------------------------------------------------------------------
def groups(
kind: str, items: list[Item], blocks: list[dict[str, Any]], links: Links
) -> list[Group]:
known = dict(GROUPS[kind])
found: list[Group] = []
for key, title in GROUPS[kind]:
mine = [i for i in items if i.position == key]
if kind == DEV and key == "ready":
rows = dev_queue(mine, blocks, links)
else:
rows = [row_of(i, links) for i in mine]
found.append(Group(key, title, tuple(rows)))
other = [row_of(i, links) for i in items if i.position not in known]
if other:
found.append(Group("other", "Other", tuple(other)))
return found
def _open_runs(
reader: calls.CallReader, now: datetime
) -> tuple[list[OpenRun], list[OpenRun]]:
running: list[OpenRun] = []
interrupted: list[OpenRun] = []
for run in reader.runs():
if run.get("ended_at"):
continue
made = reader.calls(run=run["id"])
last = made[-1] if made else None
last_at = str(last["at"] if last else run["started_at"])
when = parse_time(last_at)
stale = when is None or now - when > STALE
found = OpenRun(run, len(made), last["id"] if last else None, last_at, stale)
(interrupted if stale else running).append(found)
interrupted.sort(key=lambda r: r.last_at, reverse=True)
return running, interrupted[:INTERRUPTED_SHOWN]
def latest(*stamps: str | None) -> str | None:
dated = [(parse_time(s), s) for s in stamps if s]
dated = [(d, s) for d, s in dated if d is not None]
return max(dated)[1] if dated else None
def agent(
source: Source,
state: StateReader,
reader: calls.CallReader | None,
links: Links,
now: datetime,
*,
record_note: str = "",
) -> AgentView:
items = state.open_items()
closed = state.closed_items(CLOSED_SHOWN)
blocks = state.vm_blocks()
view = AgentView(source, kind_of(items + closed), blocks=blocks)
active = state.active_item()
view.active = next((row_of(i, links) for i in items if i.id == active), None)
view.groups = groups(view.kind, items, blocks, links)
view.closed = [closed_row(i, links) for i in closed]
last_call = None
if reader is not None:
view.running, view.interrupted = _open_runs(reader, now)
found = reader.last_call()
last_call = str(found["at"]) if found else None
else:
view.record_note = record_note or "no call record: no runs to show"
view.last_activity = latest(
state.last_event_at(), last_call, *(i.updated_at for i in items + closed)
)
return view
+31
View File
@@ -199,6 +199,37 @@ class StateReader:
found[row["id"]] = self._event(row)
return found
def open_items(self) -> list[Item]:
"""Open items oldest first, as the agents list them for their queue."""
rows = self._conn.execute(
"SELECT * FROM work_items WHERE status = 'open' ORDER BY opened_at"
).fetchall()
return [self._item(r) for r in rows]
def closed_items(self, limit: int) -> list[Item]:
rows = self._conn.execute(
"SELECT * FROM work_items WHERE status != 'open' "
"ORDER BY closed_at DESC, updated_at DESC LIMIT ?",
(limit,),
).fetchall()
return [self._item(r) for r in rows]
def last_event_at(self) -> str | None:
row = self._conn.execute(
"SELECT ts FROM canonical_events ORDER BY id DESC LIMIT 1"
).fetchone()
return row["ts"] if row else None
def vm_blocks(self) -> list[dict[str, Any]]:
"""nisse-dev's blocked build VMs (D3); none for a store without them."""
table = self._conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vm_blocks'"
).fetchone()
if table is None:
return []
rows = self._conn.execute("SELECT * FROM vm_blocks ORDER BY since").fetchall()
return [dict(r) for r in rows]
def count_items(self) -> int:
return self._conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0]
+13
View File
@@ -75,3 +75,16 @@ ul.from .text { font-family: var(--mono); font-size: .8rem; color: var(--muted);
@media (max-width: 640px) {
table.facts th, table.facts td { display: block; width: auto; border: 0; }
}
/* the overview (M6.6c) */
.agent { margin-bottom: 2rem; }
.now p { margin: .3rem 0; }
.running { background: var(--new); padding: 4px 10px; border-radius: 4px; }
tr.held td { background: var(--change); }
.why { font-size: .85rem; color: var(--muted); word-break: break-word; }
.out { display: inline-block; margin-right: 8px; white-space: nowrap; }
table.items td:first-child { width: 38%; }
@media (max-width: 640px) {
table.items thead { display: none; }
table.items td { display: block; width: auto !important; border: 0; padding: 2px 8px; }
table.items tr { display: block; border-bottom: 1px solid var(--line); padding: 4px 0; }
}
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Agents{% endblock %} · nisse-ui</title>
<link rel="stylesheet" href="/static/style.css">
{% block head %}{% endblock %}
</head>
<body>
<header class="top">
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, item_href %}
{% block title %}Overview{% endblock %}
{% block head %}{% if refresh %}<meta http-equiv="refresh" content="{{ refresh }}">{% endif %}{% endblock %}
{% macro links(source, row) %}{% for l in row.links %}{% if l.href %}<a class="out" href="{{ l.href }}" rel="noreferrer">{{ l.text }}</a>{% else %}<span class="out">{{ l.text }}</span>{% endif %}{% endfor %}{% endmacro %}
{% macro item_row(source, row) %}
<tr{% if row.held %} class="held"{% endif %}>
<td><a href="{{ item_href(source, row.item.id) }}">{{ row.item.title or row.item.id }}</a><div class="muted path">{{ row.item.id }}</div></td>
<td>{% if row.mark %}<span class="tag{% if row.held %} warn{% endif %}">{{ row.mark }}</span> {% endif %}{{ row.what }}
{% for d in row.details %}<div class="why">{{ d|clip(300) }}</div>{% endfor %}</td>
<td>{{ links(source, row) }}</td>
<td title="{{ row.item.updated_at|when }}">{{ row.item.updated_at|ago }}</td>
</tr>
{% endmacro %}
{% block main %}
<h1>Overview</h1>
<p class="meta">As of {{ now.isoformat()|when }} UTC.
{% if refresh %}Reloads every {{ refresh }} s · <a href="/?refresh=0">stop reloading</a>{% else %}Not reloading · <a href="/">reload every minute</a>{% endif %}</p>
{% if not agents and not rows %}<p class="muted">No sources. Start with <code>--source NAME=STATE_DB</code> or <code>--runs DIR</code>.</p>{% endif %}
{% for a in agents %}
<section class="agent" id="agent-{{ a.source.name }}">
<h2>{{ src_link(a.source) }}{% if a.kind %} <span class="tag">{{ "nisse-dev" if a.kind == "dev" else "nisse-review" }}</span>{% endif %}</h2>
{% if a.problem %}
<p class="bad">Couldn't read its store: {{ a.problem }}</p>
{% else %}
<div class="now">
{% for r in a.running %}
<p class="running"><span class="tag">running</span> {{ r.run.kind }} · {{ r.run.role }}
{% if r.run.work_item %} on <a href="{{ item_href(a.source, r.run.work_item) }}">{{ r.run.work_item }}</a>{% else %} (agent level){% endif %}
· {{ r.turns }} turn{{ "" if r.turns == 1 else "s" }} · since {{ r.run.started_at|when }}
{% if r.last_call %} · <a href="/s/{{ a.source.name|u }}/call/{{ r.last_call }}">latest turn</a> {{ r.last_at|ago }}{% endif %}</p>
{% endfor %}
{% if not a.running %}<p>Idle{% if a.last_activity %} since {{ a.last_activity|when }} ({{ a.last_activity|ago }}){% endif %}.</p>{% endif %}
{% for r in a.interrupted %}
<p class="muted"><span class="tag warn">maybe interrupted</span> {{ r.run.kind }} · {{ r.run.role }}
{% if r.run.work_item %} on <a href="{{ item_href(a.source, r.run.work_item) }}">{{ r.run.work_item }}</a>{% endif %}
· never ended; last heard {{ r.last_at|when }} ({{ r.last_at|ago }})</p>
{% endfor %}
{% if a.record_note %}<p class="muted">{{ a.record_note }}</p>{% endif %}
{% if a.active %}<p>Active item: <a href="{{ item_href(a.source, a.active.item.id) }}">{{ a.active.item.title or a.active.item.id }}</a> · {{ a.active.what }}</p>{% endif %}
</div>
{% if a.blocks %}
<h3>Blocked build VMs</h3>
<table>
<thead><tr><th>VM</th><th>Why</th><th>Found by</th><th>Since</th></tr></thead>
<tbody>
{% for b in a.blocks %}
<tr class="held"><td class="path">{{ b.host }}</td><td><span class="tag warn">{{ b.cause }}</span> {{ b.reason|clip(300) }}</td>
<td>{% if b.work_item %}<a href="{{ item_href(a.source, b.work_item) }}">{{ b.work_item }}</a>{% endif %}</td>
<td title="{{ b.since|when }}">{{ b.since|ago }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% for g in a.groups if g.rows %}
<h3>{{ g.title }} <span class="muted">({{ g.rows|length }})</span></h3>
<table class="items">
<thead><tr><th>Item</th><th>Where, and why</th><th>Links</th><th>Changed</th></tr></thead>
<tbody>{% for row in g.rows %}{{ item_row(a.source, row) }}{% endfor %}</tbody>
</table>
{% endfor %}
{% if not a.groups|selectattr("rows")|list %}<p class="muted">No open work items.</p>{% endif %}
{% if a.closed %}
<details class="closed">
<summary>Recently closed ({{ a.closed|length }})</summary>
<table class="items">
<thead><tr><th>Item</th><th>Outcome</th><th>Links</th><th>Closed</th></tr></thead>
<tbody>
{% for row in a.closed %}
<tr><td><a href="{{ item_href(a.source, row.item.id) }}">{{ row.item.title or row.item.id }}</a></td>
<td>{{ row.what }}</td><td>{{ links(a.source, row) }}</td>
<td title="{{ (row.item.closed_at or row.item.updated_at)|when }}">{{ (row.item.closed_at or row.item.updated_at)|ago }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
<p class="meta"><a href="/s/{{ a.source.name|u }}/">All its work items</a> · <a href="{{ item_href(a.source, none) }}">agent-level log</a></p>
{% endif %}
</section>
{% endfor %}
{% if rows %}
<h2>Recorded runs</h2>
<table>
<thead><tr><th>Run</th><th>Work items</th><th>Call record</th><th>State store</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ src_link(r.source) }}</td>
<td>{% if r.count is not none %}{{ r.count }}{% else %}<span class="bad">{{ r.note }}</span>{% endif %}</td>
<td>{{ "yes" if r.record else "none" }}</td>
<td class="path">{{ r.source.state_db }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% from "macros.html" import src_link %}
{% block title %}Sources{% endblock %}
{% block main %}
<h1>Agents and recorded runs</h1>
{% if not rows %}<p class="muted">No sources. Start with <code>--source NAME=STATE_DB</code> or <code>--runs DIR</code>.</p>{% endif %}
<table>
<thead><tr><th>Source</th><th>Kind</th><th>Work items</th><th>Call record</th><th>State store</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ src_link(r.source) }}</td>
<td>{{ r.source.kind }}</td>
<td>{% if r.count is not none %}{{ r.count }}{% else %}<span class="bad">{{ r.note }}</span>{% endif %}</td>
<td>{{ "yes" if r.record else "none" }}</td>
<td class="path">{{ r.source.state_db }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+460
View File
@@ -0,0 +1,460 @@
"""The overview (X6, M6.6c): what each agent is doing, what waits and why,
from items the agents' own code wrote."""
import hashlib
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from ui_fixtures import HOSTILE, recorded, store_at
from nisse_core.models import calls
from nisse_dev.loop import items as dev_items
from nisse_dev.loop.items import Loop
from nisse_dev.state import StateStore as DevStore
from nisse_review import state as review
from nisse_review.state import StateStore as ReviewStore
from nisse_ui import links, overview
from nisse_ui import main as ui_main
from nisse_ui.app import App
from nisse_ui.links import Links
from nisse_ui.sources import Source
LINKS = Links(
gitea="https://gitea.test",
room="!room:matrix.test",
repos={"web": "dev/web"},
hosts={"web": "root@build-web.test"},
)
def page(app: App, path: str = "/?refresh=0") -> str:
response = app.handle(path)
assert response.status == 200, response.body.decode()[:500]
return response.body.decode()
def new_store(root: Path, store_class: type[DevStore] | type[ReviewStore]):
root.mkdir(parents=True, exist_ok=True)
return store_at(root, store_class)
def dev_item(
store: DevStore, item_id: str, position: str, loop: Loop, scope: str = "web"
) -> None:
store.open_work_item(
item_id, title=f"Item {item_id}", scope=scope, position="ready", cause="t"
)
dev_items.save(store, item_id, position, loop, cause="t")
@pytest.fixture
def dev(tmp_path: Path) -> Source:
"""A nisse-dev store with an item in each place, written by its own
code: the queue, a VM block, waits, a dependency, and a closed item."""
store = new_store(tmp_path / "dev", DevStore)
dev_item(store, "web-1-aaaaaa", "ready", Loop(issue=1, thread="$t1"))
dev_item(store, "web-2-bbbbbb", "ready", Loop(issue=2, first=1))
dev_item(store, "web-3-cccccc", "ready", Loop(issue=3), scope="other")
dev_item(
store,
"web-4-dddddd",
"waiting",
Loop(
issue=4,
wait=dev_items.MERGE,
note="PR #9 is up for review",
pr=9,
pr_url="https://gitea.test/dev/web/pulls/9",
),
)
dev_item(
store,
"web-5-eeeeee",
"waiting",
Loop(wait=dev_items.CHAT, note="asked which branch"),
)
dev_item(
store,
"web-6-ffffff",
"blocked",
Loop(issue=6, blocked_by=("dev/lib#12", "dev/web#2")),
)
dev_item(store, "web-7-000000", "running", Loop(issue=7))
store.set_active("web-7-000000", cause="t")
dev_item(store, "web-8-111111", "closed", Loop(issue=8))
store.close_work_item("web-8-111111", outcome="merged", cause="t")
store.close()
return Source("dev", tmp_path / "dev" / "state.db", "agent")
def view(source: Source, links: Links = LINKS) -> overview.AgentView:
app = App([source], {source.name: links})
return app._agent(source, datetime.now(UTC))
def group(found: overview.AgentView, key: str) -> overview.Group:
return next(g for g in found.groups if g.key == key)
# --- nisse-dev --------------------------------------------------------------------
def test_dev_items_fall_into_their_places_with_their_reasons(dev: Source) -> None:
found = view(dev)
assert found.kind == overview.DEV
assert found.active is not None and found.active.item.id == "web-7-000000"
assert [r.item.id for r in group(found, "running").rows] == ["web-7-000000"]
parked = {r.item.id: r for r in group(found, "waiting").rows}
assert parked["web-4-dddddd"].what == "waiting for merge"
assert "PR #9 is up for review" in parked["web-4-dddddd"].details
assert parked["web-5-eeeeee"].what == "talking; your turn"
[blocked] = group(found, "blocked").rows
assert [(l.text, l.href) for l in blocked.links][1:] == [
("dev/lib#12", "https://gitea.test/dev/lib/issues/12"),
("dev/web#2", "https://gitea.test/dev/web/issues/2"),
]
assert [r.item.id for r in found.closed] == ["web-8-111111"]
assert found.closed[0].what == "merged"
def test_the_queue_is_in_the_order_the_agent_runs_it(dev: Source) -> None:
# The operator's "do #N next" first, then the oldest.
queue = group(view(dev), "ready").rows
assert [(r.item.id, r.mark) for r in queue] == [
("web-2-bbbbbb", "1 of 3"),
("web-1-aaaaaa", "2 of 3"),
("web-3-cccccc", "3 of 3"),
]
assert "you asked for it first" in queue[0].details
def test_an_item_whose_build_vm_is_blocked_is_held_out_of_line(
tmp_path: Path, dev: Source
) -> None:
store = store_at(dev.state_db.parent, DevStore)
store.block_vm(
"root@build-web.test",
cause="unreachable",
reason="no answer on port 22",
work_item="web-1-aaaaaa",
)
dev_item(store, "web-9-222222", "ready", Loop(question=True))
store.close()
found = view(dev)
queue = group(found, "ready").rows
assert [(r.item.id, r.held) for r in queue] == [
("web-3-cccccc", False), # another project's VM
("web-9-222222", False), # a question is answered anyway (L5)
("web-1-aaaaaa", True), # held, oldest first: not in line
("web-2-bbbbbb", True),
]
assert queue[0].mark == "1 of 2"
assert "no answer on port 22" in queue[2].mark
[block] = found.blocks
assert block["host"] == "root@build-web.test" and block["cause"] == "unreachable"
html = page(App([dev], {"dev": LINKS}))
assert "Blocked build VMs" in html and "held: its build VM is blocked" in html
# Without its config the page can't tell which VM a project builds on:
# nothing is held, and the block is still listed.
bare = group(view(dev, Links()), "ready").rows
assert not any(r.held for r in bare)
assert view(dev, Links()).blocks
def test_dev_links_go_to_the_issue_the_pr_and_the_thread(dev: Source) -> None:
rows = {r.item.id: r for g in view(dev).groups for r in g.rows}
first = {l.text: l.href for l in rows["web-1-aaaaaa"].links}
assert first == {
"#1": "https://gitea.test/dev/web/issues/1",
"thread": "https://matrix.to/#/!room:matrix.test/$t1",
}
merge = {l.text: l.href for l in rows["web-4-dddddd"].links}
assert merge["PR #9"] == "https://gitea.test/dev/web/pulls/9"
# A project the config doesn't name: the issue shows, unlinked.
assert [(l.text, l.href) for l in rows["web-3-cccccc"].links] == [("#3", "")]
def test_with_no_config_there_are_no_links_and_no_errors(dev: Source) -> None:
rows = [r for g in view(dev, Links()).groups for r in g.rows]
hrefs = [l.href for r in rows for l in r.links]
# Only the PR's own address, as Gitea gave it, links.
assert set(hrefs) == {"", "https://gitea.test/dev/web/pulls/9"}
html = page(App([dev]))
assert "Item web-1-aaaaaa" in html and 'href="https://matrix.to' not in html
# --- nisse-review -----------------------------------------------------------------
@pytest.fixture
def reviewer(tmp_path: Path) -> Source:
store = new_store(tmp_path / "rv", ReviewStore)
for number, position, pr in (
(1, review.READY, review.PR("dev/web", 1)),
(2, review.REVIEWED, review.PR("dev/web", 2, rounds=2, happy=True)),
(3, review.FAILED, review.PR("dev/web", 3, attempts=3, head="abc123def456")),
(4, review.CAPPED, review.PR("dev/web", 4, rounds=6)),
(5, review.WAITING, review.PR("dev/web", 5)),
(6, review.STOPPED, review.PR("dev/web", 6)),
):
store.open_work_item(
review.item_id("dev/web", number),
title=f"PR {number}",
position=position,
data=review.data(pr),
cause="t",
)
store.close()
return Source("review", tmp_path / "rv" / "state.db", "agent")
def test_review_prs_are_grouped_with_rounds_and_attempts(reviewer: Source) -> None:
found = view(reviewer, Links(gitea="https://gitea.test"))
assert found.kind == overview.REVIEW
by_key = {g.key: [r.item.id for r in g.rows] for g in found.groups}
assert by_key == {
"running": [],
"ready": ["dev/web#1"],
"waiting": ["dev/web#5"],
"reviewed": ["dev/web#2"],
"capped": ["dev/web#4"],
"failed": ["dev/web#3"],
"stopped": ["dev/web#6"],
}
rows = {r.item.id: r for g in found.groups for r in g.rows}
assert rows["dev/web#2"].details == ("2 rounds", "signed off")
assert "3 failed runs at this head" in rows["dev/web#3"].details
assert [(l.text, l.href) for l in rows["dev/web#1"].links] == [
("dev/web#1", "https://gitea.test/dev/web/pulls/1")
]
# --- what's running now -----------------------------------------------------------
def test_an_open_run_shows_as_running_and_a_stale_one_as_interrupted(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a") # its runs all ended
recorder = calls.CallRecorder(source.calls_db)
# A run under way: begun, with no end recorded.
recorder.run_started("open-run", None, "w1", "driver", "work")
recorder.close()
store.close()
now = datetime.now(UTC)
found = App([source])._agent(source, now)
[running] = found.running
assert running.run["work_item"] == "w1" and running.turns == 0
assert not found.interrupted
html = page(App([source], clock=lambda: now))
assert "running" in html and "Idle" not in html
later = App([source])._agent(source, now + timedelta(minutes=16))
assert not later.running and later.interrupted[0].run["work_item"] == "w1"
assert "maybe interrupted" in page(
App([source], clock=lambda: now + timedelta(minutes=16))
)
def test_with_no_run_open_the_agent_is_idle_since_its_last_activity(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a")
store.close()
found = App([source])._agent(source, datetime.now(UTC))
assert not found.running and not found.interrupted
assert found.last_activity is not None
assert "Idle since" in page(App([source]))
def test_an_agent_without_a_call_record_still_shows_its_items(dev: Source) -> None:
found = view(dev)
assert not found.running and "no call record" in found.record_note
assert found.last_activity is not None # from its items' changes
# --- the page ---------------------------------------------------------------------
def test_the_overview_reloads_itself_unless_asked_not_to(dev: Source) -> None:
app = App([dev])
assert '<meta http-equiv="refresh" content="60">' in page(app, "/")
assert 'http-equiv="refresh"' not in page(app, "/?refresh=0")
def test_kept_runs_are_listed_below_the_agents(tmp_path: Path, dev: Source) -> None:
_, store = recorded(tmp_path / "kept" / "fix-bug-1a2b")
store.close()
app = App(
[
dev,
Source(
"kept.fix-bug-1a2b",
tmp_path / "kept" / "fix-bug-1a2b" / "state.db",
"run",
),
]
)
html = page(app)
assert html.index("Item web-1-aaaaaa") < html.index("Recorded runs")
assert "/s/kept.fix-bug-1a2b/" in html
def test_unknown_positions_and_fields_still_show(tmp_path: Path) -> None:
store = new_store(tmp_path / "x", DevStore)
store.open_work_item(
"odd",
title="Odd item",
position="pondering",
data={
"loop": {
"issue": "not a number",
"wait": 3,
"blocked_by": "dev/x#1",
"new_field": [1],
}
},
cause="t",
)
store.open_work_item("bare", title="No data", position="ready", cause="t")
store.close()
source = Source("x", tmp_path / "x" / "state.db", "agent")
found = view(source)
[other] = [g for g in found.groups if g.key == "other"]
assert [(r.item.id, r.what) for r in other.rows] == [("odd", "pondering")]
assert "No data" in page(App([source]))
def test_an_unreadable_store_shows_as_a_problem_not_a_broken_page(
tmp_path: Path, dev: Source
) -> None:
bad = tmp_path / "bad.db"
with sqlite3.connect(bad) as conn:
conn.execute("CREATE TABLE meta (key TEXT, value TEXT)")
conn.execute("INSERT INTO meta VALUES ('schema_version', '99')")
html = page(App([Source("bad", bad, "agent"), dev]))
assert (
"Couldn't read its store: state store schema 99" in html
and "Item web-1-aaaaaa" in html
)
def test_stored_text_is_escaped_and_never_becomes_a_link(tmp_path: Path) -> None:
store = new_store(tmp_path / "h", DevStore)
dev_item(
store,
"h1",
"waiting",
Loop(
wait=HOSTILE,
note=HOSTILE,
pr=3,
pr_url="javascript:alert(1)",
thread='"><script>x</script>',
),
)
store.close()
source = Source("h", tmp_path / "h" / "state.db", "agent")
html = page(App([source], {"h": LINKS}))
assert "<script>" not in html and "javascript:" not in html
assert "&lt;script&gt;" in html
def test_browsing_the_overview_changes_no_file(tmp_path: Path, dev: Source) -> None:
source, store = recorded(tmp_path / "a")
store.close()
def files() -> dict[str, str]:
return {
str(p): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(tmp_path.rglob("*"))
if p.is_file()
}
before = files()
app = App([source, dev], {"dev": LINKS})
page(app, "/")
page(app, "/?refresh=0")
assert files() == before
# --- the config -------------------------------------------------------------------
CONFIG = """
state_db: /var/lib/nisse-dev/state.db
llm:
base_url: http://litellm.test:4000
token: sk-planted-secret-value
matrix:
homeserver: https://matrix.test
token: ${NISSE_MATRIX_TOKEN}
room_id: "!room:matrix.test"
gitea:
url: https://gitea.test/
token: gitea-planted-secret
projects:
web:
repo: dev/web
workspace: /root/work/web
build_host:
address: build-web.test
key: ~/.ssh/nisse-dev
push_token: push-planted-secret
lib:
repo: ${LIB_REPO}
build_host: {address: build-lib.test, user: builder, key: k}
"""
def test_links_come_from_four_settings_and_nothing_else(tmp_path: Path) -> None:
path = tmp_path / "nisse-dev.yaml"
path.write_text(CONFIG)
found = links.load(path)
assert found == Links(
gitea="https://gitea.test",
room="!room:matrix.test",
repos={"web": "dev/web"}, # a ${VAR} isn't resolved: unset
hosts={"web": "root@build-web.test", "lib": "builder@build-lib.test"},
)
held = repr(found)
for secret in ("planted", "sk-", "NISSE_MATRIX_TOKEN", "litellm"):
assert secret not in held
def test_a_gitea_address_that_isnt_http_gives_no_links(tmp_path: Path) -> None:
path = tmp_path / "c.yaml"
path.write_text("gitea: {url: 'javascript:alert(1)'}\n")
found = links.load(path)
assert found.gitea == "" and found.pull("dev/web", 1) == ""
assert links.web_address("HTTPS://x.test") == "HTTPS://x.test"
assert links.web_address(" data:text/html,x") == ""
def test_the_command_line_takes_a_config_for_a_named_source(
tmp_path: Path, dev: Source, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "nisse-dev.yaml"
path.write_text(CONFIG)
built: dict[str, App] = {}
class Bound(Exception):
pass
def server(address: tuple[str, int], handler: object) -> None:
raise Bound
real_app = ui_main.App
def app(*args: object, **kwargs: object) -> App:
built["app"] = real_app(*args, **kwargs) # type: ignore[arg-type]
return built["app"]
monkeypatch.setattr(ui_main, "ThreadingHTTPServer", server)
monkeypatch.setattr(ui_main, "App", app)
with pytest.raises(Bound):
ui_main.main(["--source", f"dev={dev.state_db}", "--config", f"dev={path}"])
assert built["app"].links["dev"].repos == {"web": "dev/web"}
for bad in (["--config", f"nope={path}"], ["--config", f"dev={tmp_path}/none"]):
with pytest.raises(SystemExit):
ui_main.main(["--source", f"dev={dev.state_db}", *bad])
+1 -1
View File
@@ -47,7 +47,7 @@ def steps(source: Source, item: str | None) -> walk.Walkthrough:
def test_the_sources_and_an_agents_items_are_listed(agent: Source) -> None:
app = App([agent])
home = page(app, "/")
assert "/s/agent/" in home and ">1<" in home # one work item
assert "/s/agent/" in home and "Fix the parser" in home
items = page(app, "/s/agent/")
assert "Fix the parser" in items and "/s/agent/item/w1" in items
assert "/s/agent/item/-" in items # the agent-level log
Generated
+2
View File
@@ -299,12 +299,14 @@ source = { editable = "nisse-ui" }
dependencies = [
{ name = "jinja2" },
{ name = "nisse-core" },
{ name = "pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "jinja2", specifier = ">=3.1" },
{ name = "nisse-core", editable = "nisse-core" },
{ name = "pyyaml", specifier = ">=6" },
]
[[package]]