The status page's overview (M6.6c) #182
No files matched your search
@@ -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).
|
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
|
- **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
|
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
|
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.
|
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
|
- **Versions:** it is released with the agents, from the same tag. It reads the
|
||||||
|
|||||||
@@ -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) |
|
| **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) |
|
| **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 |
|
| **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) |
|
| `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
|
fitting; wired in each agent's build; deleted at start. Each system prompt
|
||||||
says the calls are recorded, while they are. **Done** (#179).
|
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
|
- **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
|
against the log it was made from; runs going in circles flagged. **Done**
|
||||||
PR): the `nisse-ui` package, on 127.0.0.1 behind an SSH tunnel until d. The
|
(#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.
|
loop rules are `nisse_core.loop.circles`, for M7.8 to reuse.
|
||||||
- **c. The overview:** each agent's active, queued, parked and blocked items.
|
- **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
|
- **d. Deployment:** its image, the read-only mounts and a Caddy route behind
|
||||||
Authelia (with #166).
|
Authelia (with #166).
|
||||||
|
|
||||||
|
|||||||
@@ -484,6 +484,12 @@ class CallReader:
|
|||||||
)
|
)
|
||||||
return self._decoded(rows)
|
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:
|
def call(self, call_id: int) -> dict[str, Any] | None:
|
||||||
rows = self._conn.execute("SELECT * FROM calls WHERE id = ?", (call_id,))
|
rows = self._conn.execute("SELECT * FROM calls WHERE id = ?", (call_id,))
|
||||||
found = self._decoded(rows)
|
found = self._decoded(rows)
|
||||||
|
|||||||
@@ -392,3 +392,17 @@ def test_the_page_reads_one_items_calls_and_when_recording_began(
|
|||||||
assert reader.call(999) is None
|
assert reader.call(999) is None
|
||||||
assert reader.started_at is not None
|
assert reader.started_at is not None
|
||||||
assert reader.started_at <= mine["at"]
|
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
|
||||||
@@ -3,7 +3,7 @@ name = "nisse-ui"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Status page: a read-only view of what the agents did and how (X6)"
|
description = "Status page: a read-only view of what the agents did and how (X6)"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = ["nisse-core", "jinja2>=3.1"]
|
dependencies = ["nisse-core", "jinja2>=3.1", "pyyaml>=6"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
nisse-ui = "nisse_ui.main:main"
|
nisse-ui = "nisse_ui.main:main"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""The page's routes, as a plain function from a path to a response, so the
|
"""The page's routes, as a plain function from a path to a response, so the
|
||||||
tests drive it without a socket (X6).
|
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>/ a source's work items
|
||||||
/s/<source>/item/<id> an item's walkthrough ("-": the agent-level log)
|
/s/<source>/item/<id> an item's walkthrough ("-": the agent-level log)
|
||||||
/s/<source>/call/<n> one turn, read against its 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 json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
from typing import Any
|
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 jinja2 import Environment, PackageLoader, StrictUndefined
|
||||||
|
|
||||||
from nisse_core.models import calls
|
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
|
from nisse_ui.sources import Source, StateReader, UnknownSchema, call_record
|
||||||
|
|
||||||
AGENT_LEVEL = "-" # the item id that stands for the agent-level log
|
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
|
# Everything shown is untrusted text (L11): escaped by the templates, and
|
||||||
# the browser runs no script at all, loads nothing from elsewhere, 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)"
|
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:
|
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.sources = list(sources)
|
||||||
self.by_name = {s.name: s for s in sources}
|
self.by_name = {s.name: s for s in sources}
|
||||||
|
self.links = dict(links or {})
|
||||||
|
self.clock = clock
|
||||||
env = Environment(
|
env = Environment(
|
||||||
loader=PackageLoader("nisse_ui", "templates"),
|
loader=PackageLoader("nisse_ui", "templates"),
|
||||||
autoescape=True, # always: every page shows untrusted text
|
autoescape=True, # always: every page shows untrusted text
|
||||||
@@ -127,6 +153,7 @@ class App:
|
|||||||
pretty=_pretty,
|
pretty=_pretty,
|
||||||
clip=_clip,
|
clip=_clip,
|
||||||
reply_line=_reply_line,
|
reply_line=_reply_line,
|
||||||
|
ago=lambda ts: _ago(ts, self.clock()),
|
||||||
)
|
)
|
||||||
env.globals.update(summary=summary, AGENT_LEVEL=AGENT_LEVEL)
|
env.globals.update(summary=summary, AGENT_LEVEL=AGENT_LEVEL)
|
||||||
self.env = env
|
self.env = env
|
||||||
@@ -139,10 +166,12 @@ class App:
|
|||||||
return self.page("problem.html", status, title=title, detail=detail)
|
return self.page("problem.html", status, title=title, detail=detail)
|
||||||
|
|
||||||
def handle(self, raw_path: str) -> Response:
|
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:]]
|
parts = [unquote(p) for p in path.split("/")[1:]]
|
||||||
if parts in ([], [""]):
|
if parts in ([], [""]):
|
||||||
return self.home()
|
refresh = parse_qs(split.query).get("refresh", [""])[-1] != "0"
|
||||||
|
return self.home(refresh)
|
||||||
if parts == ["static", "style.css"]:
|
if parts == ["static", "style.css"]:
|
||||||
css = resources.files("nisse_ui").joinpath("static/style.css").read_bytes()
|
css = resources.files("nisse_ui").joinpath("static/style.css").read_bytes()
|
||||||
return Response(200, css, "text/css; charset=utf-8")
|
return Response(200, css, "text/css; charset=utf-8")
|
||||||
@@ -179,9 +208,13 @@ class App:
|
|||||||
|
|
||||||
# --- pages ---------------------------------------------------------------------
|
# --- 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 = []
|
rows = []
|
||||||
for source in self.sources:
|
for source in self.sources:
|
||||||
|
if source.kind == "agent":
|
||||||
|
continue
|
||||||
row: dict[str, Any] = {"source": source, "count": None, "note": ""}
|
row: dict[str, Any] = {"source": source, "count": None, "note": ""}
|
||||||
try:
|
try:
|
||||||
with StateReader(source.state_db) as state:
|
with StateReader(source.state_db) as state:
|
||||||
@@ -191,7 +224,29 @@ class App:
|
|||||||
row["note"] = str(exc)
|
row["note"] = str(exc)
|
||||||
row["record"] = source.calls_db.is_file()
|
row["record"] = source.calls_db.is_file()
|
||||||
rows.append(row)
|
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:
|
def items(self, source: Source) -> Response:
|
||||||
with StateReader(source.state_db) as state:
|
with StateReader(source.state_db) as state:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Serve the status page (X6), read-only, on this machine only.
|
"""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.
|
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
|
``--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
|
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.
|
through an SSH tunnel, since it shows everything the models saw.
|
||||||
@@ -13,15 +16,24 @@ import sys
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nisse_ui import links
|
||||||
from nisse_ui.app import App
|
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]:
|
def _source(text: str) -> tuple[str, Path]:
|
||||||
name, sep, path = text.partition("=")
|
return _pair(text, "STATE_DB")
|
||||||
if not sep or not name or not path:
|
|
||||||
raise argparse.ArgumentTypeError("expected NAME=STATE_DB")
|
|
||||||
return name, Path(path)
|
def _config(text: str) -> tuple[str, Path]:
|
||||||
|
return _pair(text, "CONFIG")
|
||||||
|
|
||||||
|
|
||||||
def handler_for(app: App) -> type[BaseHTTPRequestHandler]:
|
def handler_for(app: App) -> type[BaseHTTPRequestHandler]:
|
||||||
@@ -50,11 +62,24 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--source", action="append", type=_source, default=[], metavar="NAME=STATE_DB"
|
"--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("--runs", action="append", type=Path, default=[], metavar="DIR")
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=8765)
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
args = parser.parse_args(argv)
|
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))
|
server = ThreadingHTTPServer((args.host, args.port), handler_for(app))
|
||||||
print(f"nisse-ui on http://{args.host}:{args.port}/", file=sys.stderr)
|
print(f"nisse-ui on http://{args.host}:{args.port}/", file=sys.stderr)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -199,6 +199,37 @@ class StateReader:
|
|||||||
found[row["id"]] = self._event(row)
|
found[row["id"]] = self._event(row)
|
||||||
return found
|
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:
|
def count_items(self) -> int:
|
||||||
return self._conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0]
|
return self._conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0]
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,16 @@ ul.from .text { font-family: var(--mono); font-size: .8rem; color: var(--muted);
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
table.facts th, table.facts td { display: block; width: auto; border: 0; }
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Agents{% endblock %} · nisse-ui</title>
|
<title>{% block title %}Agents{% endblock %} · nisse-ui</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top">
|
<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 %}
|
|
||||||
@@ -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 "<script>" 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])
|
||||||
@@ -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:
|
def test_the_sources_and_an_agents_items_are_listed(agent: Source) -> None:
|
||||||
app = App([agent])
|
app = App([agent])
|
||||||
home = page(app, "/")
|
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/")
|
items = page(app, "/s/agent/")
|
||||||
assert "Fix the parser" in items and "/s/agent/item/w1" in items
|
assert "Fix the parser" in items and "/s/agent/item/w1" in items
|
||||||
assert "/s/agent/item/-" in items # the agent-level log
|
assert "/s/agent/item/-" in items # the agent-level log
|
||||||
|
|||||||
@@ -299,12 +299,14 @@ source = { editable = "nisse-ui" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "nisse-core" },
|
{ name = "nisse-core" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "jinja2", specifier = ">=3.1" },
|
{ name = "jinja2", specifier = ">=3.1" },
|
||||||
{ name = "nisse-core", editable = "nisse-core" },
|
{ name = "nisse-core", editable = "nisse-core" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in new issue
Block a user