The status page's overview (M6.6c)
The home page now shows, per agent: the run under way (or idle since when, and runs a crash may have left open), the active item, nisse-dev's queue in its own order with items on a blocked build VM held out, parked items with their wait and note, blocked items and VM blocks, review PRs by position, and the last ten closed. Kept benchmark runs follow. Item data is read tolerantly (nisse-dev's own parser refuses unknown fields); contract tests write items with the agents' own code. Links to issues, PRs and threads come from --config NAME=YAML, reading only the Gitea address, the Matrix room and each project's repo and build VM. The page reloads every minute by meta refresh; ?refresh=0 stops it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
3b1aa39fcd
commit
d02f8c1fbb
17 files changed
+1184
-42
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
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]
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user