Compare commits

...
3 Commits
Author SHA1 Message Date
dongarandClaude Opus 5.5 d02f8c1fbb The status page's overview (M6.6c)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m35s
CI / e2e (pull_request) Successful in 4m6s
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
2026-10-26 04:45:38 +00:00
dongar 3b1aa39fcd Merge pull request 'The status page's walkthrough (M6.6b)' (#181) from walkthrough into main
CI / checks (push) Successful in 3m42s
CI / e2e (push) Skipped
Reviewed-on: #181
Reviewed-by: Don Garrett <dgarrett@acm.org>
2026-10-25 23:25:09 +00:00
dongarandClaude Opus 5.5 a8e2023183 The status page's walkthrough (M6.6b)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m41s
CI / e2e (pull_request) Successful in 4m25s
A new package, nisse-ui: a read-only page for studying how the agents
work. For each work item it shows the steps (what people and the
orchestrator said, and each run), each run's turns, and each turn's
request read against the log it was made from:
- each message beside the events it came from, tagged unchanged,
  labelled, narrated, shortened (by how much) or a compaction, with what
  fitting dropped;
- what is new since the turn before, and where the request first
  differs (where prompt reuse had to stop), next to the server's count;
- the reply with its reasoning, and the raw JSON.

Runs going in circles are flagged by nisse_core.loop.circles (repeated
near-identical replies, one correction given again and again, repeated
apologies), a pure function M7.8 can reuse in Driver.run.

In nisse-core:
- render.py: message_indexes(), from the same code render() uses, so
  the page's matching can't drift from what was sent.
- calls.py: the record notes when recording began; the reader gains
  calls_for(item) and call(id).
- state/readonly.py: opens an agent's SQLite file without creating its
  -wal/-shm files (mode=ro alone does, once the writer has closed).

The page reads only through read-only connections, escapes everything,
and sends a Content Security Policy that allows no script. It listens on
127.0.0.1; until M6.6d puts it behind Authelia, reach it over an SSH
tunnel. Jinja2 is its one new dependency, for autoescaping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-25 03:36:50 +00:00
35 changed files with 3693 additions and 16 deletions

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ COPY nisse-core nisse-core
COPY nisse-dev nisse-dev
COPY nisse-ops nisse-ops
COPY nisse-review nisse-review
COPY nisse-ui nisse-ui
# nisse-dev and what it needs, installed (not editable): the source tree
# isn't part of the image.
+4 -1
View File
@@ -1185,7 +1185,10 @@ and memory work in practice), more than for daily use.
given again and again, so they're seen and fixed (L2).
- **It reads, never writes:** each agent's state store and call record through a
read-only mount, opened read-only in short transactions, and the agent's memory
folder read-only, as SilverBullet does (L10). Each agent stays the only writer
folder read-only, as SilverBullet does (L10). For its links it reads a few
settings from each agent's config file: the Gitea address, the Matrix room,
and each project's repository and build VM. It reads nothing else there, and
never resolves a secret reference. Each agent stays the only writer
of its state (L8), and agent code doesn't change for the page. It runs no
model, holds no credentials and reaches nothing but the mounts and Caddy.
- **Versions:** it is released with the agents, from the same tag. It reads the
+12 -3
View File
@@ -40,7 +40,7 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
| **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) |
| **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) |
| **D7–D8** Deployment, projects | partial | Bring-up runs `nisse-dev`'s and `nisse-review`'s images under systemd on this box; project entries in config (M1.4); egress not enforced |
| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host |
| **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) |
---
@@ -585,10 +585,19 @@ bring-up, end-to-end and benchmark runs; only d waits for M6.1.
- **a. The call record** (L7) in `nisse-core`: written by the shared model client
and `Driver.run`, with each message's provenance from the per-role view and
fitting; wired in each agent's build; deleted at start. Each system prompt
says the calls are recorded, while they are.
says the calls are recorded, while they are. **Done** (#179).
- **b. The walkthrough:** an item's steps, each run's turns, and each request read
against the log it was made from; runs going in circles flagged.
against the log it was made from; runs going in circles flagged. **Done**
(#181): the `nisse-ui` package, on 127.0.0.1 behind an SSH tunnel until d. The
loop rules are `nisse_core.loop.circles`, for M7.8 to reuse.
- **c. The overview:** each agent's active, queued, parked and blocked items.
**Done** (this PR): the home page. Per agent: the run under way (or idle since
when; a run open with no call for 15 minutes shows as maybe interrupted), the
queue in nisse-dev's own order with items on a blocked build VM held out, the
parked items with their wait and note, blocked items and VM blocks, the review
PRs by position, and the last ten closed. Links to issues, PRs and threads come
from `--config NAME=YAML`, the agent's own file (X6). It reloads every minute
with no script (`?refresh=0` stops it).
- **d. Deployment:** its image, the read-only mounts and a Caddy route behind
Authelia (with #166).
+111
View File
@@ -0,0 +1,111 @@
"""Runs going in circles (L2, X6): turns that repeat near-identically, one
correction given again and again, or apologies over and over.
A pure function over a run's turns, so the status page can flag a loop now
and ``Driver.run`` can end one later (M7.8) from the same rules.
"""
import re
from collections.abc import Sequence
from dataclasses import dataclass
from difflib import SequenceMatcher
SIMILAR = 0.9 # how alike two replies are to count as the same
REPEATS = 3 # how many times before it's a circle
_COMPARED = 2000 # characters of each reply compared
_APOLOGY = re.compile(
r"\b(sorry|apologi[sz]e|apologies|my mistake|my apologies|please disregard|"
r"disregard (this|that|the previous|my))\b",
re.IGNORECASE,
)
@dataclass(frozen=True)
class Turn:
"""What a circle is judged on: the model's reply (its text and tool
calls) and what the harness told it before this turn."""
text: str = ""
calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent)
told: tuple[str, ...] = () # corrections given since the turn before
@dataclass(frozen=True)
class Circle:
kind: str # "repeated reply" | "repeated correction" | "repeated apology"
turns: tuple[int, ...] # indexes into the turns given
detail: str
def find_circles(
turns: Sequence[Turn], *, similar: float = SIMILAR, repeats: int = REPEATS
) -> list[Circle]:
"""Every circle in a run, in the order they started."""
found = _repeated_replies(turns, similar, repeats)
found += _repeated_corrections(turns, repeats)
apologies = tuple(i for i, t in enumerate(turns) if _APOLOGY.search(t.text))
if len(apologies) >= repeats:
found.append(
Circle(
"repeated apology",
apologies,
f"the model apologised in {len(apologies)} turns",
)
)
return sorted(found, key=lambda c: c.turns[0])
def _signature(turn: Turn) -> str:
calls = "\n".join(f"{tool}({args})" for tool, args in turn.calls)
return f"{turn.text.strip()}\n{calls}"[:_COMPARED]
def _alike(a: str, b: str, similar: float) -> bool:
if a == b:
return True
matcher = SequenceMatcher(None, a, b, autojunk=False)
return matcher.quick_ratio() >= similar and matcher.ratio() >= similar
def _repeated_replies(
turns: Sequence[Turn], similar: float, repeats: int
) -> list[Circle]:
"""Stretches of consecutive turns each alike the one before."""
found: list[Circle] = []
signatures = [_signature(t) for t in turns]
start = 0
for i in range(1, len(turns) + 1):
if (
i < len(turns)
and signatures[i].strip()
and _alike(signatures[i - 1], signatures[i], similar)
):
continue
if i - start >= repeats:
found.append(
Circle(
"repeated reply",
tuple(range(start, i)),
f"{i - start} near-identical replies in a row",
)
)
start = i
return found
def _repeated_corrections(turns: Sequence[Turn], repeats: int) -> list[Circle]:
"""One correction given in ``repeats`` or more turns, in a row or not."""
given: dict[str, list[int]] = {}
for i, turn in enumerate(turns):
for text in dict.fromkeys(turn.told):
given.setdefault(text, []).append(i)
return [
Circle(
"repeated correction",
tuple(at),
f"told {len(at)} times: {text[:120]}",
)
for text, at in given.items()
if len(at) >= repeats
]
+38 -6
View File
@@ -29,7 +29,7 @@ import sqlite3
import threading
import uuid
import zlib
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Iterable, Iterator, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass, replace
from datetime import UTC, datetime
@@ -38,6 +38,7 @@ from typing import Any
from nisse_core.log.canonical import StoredEvent, event_type, to_payload
from nisse_core.oplog import OpLog
from nisse_core.state import readonly
LAYOUT_VERSION = 1
@@ -241,8 +242,9 @@ class CallRecorder:
for statement in _SCHEMA:
conn.execute(statement)
conn.execute(
"INSERT INTO meta (key, value) VALUES ('layout_version', ?)",
(str(LAYOUT_VERSION),),
"INSERT INTO meta (key, value) VALUES ('layout_version', ?), "
"('started_at', ?)",
(str(LAYOUT_VERSION), utc_now()),
)
conn.execute("COMMIT")
self._conn = conn
@@ -427,11 +429,10 @@ class UnknownLayout(RuntimeError):
class CallReader:
"""Opens a call record read-only (``mode=ro``) and never writes to it."""
"""Opens a call record read-only and never writes to it (``readonly``)."""
def __init__(self, path: str | Path) -> None:
self._conn = sqlite3.connect(f"file:{Path(path)}?mode=ro", uri=True)
self._conn.row_factory = sqlite3.Row
self._conn = readonly.connect(path)
row = self._conn.execute(
"SELECT value FROM meta WHERE key = 'layout_version'"
).fetchone()
@@ -442,6 +443,11 @@ class CallReader:
f"call record layout {self.layout_version}; this reader knows "
f"{LAYOUT_VERSION}"
)
row = self._conn.execute(
"SELECT value FROM meta WHERE key = 'started_at'"
).fetchone()
# When recording began: work before it has its log but no requests.
self.started_at: str | None = row["value"] if row else None
def blob(self, digest: str) -> Any:
row = self._conn.execute(
@@ -464,6 +470,32 @@ class CallReader:
rows = self._conn.execute(
"SELECT * FROM calls WHERE run = ? ORDER BY id", (run,)
)
return self._decoded(rows)
def calls_for(self, work_item: str | None) -> list[dict[str, Any]]:
"""One work item's calls, or the agent-level ones (None)."""
if work_item is None:
rows = self._conn.execute(
"SELECT * FROM calls WHERE work_item IS NULL ORDER BY id"
)
else:
rows = self._conn.execute(
"SELECT * FROM calls WHERE work_item = ? ORDER BY id", (work_item,)
)
return self._decoded(rows)
def last_call(self) -> dict[str, Any] | None:
"""The latest call, of any run: when the agent last asked a model."""
rows = self._conn.execute("SELECT * FROM calls ORDER BY id DESC LIMIT 1")
found = self._decoded(rows)
return found[0] if found else None
def call(self, call_id: int) -> dict[str, Any] | None:
rows = self._conn.execute("SELECT * FROM calls WHERE id = ?", (call_id,))
found = self._decoded(rows)
return found[0] if found else None
def _decoded(self, rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]:
out = []
for row in rows:
call = dict(row)
+22 -4
View File
@@ -88,13 +88,34 @@ class OpenAIChatRenderer:
model: str,
system: str | None = None,
) -> dict[str, Any]:
messages, _ = self._messages(events, system)
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
return body
def message_indexes(
self, events: list[StoredEvent], system: str | None = None
) -> list[int | None]:
"""For each event, the index of the request message ``render`` puts it
in, or None where a compaction stands in for it: so a request can be
read against the log it was made from (X6). Several events share an
index where one turn's text and tool calls fold into one message."""
_, where = self._messages(events, system)
return [where.get(id(stored)) for stored in events]
def _messages(
self, events: list[StoredEvent], system: str | None
) -> tuple[list[dict[str, Any]], dict[int, int]]:
messages: list[dict[str, Any]] = []
where: dict[int, int] = {} # id() of an event given -> its message
if system is not None:
messages.append({"role": "system", "content": system})
kept = effective_events(events)
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
for stored in kept:
_append_merged(messages, self._message_for(stored.event))
where[id(stored)] = len(messages) - 1
# Providers reject a tool call with no result following it. One
# can legitimately be missing: a crash between the call and its
# result (L8), or a turn-ending gate tool. Render a placeholder so
@@ -109,10 +130,7 @@ class OpenAIChatRenderer:
"content": "[no result recorded]",
}
)
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
return body
return messages, where
def _message_for(self, event: object) -> dict[str, Any]:
match event:
@@ -0,0 +1,26 @@
"""Opening an agent's SQLite file without writing to it (X6), for readers
beside the agent: the status page, the call record's reader.
``mode=ro`` alone still creates the ``-wal`` and ``-shm`` files of a
database whose writer has closed, which writes to the agent's volume and
fails on a read-only mount. With no ``-wal`` file there is no writer, so the
file is opened immutable instead: nothing is created, nothing is locked.
With one, a writer is (or was) there, and SQLite's own read-only WAL
reading applies.
"""
import sqlite3
from pathlib import Path
from urllib.parse import quote
def connect(path: str | Path, *, timeout: float = 5.0) -> sqlite3.Connection:
path = Path(path)
if not path.is_file():
raise sqlite3.OperationalError(f"unable to open database file: {path}")
mode = "ro" if Path(f"{path}-wal").exists() else "ro&immutable=1"
conn = sqlite3.connect(
f"file:{quote(str(path.resolve()))}?mode={mode}", uri=True, timeout=timeout
)
conn.row_factory = sqlite3.Row
return conn
+32
View File
@@ -374,3 +374,35 @@ def test_the_reader_never_writes(recorder: calls.CallRecorder) -> None:
reader = read(recorder)
with pytest.raises(sqlite3.OperationalError, match="readonly"):
reader._conn.execute("DELETE FROM calls")
def test_the_page_reads_one_items_calls_and_when_recording_began(
recorder: calls.CallRecorder,
) -> None:
client = budgeted(Server(), recorder)
with calls.run(recorder, work_item="w", role="driver"):
client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
with calls.run(recorder, work_item=None, role="classifier"):
client.complete(stored([UserMessage(text="hi", source="t")]), None, "local")
reader = read(recorder)
[mine] = reader.calls_for("w")
[agent_level] = reader.calls_for(None)
assert (mine["work_item"], agent_level["work_item"]) == ("w", None)
assert reader.call(agent_level["id"])["role"] == "classifier"
assert reader.call(999) is None
assert reader.started_at is not None
assert reader.started_at <= mine["at"]
def test_the_page_reads_the_latest_call_of_any_run(
recorder: calls.CallRecorder,
) -> None:
assert read(recorder).last_call() is None
client = budgeted(Server(), recorder)
with calls.run(recorder, work_item="w", role="driver"):
client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
client.complete(stored([UserMessage(text="hi", source="t")]), None, "local")
reader = read(recorder)
last = reader.last_call()
assert last is not None and last["id"] == reader.calls()[-1]["id"]
assert last["work_item"] is None
+73
View File
@@ -0,0 +1,73 @@
"""Runs going in circles (L2, X6), judged from the turns alone."""
from nisse_core.loop.circles import Turn, find_circles
NUDGE = "Your last reply was prose. Every reply must be a tool call."
def comment(n: int) -> Turn:
return Turn(
text="",
calls=(("post_comment", f'{{"body": "Duplicate, please disregard. ({n})"}}'),),
)
def test_near_identical_replies_in_a_row_are_a_circle() -> None:
# The Flash-Next review loop of 2026-10-24: the same comment posted over
# and over, differing only in a counter.
turns = [Turn(calls=(("read_file", '{"path": "a.py"}'),))]
turns += [comment(n) for n in range(6)]
turns += [Turn(calls=(("finish", "{}"),))]
[circle] = find_circles(turns)
assert circle.kind == "repeated reply"
assert circle.turns == (1, 2, 3, 4, 5, 6)
def test_work_that_moves_on_is_no_circle() -> None:
turns = [
Turn(calls=(("read_file", '{"path": "a.py"}'),)),
Turn(calls=(("run_command", '{"command": "pytest -q"}'),)),
Turn(calls=(("edit_file", '{"path": "a.py", "old": "x", "new": "y"}'),)),
Turn(calls=(("run_command", '{"command": "pytest -q"}'),)),
Turn(text="Done.", calls=(("finish", "{}"),)),
]
assert find_circles(turns) == []
def test_two_alike_is_not_yet_a_circle() -> None:
assert find_circles([comment(1), comment(2), Turn(text="done")]) == []
def test_one_correction_given_again_and_again_is_a_circle() -> None:
# Invented tools met with the prose reminder turn after turn, the replies
# themselves all different.
replies = [
"I'll run the linter first: <tool_call>lint_fix</tool_call>",
"<call>read_file src/review.py</call>",
"Installing the dependencies with install(requirements.txt) now.",
"Let me use the skill tool to summarise the diff for the review.",
]
turns = [Turn(text=r, told=(NUDGE,) if i else ()) for i, r in enumerate(replies)]
[circle] = find_circles(turns)
assert circle.kind == "repeated correction"
assert circle.turns == (1, 2, 3)
assert NUDGE[:40] in circle.detail
def test_apologies_over_and_over_are_a_circle() -> None:
turns = [
Turn(text="Sorry, I made an error reading the file."),
Turn(calls=(("read_file", '{"path": "b.py"}'),)),
Turn(text="My apologies, that was the wrong path."),
Turn(calls=(("list_files", "{}"),)),
Turn(text="Please disregard my previous comment."),
]
[circle] = find_circles(turns)
assert circle.kind == "repeated apology"
assert circle.turns == (0, 2, 4)
def test_the_limits_are_tuning() -> None:
turns = [comment(1), comment(2)]
assert [c.kind for c in find_circles(turns, repeats=2)] == ["repeated reply"]
assert find_circles([comment(1), comment(22), comment(333)], similar=1.0) == []
+43
View File
@@ -0,0 +1,43 @@
"""Readers beside an agent open its files without writing to them (X6)."""
import sqlite3
from pathlib import Path
import pytest
from nisse_core.state import readonly
def wal_db(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE t (v TEXT)")
conn.execute("INSERT INTO t VALUES ('one')")
return conn
def test_a_closed_database_is_read_without_creating_any_file(tmp_path: Path) -> None:
wal_db(tmp_path / "a.db").close()
assert sorted(p.name for p in tmp_path.iterdir()) == ["a.db"]
conn = readonly.connect(tmp_path / "a.db")
assert conn.execute("SELECT v FROM t").fetchall()[0]["v"] == "one"
conn.close()
assert sorted(p.name for p in tmp_path.iterdir()) == ["a.db"]
def test_a_live_database_is_read_as_its_writer_commits(tmp_path: Path) -> None:
writer = wal_db(tmp_path / "a.db")
conn = readonly.connect(tmp_path / "a.db")
writer.execute("INSERT INTO t VALUES ('two')")
assert [r["v"] for r in conn.execute("SELECT v FROM t")] == ["one", "two"]
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO t VALUES ('three')")
conn.close()
writer.execute("INSERT INTO t VALUES ('four')") # no lock left behind
writer.close()
def test_a_missing_file_is_an_error_not_a_new_database(tmp_path: Path) -> None:
with pytest.raises(sqlite3.OperationalError):
readonly.connect(tmp_path / "none.db")
assert not (tmp_path / "none.db").exists()
+44
View File
@@ -163,3 +163,47 @@ def test_roles_never_repeat_for_strict_templates() -> None:
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
for first, second in pairwise(roles):
assert not (first == second and first in ("user", "assistant")), roles
def test_each_event_maps_to_the_message_render_puts_it_in() -> None:
# A turn's text and tool call fold into one message; a call with no
# result gets a placeholder that came from no event; the system prompt
# is message 0.
events = stored(
[
UserMessage(text="go", source="m:1"),
AssistantText(text="Looking.", model="m"),
ToolCall(call_id="c1", tool="read_file", arguments={"path": "a"}),
ToolResult(call_id="c1", result="text", success=True),
ToolCall(call_id="c2", tool="finish", arguments={}),
UserMessage(text="and then?", source="m:2"),
]
)
renderer = OpenAIChatRenderer()
body = renderer.render(events, tools=None, model="m", system="sys")
indexes = renderer.message_indexes(events, system="sys")
assert indexes == [1, 2, 2, 3, 4, 6]
assert body["messages"][5]["content"] == "[no result recorded]"
for event, index in zip(events, indexes, strict=True):
message = body["messages"][index]
if isinstance(event.event, ToolCall):
ids = [c["id"] for c in message["tool_calls"]]
assert event.event.call_id in ids
elif isinstance(event.event, (UserMessage, AssistantText)):
assert event.event.text in message["content"]
assert renderer.message_indexes(events) == [i - 1 for i in indexes]
def test_events_a_compaction_replaced_map_to_no_message() -> None:
events = stored(
[
UserMessage(text="one", source="m:1"),
AssistantText(text="two", model="m"),
UserMessage(text="three", source="m:3"),
Compaction(summary="one and two", model="m", replaces=[1, 2]),
]
)
# The summary takes the place of what it replaced, and the user message
# after it folds into the same user message.
indexes = OpenAIChatRenderer().message_indexes(events)
assert indexes == [None, None, 0, 0]
+40
View File
@@ -0,0 +1,40 @@
# nisse-ui
The status page (architecture X6): a read-only view of what the agents did
and how, for studying context, compaction and memory in practice.
It reads each agent's state store and the call record beside it (L7), and
never writes to either. It runs no model and holds no credentials.
uv run nisse-ui --source dev=/var/lib/nisse-dev/state.db \
--source review=/var/lib/nisse-review/state.db \
--runs /path/to/kept-runs
- `--source NAME=STATE_DB`: an agent's state store. Its call record is
`<stem>.calls.db` beside it.
- `--runs DIR`: runs the benchmark kept (`scripts/benchmark.py run
--keep-runs DIR`). Each folder holding a `state.db` is a source.
It listens on 127.0.0.1:8765, because it shows everything the models saw,
including what other people wrote. Until it sits behind Caddy and Authelia
(M6.6d), reach it through an SSH tunnel:
ssh -L 8765:localhost:8765 <this VM>
then open http://localhost:8765/.
**What it shows:** for each work item, its steps (what people and the
orchestrator said, and each run), each run's turns, and for each turn:
- the request exactly as sent, message by message, beside the log events
each came from and what rendering did to them;
- what changed since the turn before, and where prompt reuse had to stop,
next to the server's own count;
- the reply, with its reasoning;
- the raw JSON.
Runs going in circles are flagged (`nisse_core.loop.circles`).
**Safety:** everything is escaped, and the page sends a Content Security
Policy that runs no script at all. Files are opened through
`nisse_core.state.readonly`, which never creates or locks anything.
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "nisse-ui"
version = "0.1.0"
description = "Status page: a read-only view of what the agents did and how (X6)"
requires-python = ">=3.12"
dependencies = ["nisse-core", "jinja2>=3.1", "pyyaml>=6"]
[project.scripts]
nisse-ui = "nisse_ui.main:main"
[tool.uv.sources]
nisse-core = { workspace = true }
[build-system]
requires = ["uv_build>=0.12,<0.13"]
build-backend = "uv_build"
+1
View File
@@ -0,0 +1 @@
"""nisse-ui: the operator's read-only view of the agents (architecture X6)."""
+285
View File
@@ -0,0 +1,285 @@
"""The page's routes, as a plain function from a path to a response, so the
tests drive it without a socket (X6).
/ 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
/s/<source>/event/<n> one log event in full
"""
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 parse_qs, quote, unquote, urlsplit
from jinja2 import Environment, PackageLoader, StrictUndefined
from nisse_core.models import calls
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
# sends no referrer.
HEADERS = {
"Content-Security-Policy": (
"default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'"
),
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",
}
@dataclass(frozen=True)
class Response:
status: int
body: bytes
content_type: str = "text/html; charset=utf-8"
headers: dict[str, str] = field(default_factory=lambda: dict(HEADERS))
def _when(ts: Any) -> str:
return str(ts)[:19].replace("T", " ") if ts else "—"
def _num(n: Any) -> str:
return f"{n:,}" if isinstance(n, int) and not isinstance(n, bool) else "—"
def _pretty(text: Any) -> str:
"""JSON text indented, anything else as it is."""
if not isinstance(text, str):
return json.dumps(text, indent=2, ensure_ascii=False)
try:
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
except ValueError:
return text
def _clip(text: Any, limit: int = 300) -> str:
text = str(text or "")
return (
text
if len(text) <= limit
else text[:limit] + f" … [{len(text) - limit:,} more]"
)
def summary(kind: str, payload: dict[str, Any]) -> str:
"""One event as a line of text: what it said or did."""
match kind:
case "user_message" | "assistant_text" | "fact":
return str(payload.get("text", ""))
case "tool_call":
args = json.dumps(payload.get("arguments", {}), ensure_ascii=False)
return f"{payload.get('tool', '?')}({args})"
case "tool_result":
mark = "ok" if payload.get("success") else "FAILED"
return f"{mark}: {payload.get('result', '')}"
case "plan_submitted":
return str(payload.get("plan", ""))
case "gate_opened":
return f"{payload.get('gate', '')}: {payload.get('reason', '')}"
case "delegation_started":
return f"({payload.get('model', '')}) {payload.get('brief', '')}"
case "delegation_completed":
return f"[{payload.get('exit_status', '')}] {payload.get('handback', '')}"
case "compaction":
first, last = (payload.get("replaces") or ["?", "?"])[:2]
return f"replaces {first}–{last}: {payload.get('summary', '')}"
case "action_verdict":
return (
f"{payload.get('tool', '')}: {payload.get('effect', '')} → "
f"{payload.get('decision', '')} ({payload.get('reason', '')})"
)
case _:
return json.dumps(payload, ensure_ascii=False)
def _reply_line(reply: walk.Reply) -> str:
calls_made = "; ".join(f"{tool}({args})" for tool, args in reply.calls)
text = " ".join(reply.text.split())
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],
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
undefined=StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
)
env.filters.update(
u=lambda s: quote(str(s), safe=""),
when=_when,
num=_num,
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
def page(self, template: str, status: int = 200, **values: Any) -> Response:
html = self.env.get_template(template).render(**values)
return Response(status, html.encode())
def problem(self, status: int, title: str, detail: str) -> Response:
return self.page("problem.html", status, title=title, detail=detail)
def handle(self, raw_path: str) -> Response:
split = urlsplit(raw_path)
path = split.path
parts = [unquote(p) for p in path.split("/")[1:]]
if parts in ([], [""]):
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")
if len(parts) >= 2 and parts[0] == "s":
source = self.by_name.get(parts[1])
if source is None:
return self.problem(404, "No such source", parts[1])
rest = parts[2:]
try:
if rest in ([], [""]):
return self.items(source)
if len(rest) == 2 and rest[0] == "item":
item = None if rest[1] == AGENT_LEVEL else rest[1]
return self.item(source, item)
if (
len(rest) == 2
and rest[0] in ("call", "event")
and rest[1].isdigit()
):
if rest[0] == "call":
return self.call(source, int(rest[1]))
return self.event(source, int(rest[1]))
except UnknownSchema as exc:
return self.problem(
409, "A state store this page doesn't know", str(exc)
)
except calls.UnknownLayout as exc:
return self.problem(
409, "A call record this page doesn't know", str(exc)
)
except sqlite3.Error as exc:
return self.problem(503, "Couldn't read the store", str(exc))
return self.problem(404, "Not found", path)
# --- pages ---------------------------------------------------------------------
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:
row["count"] = state.count_items()
row["schema"] = state.schema_version
except (UnknownSchema, sqlite3.Error) as exc:
row["note"] = str(exc)
row["record"] = source.calls_db.is_file()
rows.append(row)
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:
return self.page(
"items.html",
source=source,
items=state.items(),
active=state.active_item(),
)
def item(self, source: Source, item_id: str | None) -> Response:
with StateReader(source.state_db) as state:
if item_id is not None and state.item(item_id) is None:
return self.problem(404, "No such work item", item_id)
try:
with call_record(source) as reader:
view = walk.walkthrough(state, reader, item_id)
except calls.UnknownLayout as exc:
view = walk.walkthrough(state, None, item_id, record_note=str(exc))
return self.page("item.html", source=source, view=view)
def call(self, source: Source, call_id: int) -> Response:
with StateReader(source.state_db) as state, call_record(source) as reader:
if reader is None:
return self.problem(404, "No call record", str(source.calls_db))
view = walk.turn(state, reader, call_id)
if view is None:
return self.problem(404, "No such call", str(call_id))
return self.page("call.html", source=source, view=view)
def event(self, source: Source, event_id: int) -> Response:
with StateReader(source.state_db) as state:
found = state.event(event_id)
if found is None:
return self.problem(404, "No such event", str(event_id))
return self.page("event.html", source=source, event=found)
+99
View File
@@ -0,0 +1,99 @@
"""Links out of the page, to Gitea and Matrix (X6, M6.6c), built from an
agent's own config file.
The page reads exactly these settings and nothing else: ``gitea.url``,
``matrix.room_id``, and per project ``repo`` and ``build_host.address`` and
``.user`` (the build VM's name, as a VM block names it). It never resolves a
``${VAR}`` reference (a setting given as one is treated as unset) and never
shows the file. Configs hold no secrets, only references to them, but the
page has no use for the rest, so it never holds them.
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import quote
from nisse_core.config import flatten, read_file
class ConfigProblem(ValueError):
pass
@dataclass(frozen=True)
class Links:
gitea: str = "" # the Gitea address, no trailing slash
room: str = "" # the agent's Matrix room
repos: dict[str, str] = field(default_factory=dict) # project -> owner/name
hosts: dict[str, str] = field(default_factory=dict) # project -> user@address
def repo_url(self, repo: str) -> str:
return f"{self.gitea}/{repo}" if self.gitea and _repo(repo) else ""
def issue(self, repo: str, number: int) -> str:
base = self.repo_url(repo)
return f"{base}/issues/{number}" if base and number > 0 else ""
def pull(self, repo: str, number: int) -> str:
base = self.repo_url(repo)
return f"{base}/pulls/{number}" if base and number > 0 else ""
def dependency(self, ref: str) -> str:
"""An issue named as ``owner/repo#N``, as blocked items record them."""
repo, sep, number = ref.rpartition("#")
return self.issue(repo, int(number)) if sep and number.isdigit() else ""
def thread(self, event: str) -> str:
"""A Matrix thread's root, opened in the operator's own client."""
if not (self.room and event):
return ""
return (
f"https://matrix.to/#/{quote(self.room, safe='!:')}/"
f"{quote(event, safe='$:')}"
)
def web_address(value: Any) -> str:
"""An http(s) address, or "" for anything else: whatever reaches an href
goes through here, so a stored ``javascript:`` never becomes a link."""
text = str(value or "").strip()
return text if text.lower().startswith(("https://", "http://")) else ""
def _repo(text: str) -> bool:
owner, sep, name = text.partition("/")
return bool(sep and owner and name and "/" not in name)
def _plain(value: Any) -> str:
"""A setting's text, or "" when it's a ${VAR} reference or not text."""
if not isinstance(value, str) or "${" in value:
return ""
return value.strip()
def load(path: Path) -> Links:
errors: list[str] = []
tree = read_file(path, errors) if path.is_file() else {}
if errors or not path.is_file():
raise ConfigProblem(errors[0] if errors else f"{path}: no such file")
top = flatten({k: v for k, v in tree.items() if k in ("gitea", "matrix")})
repos: dict[str, str] = {}
hosts: dict[str, str] = {}
projects = tree.get("projects")
for name, entry in (projects if isinstance(projects, dict) else {}).items():
if not isinstance(entry, dict):
continue
flat = flatten(entry)
if _repo(repo := _plain(flat.get("repo"))):
repos[str(name)] = repo
if address := _plain(flat.get("build_host.address")):
user = _plain(flat.get("build_host.user")) or "root"
hosts[str(name)] = f"{user}@{address}"
return Links(
gitea=web_address(_plain(top.get("gitea.url"))).rstrip("/"),
room=_plain(top.get("matrix.room_id")),
repos=repos,
hosts=hosts,
)
+95
View File
@@ -0,0 +1,95 @@
"""Serve the status page (X6), read-only, on this machine only.
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.
"""
import argparse
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, 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]:
return _pair(text, "STATE_DB")
def _config(text: str) -> tuple[str, Path]:
return _pair(text, "CONFIG")
def handler_for(app: App) -> type[BaseHTTPRequestHandler]:
class Handler(BaseHTTPRequestHandler):
server_version = "nisse-ui"
sys_version = ""
def do_GET(self) -> None:
response = app.handle(self.path)
self.send_response(response.status)
self.send_header("Content-Type", response.content_type)
self.send_header("Content-Length", str(len(response.body)))
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(response.body)
def log_message(self, format: str, *args: object) -> None:
sys.stderr.write(f"nisse-ui {self.address_string()} {format % args}\n")
return Handler
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
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)
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:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
+344
View File
@@ -0,0 +1,344 @@
"""The overview (X6, M6.6c): what each agent is doing now, what waits, and
why, read from its state store and call record.
Each agent keeps what it knows about an item in the item's data: nisse-dev
as ``data["loop"]``, nisse-review as ``data["pr"]``. The page reads each
tolerantly, never with the agents' own parsers (nisse-dev's refuses a field
it doesn't know): a missing field reads as empty, an unknown one is ignored,
and an unknown position is shown by its name. The contract tests write items
with the agents' own code, so renaming a field the page reads fails there.
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any
from nisse_core.models import calls
from nisse_ui.links import Links, web_address
from nisse_ui.sources import Item, Source, StateReader
# An open run with no call for this long is probably one a crash left open:
# a run that never ended has no end to record.
STALE = timedelta(minutes=15)
CLOSED_SHOWN = 10
INTERRUPTED_SHOWN = 3
DEV = "dev"
REVIEW = "review"
# The groups each agent's open items fall into, in the order shown: the
# position, and what the page calls it. Anything else goes under "Other".
GROUPS = {
DEV: (
("running", "Running"),
("ready", "Queue"),
("waiting", "Parked"),
("blocked", "Blocked"),
),
REVIEW: (
("running", "Running"),
("ready", "Due"),
("waiting", "Draft or work in progress"),
("reviewed", "Watching"),
("capped", "At the round cap"),
("failed", "Failed"),
("stopped", "Stopped"),
),
"": (),
}
@dataclass(frozen=True)
class Link:
text: str
href: str
@dataclass(frozen=True)
class Row:
item: Item
what: str # where it is, in words
details: tuple[str, ...] = () # why: its note, its dependencies, ...
links: tuple[Link, ...] = ()
mark: str = "" # its place in line, or why it's held out of it
held: bool = False # in the queue's position, but not in line
@dataclass(frozen=True)
class Group:
key: str
title: str
rows: tuple[Row, ...]
@dataclass(frozen=True)
class OpenRun:
run: dict[str, Any]
turns: int
last_call: int | None # the latest turn's call, to link to
last_at: str # when it last asked a model, or began
stale: bool
@dataclass
class AgentView:
source: Source
kind: str # DEV, REVIEW, or "" for items in neither shape
active: Row | None = None
running: list[OpenRun] = field(default_factory=list)
interrupted: list[OpenRun] = field(default_factory=list)
last_activity: str | None = None
groups: list[Group] = field(default_factory=list)
blocks: list[dict[str, Any]] = field(default_factory=list)
closed: list[Row] = field(default_factory=list)
record_note: str = ""
problem: str = "" # why the store couldn't be read
# --- reading item data tolerantly ------------------------------------------------
def _section(item: Item, key: str) -> dict[str, Any]:
found = item.data.get(key)
return found if isinstance(found, dict) else {}
def _text(data: dict[str, Any], key: str) -> str:
value = data.get(key)
return value if isinstance(value, str) else ""
def _int(data: dict[str, Any], key: str) -> int:
value = data.get(key)
return value if isinstance(value, int) and not isinstance(value, bool) else 0
def _number(data: dict[str, Any], key: str) -> float:
value = data.get(key)
if isinstance(value, bool) or not isinstance(value, int | float):
return 0.0
return float(value)
def _texts(data: dict[str, Any], key: str) -> tuple[str, ...]:
value = data.get(key)
if not isinstance(value, list | tuple):
return ()
return tuple(v for v in value if isinstance(v, str))
def kind_of(items: list[Item]) -> str:
"""Which agent's items these are, by their data's shape: the store
doesn't record the agent's name."""
if any(_section(i, "loop") for i in items):
return DEV
if any(_section(i, "pr") for i in items):
return REVIEW
return ""
def parse_time(text: Any) -> datetime | None:
try:
found = datetime.fromisoformat(str(text))
except ValueError:
return None
return found if found.tzinfo else found.replace(tzinfo=UTC)
def _stamp(seconds: float) -> str:
return datetime.fromtimestamp(seconds, UTC).isoformat()
# --- one item, as a row ----------------------------------------------------------
def dev_row(item: Item, links: Links, place: str = "", held: str = "") -> Row:
loop = _section(item, "loop")
wait, note = _text(loop, "wait"), _text(loop, "note")
details: list[str] = []
what = item.position
if item.position == "waiting" and wait == "conversation":
what = "talking; your turn"
elif item.position == "waiting":
what = f"waiting for {wait}" if wait else "waiting"
if grace := _number(loop, "grace_until"):
details.append(f"goes ahead at {_stamp(grace)[:16]} unless you reply")
elif item.position == "blocked":
what = "blocked"
if note:
details.append(note)
if item.position == "blocked" and not _texts(loop, "blocked_by"):
details.append("waits for its dependencies") # shown as links when known
if _section(item, "loop").get("question") is True:
details.append("answering your question")
if action := _text(loop, "pending_action"):
details.append(f"asks you to allow: {action}")
if held_merge := _text(loop, "merge_held"):
details.append(f"merge held: {held_merge}")
if _int(loop, "first"):
details.append("you asked for it first")
repo = links.repos.get(item.scope or "", "")
found: list[Link] = []
if issue := _int(loop, "issue"):
found.append(Link(f"#{issue}", links.issue(repo, issue)))
if pr := _int(loop, "pr"):
href = web_address(_text(loop, "pr_url")) or links.pull(repo, pr)
found.append(Link(f"PR #{pr}", href))
for dep in _texts(loop, "blocked_by") if item.position == "blocked" else ():
found.append(Link(dep, links.dependency(dep)))
if thread := _text(loop, "thread"):
found.append(Link("thread", links.thread(thread)))
return Row(
item,
what,
tuple(details),
tuple(found),
mark=held or place,
held=bool(held),
)
def review_row(item: Item, links: Links) -> Row:
pr = _section(item, "pr")
details: list[str] = []
if rounds := _int(pr, "rounds"):
details.append(f"{rounds} round{'s' if rounds != 1 else ''}")
if attempts := _int(pr, "attempts"):
details.append(
f"{attempts} failed run{'s' if attempts != 1 else ''} at this head"
)
if pr.get("happy") is True:
details.append("signed off")
if pr.get("full") is True:
details.append("a fresh review asked for")
if run := _text(pr, "run"):
details.append(f"run: {run}")
if head := _text(pr, "head"):
details.append(f"head {head[:10]}")
found: list[Link] = []
repo, number = _text(pr, "repo"), _int(pr, "number")
if number:
found.append(Link(f"{repo}#{number}", links.pull(repo, number)))
return Row(item, item.position, tuple(details), tuple(found))
def row_of(item: Item, links: Links) -> Row:
if _section(item, "loop"):
return dev_row(item, links)
if _section(item, "pr"):
return review_row(item, links)
return Row(item, item.position)
def closed_row(item: Item, links: Links) -> Row:
found = row_of(item, links)
return Row(item, item.outcome or item.status, (), found.links)
# --- the queue -------------------------------------------------------------------
def dev_queue(
ready: list[Item], blocks: list[dict[str, Any]], links: Links
) -> list[Row]:
"""nisse-dev's ready items in the order it runs them (its ``_queue``):
the operator's "do #N next" first, then the oldest. An item whose build
VM is blocked is held out of line, unless it's a question (L5). Which VM
a project builds on comes from its config: without one, none is held."""
blocked = {str(b.get("host")): b for b in blocks}
def block_of(item: Item) -> dict[str, Any] | None:
host = links.hosts.get(item.scope or "")
if host is None or _section(item, "loop").get("question") is True:
return None
return blocked.get(host)
in_line = sorted(
(i for i in ready if block_of(i) is None),
key=lambda i: -_int(_section(i, "loop"), "first"),
)
rows = [
dev_row(item, links, place=f"{n} of {len(in_line)}")
for n, item in enumerate(in_line, 1)
]
for item in ready:
if (block := block_of(item)) is not None:
held = f"held: its build VM is blocked ({block.get('reason', '')})"
rows.append(dev_row(item, links, held=held))
return rows
# --- an agent --------------------------------------------------------------------
def groups(
kind: str, items: list[Item], blocks: list[dict[str, Any]], links: Links
) -> list[Group]:
known = dict(GROUPS[kind])
found: list[Group] = []
for key, title in GROUPS[kind]:
mine = [i for i in items if i.position == key]
if kind == DEV and key == "ready":
rows = dev_queue(mine, blocks, links)
else:
rows = [row_of(i, links) for i in mine]
found.append(Group(key, title, tuple(rows)))
other = [row_of(i, links) for i in items if i.position not in known]
if other:
found.append(Group("other", "Other", tuple(other)))
return found
def _open_runs(
reader: calls.CallReader, now: datetime
) -> tuple[list[OpenRun], list[OpenRun]]:
running: list[OpenRun] = []
interrupted: list[OpenRun] = []
for run in reader.runs():
if run.get("ended_at"):
continue
made = reader.calls(run=run["id"])
last = made[-1] if made else None
last_at = str(last["at"] if last else run["started_at"])
when = parse_time(last_at)
stale = when is None or now - when > STALE
found = OpenRun(run, len(made), last["id"] if last else None, last_at, stale)
(interrupted if stale else running).append(found)
interrupted.sort(key=lambda r: r.last_at, reverse=True)
return running, interrupted[:INTERRUPTED_SHOWN]
def latest(*stamps: str | None) -> str | None:
dated = [(parse_time(s), s) for s in stamps if s]
dated = [(d, s) for d, s in dated if d is not None]
return max(dated)[1] if dated else None
def agent(
source: Source,
state: StateReader,
reader: calls.CallReader | None,
links: Links,
now: datetime,
*,
record_note: str = "",
) -> AgentView:
items = state.open_items()
closed = state.closed_items(CLOSED_SHOWN)
blocks = state.vm_blocks()
view = AgentView(source, kind_of(items + closed), blocks=blocks)
active = state.active_item()
view.active = next((row_of(i, links) for i in items if i.id == active), None)
view.groups = groups(view.kind, items, blocks, links)
view.closed = [closed_row(i, links) for i in closed]
last_call = None
if reader is not None:
view.running, view.interrupted = _open_runs(reader, now)
found = reader.last_call()
last_call = str(found["at"]) if found else None
else:
view.record_note = record_note or "no call record: no runs to show"
view.last_activity = latest(
state.last_event_at(), last_call, *(i.updated_at for i in items + closed)
)
return view
+260
View File
@@ -0,0 +1,260 @@
"""Where the page reads from (X6): each agent's state store and the call
record beside it, and the runs the benchmark kept. Everything is opened
read-only (SQLite ``mode=ro``), one short connection per page, so the agent
stays the only writer of its state (L8) and nothing here can lock it."""
import contextlib
import json
import re
import sqlite3
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Self
from nisse_core.models import calls
from nisse_core.state import readonly
# The state store schema versions this page knows: the shared store's, and
# each agent's that adds tables of its own. The page reads only the shared
# tables, but says so for any version it doesn't know rather than guessing.
KNOWN_SCHEMAS = {4: "nisse-core (nisse-review)", 5: "nisse-dev"}
class UnknownSchema(RuntimeError):
pass
@dataclass(frozen=True)
class Source:
name: str # what the page calls it, safe in a URL
state_db: Path
kind: str # "agent" | "run" (a kept benchmark run)
@property
def calls_db(self) -> Path:
return calls.path_for(self.state_db)
def safe_name(text: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-") or "source"
def discover(agents: list[tuple[str, Path]], runs: list[Path]) -> list[Source]:
"""The agents named on the command line, then each run kept under the
runs folders (a folder holding a ``state.db``), newest first."""
found = [Source(safe_name(name), path, "agent") for name, path in agents]
for folder in runs:
kept = sorted(
(p for p in folder.glob("*/state.db") if p.is_file()),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
found += [
Source(safe_name(f"{folder.name}.{p.parent.name}"), p, "run") for p in kept
]
names: set[str] = set()
unique = []
for source in found:
name, n = source.name, 2
while name in names:
name, n = f"{source.name}-{n}", n + 1
names.add(name)
unique.append(Source(name, source.state_db, source.kind))
return unique
@dataclass(frozen=True)
class Item:
id: str
title: str
status: str
outcome: str | None
position: str
scope: str | None
opened_at: str
updated_at: str
closed_at: str | None
data: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Event:
id: int
ts: str
type: str
payload: dict[str, Any]
work_item: str | None
actor: str
def _loads(text: str | None) -> Any:
"""Item data and payloads, read tolerantly: whatever doesn't parse is kept
as text, so a field from a newer agent never breaks the page."""
if text is None:
return None
try:
return json.loads(text)
except (TypeError, ValueError):
return {"unreadable": text}
def connect(path: Path) -> sqlite3.Connection:
return readonly.connect(path)
class StateReader:
"""A state store, read-only. Use as a context manager: it closes at the
end of the page that opened it."""
def __init__(self, path: Path) -> None:
self.path = path
self._conn = connect(path)
try:
row = self._conn.execute(
"SELECT value FROM meta WHERE key = 'schema_version'"
).fetchone()
except sqlite3.Error as exc:
self._conn.close()
raise UnknownSchema(f"not a state store: {exc}") from exc
self.schema_version = int(row["value"]) if row else 0
if self.schema_version not in KNOWN_SCHEMAS:
self._conn.close()
known = ", ".join(str(v) for v in sorted(KNOWN_SCHEMAS))
raise UnknownSchema(
f"state store schema {self.schema_version}, not known to this "
f"page (it knows {known})"
)
def __enter__(self) -> Self:
return self
def __exit__(self, *exc: object) -> None:
self.close()
def close(self) -> None:
self._conn.close()
def active_item(self) -> str | None:
row = self._conn.execute(
"SELECT active_item FROM agent WHERE id = 1"
).fetchone()
return row["active_item"] if row else None
def items(self) -> list[Item]:
rows = self._conn.execute(
"SELECT * FROM work_items ORDER BY opened_at DESC, id"
).fetchall()
return [self._item(r) for r in rows]
def item(self, item_id: str) -> Item | None:
row = self._conn.execute(
"SELECT * FROM work_items WHERE id = ?", (item_id,)
).fetchone()
return self._item(row) if row else None
def _item(self, row: sqlite3.Row) -> Item:
data = _loads(row["data"])
return Item(
id=row["id"],
title=row["title"],
status=row["status"],
outcome=row["outcome"],
position=row["position"],
scope=row["scope"],
opened_at=row["opened_at"],
updated_at=row["updated_at"],
closed_at=row["closed_at"],
data=data if isinstance(data, dict) else {"unreadable": row["data"]},
)
def events(self, work_item: str | None) -> list[Event]:
"""One item's log, or the agent-level log (None)."""
if work_item is None:
rows = self._conn.execute(
"SELECT * FROM canonical_events WHERE work_item IS NULL ORDER BY id"
)
else:
rows = self._conn.execute(
"SELECT * FROM canonical_events WHERE work_item = ? ORDER BY id",
(work_item,),
)
return [self._event(r) for r in rows]
def event(self, event_id: int) -> Event | None:
row = self._conn.execute(
"SELECT * FROM canonical_events WHERE id = ?", (event_id,)
).fetchone()
return self._event(row) if row else None
def events_by_id(self, ids: list[int]) -> dict[int, Event]:
found: dict[int, Event] = {}
wanted = sorted({i for i in ids if i > 0})
for start in range(0, len(wanted), 500):
chunk = wanted[start : start + 500]
marks = ",".join("?" * len(chunk))
for row in self._conn.execute(
f"SELECT * FROM canonical_events WHERE id IN ({marks})", chunk
):
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]
def _event(self, row: sqlite3.Row) -> Event:
payload = _loads(row["payload"])
return Event(
id=row["id"],
ts=row["ts"],
type=row["type"],
payload=payload if isinstance(payload, dict) else {"value": payload},
work_item=row["work_item"],
actor=row["actor"],
)
@contextlib.contextmanager
def call_record(source: Source) -> Iterator[calls.CallReader | None]:
"""The source's call record, or None when there is none (an agent
started before M6.6a, or recording turned off). An unknown layout raises
``calls.UnknownLayout`` for the page to report."""
if not source.calls_db.is_file():
yield None
return
reader = calls.CallReader(source.calls_db)
try:
yield reader
finally:
reader.close()
+90
View File
@@ -0,0 +1,90 @@
/* nisse-ui: plain, readable, no external assets (X6). */
:root {
--bg: #fbfbfa; --fg: #1d1d1b; --muted: #6b6b66; --line: #deded8;
--panel: #f2f2ee; --accent: #2f5d8a; --bad: #a3322a; --warn: #8a5a00;
--new: #e3f0e0; --change: #fbe6c8; --flag: #f6d9d6;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161615; --fg: #e6e6e1; --muted: #9a9a93; --line: #34342f;
--panel: #20201e; --accent: #8fb8e0; --bad: #ec8a80; --warn: #e2b462;
--new: #233523; --change: #3d2f17; --flag: #432522;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 1100px; margin: 0 auto; padding: 0 16px 64px; }
a { color: var(--accent); }
h1 { font-size: 1.5rem; margin: 1.2rem 0 .4rem; }
h2 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; border-bottom: 1px solid var(--line); }
h3 { font-size: 1rem; margin: 1.2rem 0 .4rem; }
.top { border-bottom: 1px solid var(--line); background: var(--panel); }
.crumbs { max-width: 1100px; margin: 0 auto; padding: 8px 16px; }
.muted, .meta { color: var(--muted); }
.meta { font-size: .9rem; }
.bad { color: var(--bad); }
.path { font-family: var(--mono); font-size: .85rem; word-break: break-all; }
pre, code { font-family: var(--mono); font-size: .85rem; }
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 4px;
padding: 8px 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; margin: .4rem 0; }
table { border-collapse: collapse; width: 100%; margin: .6rem 0; font-size: .92rem; }
th, td { text-align: left; vertical-align: top; padding: 4px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; }
tr.active td { background: var(--new); }
table.facts th { width: 14%; white-space: nowrap; }
table.log td.text { font-family: var(--mono); font-size: .8rem; word-break: break-word; }
details { margin: .4rem 0; }
summary { cursor: pointer; }
.tag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px;
background: var(--panel); border: 1px solid var(--line); margin-right: 2px; }
.tag.warn { color: var(--warn); }
.flag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px;
background: var(--flag); color: var(--bad); margin-left: 4px; }
.badge { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px; }
.badge.new { background: var(--new); }
.badge.change { background: var(--change); }
.note { background: var(--panel); border-left: 3px solid var(--accent); padding: 6px 10px; }
.circle { background: var(--flag); color: var(--bad); padding: 4px 10px; border-radius: 4px; }
.timeline > * { margin: .5rem 0; }
.step { border-left: 3px solid var(--line); padding: 2px 10px; }
.step .text { white-space: pre-wrap; word-break: break-word; }
.step-user_message { border-left-color: var(--accent); }
.step-gate_opened, .step-fact { border-left-color: var(--warn); }
.run { border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; background: var(--bg); }
.run .run { margin: .4rem 0 .4rem 12px; }
.run > summary { padding: 4px 0; }
.turn { padding: 4px 0 4px 8px; border-top: 1px dashed var(--line); }
.turn.flagged { background: var(--flag); }
.turn-no { font-weight: 600; margin-right: 6px; }
.turn .reply { font-family: var(--mono); font-size: .82rem; color: var(--muted); word-break: break-word; }
.told { font-size: .82rem; color: var(--warn); }
.pager { display: flex; justify-content: space-between; margin-top: 1rem; }
.msg { border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; margin: .6rem 0; }
.msg.first-change { border-color: var(--warn); border-width: 2px; }
.msg-head { font-size: .88rem; }
.msg-head .role { font-weight: 700; text-transform: uppercase; letter-spacing: .03em; margin-right: 4px; }
.role-user { border-left: 4px solid var(--accent); }
.role-assistant { border-left: 4px solid #6a8f4e; }
.role-tool { border-left: 4px solid var(--muted); }
.role-system { border-left: 4px solid var(--warn); }
ul.from { margin: .3rem 0; padding-left: 1.2rem; font-size: .85rem; }
ul.from .text { font-family: var(--mono); font-size: .8rem; color: var(--muted); }
.toolcall { margin: .3rem 0; }
@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; }
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<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">
<nav class="crumbs"><a href="/">nisse-ui</a>{% block crumbs %}{% endblock %}</nav>
</header>
<main>
{% block main %}{% endblock %}
</main>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, item_href, event_link, flags %}
{% set c = view.call %}
{% block title %}Turn {{ view.number }}{% endblock %}
{% block crumbs %} › {{ src_link(source) }} › <a href="{{ item_href(source, c.work_item) }}">{{ c.work_item or "agent" }}</a> › {% if view.run %}{{ view.run.kind }} {{ view.run.role }} › {% endif %}turn {{ view.number }}{% endblock %}
{% block main %}
<nav class="pager">
{% if view.previous %}<a href="/s/{{ source.name|u }}/call/{{ view.previous }}">← turn {{ view.number - 1 }}</a>{% else %}<span class="muted">first turn</span>{% endif %}
{% if view.following %}<a href="/s/{{ source.name|u }}/call/{{ view.following }}">turn {{ view.number + 1 }} →</a>{% else %}<span class="muted">last turn</span>{% endif %}
</nav>
<h1>Turn {{ view.number }}{% if c.purpose %}: {{ c.purpose }}{% endif %} {{ flags(view.flags) }}</h1>
<table class="facts">
<tr><th>Role</th><td>{{ c.role or "—" }}</td><th>Time</th><td>{{ c.at|when }}, {{ "%.2f"|format(c.seconds) }}s</td></tr>
<tr><th>Model asked</th><td>{{ c.model }}</td><th>Answered by</th><td>{{ c.answered_by or "—" }}</td></tr>
<tr><th>Tokens</th><td>{{ c.tokens_in|num }} in · {{ c.tokens_out|num }} out</td><th>Prompt reuse (server)</th><td>{{ view.reused or "not reported" }}</td></tr>
<tr><th>Estimate</th><td>{{ c.estimate|num }} tokens (factor {{ c.factor }})</td><th>Route</th><td>{{ c.route or "—" }}, HTTP {{ c.status or "—" }}</td></tr>
<tr><th>Outcome</th><td{% if c.outcome != "answered" %} class="bad"{% endif %}>{{ c.outcome }}</td><th>Fitting</th><td>{% if c.trim %}{{ c.trim|pretty }}{% else %}nothing cut{% endif %}</td></tr>
</table>
{% if c.error %}<pre class="bad">{{ c.error }}</pre>{% endif %}
<h2>The reply</h2>
{% if view.reply.reasoning %}
<details open class="reasoning"><summary>Reasoning ({{ view.reply.reasoning|length|num }} characters)</summary><pre class="text">{{ view.reply.reasoning }}</pre></details>
{% endif %}
{% if view.reply.text %}<pre class="text">{{ view.reply.text }}</pre>{% endif %}
{% for tool, args in view.reply.calls %}
<div class="toolcall"><span class="tag">tool call</span> <b>{{ tool }}</b><pre>{{ args|pretty }}</pre></div>
{% endfor %}
{% if not view.reply.text and not view.reply.calls and not view.reply.reasoning %}<p class="muted">No reply recorded.</p>{% endif %}
{% if view.reply.finish %}<p class="meta">Finish reason: {{ view.reply.finish }}</p>{% endif %}
<h2>The request</h2>
{% if view.settings %}
<table class="facts">{% for k, v in view.settings.items() %}<tr><th>{{ k }}</th><td><code>{{ v|pretty }}</code></td></tr>{% endfor %}</table>
{% endif %}
{% if view.tools %}
<details><summary>{{ view.tools|length }} tools offered{% if view.tools_changed %} <span class="flag">changed since the turn before</span>{% endif %}</summary>
<p>{{ view.tools|join(", ") }}</p></details>
{% endif %}
{% if view.compared %}
<p class="note">Messages marked <span class="badge new">new</span> weren't in the turn before. <span class="badge change">first change</span> marks where this request first differs from the last one: prompt reuse can't go past it. The server reused {{ view.reused or "an unreported number of" }} tokens.</p>
{% endif %}
{% if view.match_note %}<p class="note">{{ view.match_note }}</p>{% endif %}
{% for m in view.messages %}
<section class="msg role-{{ m.role }}{% if m.first_change %} first-change{% endif %}" id="m{{ m.index }}">
<div class="msg-head">
<span class="role">{{ m.role }}</span>{% if m.index >= 0 %} <span class="muted">#{{ m.index }}</span>{% endif %}
{% if m.new %}<span class="badge new">new</span>{% endif %}
{% if m.first_change %}<span class="badge change">first change</span>{% endif %}
{% if m.tool_call_id %}<span class="muted">result of {{ m.tool_call_id }}</span>{% endif %}
<span class="muted">{{ m.text|length|num }} characters</span>
</div>
{% if m.role == "system" %}
<details><summary>System prompt</summary><pre class="text">{{ m.text }}</pre></details>
{% elif m.text %}<pre class="text">{{ m.text }}</pre>{% endif %}
{% for id, tool, args in m.tool_calls %}
<div class="toolcall"><span class="tag">tool call</span> <b>{{ tool }}</b> <span class="muted">{{ id }}</span><pre>{{ args|pretty }}</pre></div>
{% endfor %}
{% if m.events %}
<ul class="from">
{% for s in m.events %}<li>from {{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}<span class="tag{% if t != 'unchanged' %} warn{% endif %}">{{ t }}</span>{% endfor %}</li>{% endfor %}
</ul>
{% endif %}
</section>
{% endfor %}
{% if view.unmatched %}
<h3>Events sent{% if view.match_note %}, not matched to messages{% else %} but replaced by a compaction{% endif %}</h3>
<ul class="from">
{% for s in view.unmatched %}<li>{{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}<span class="tag{% if t != 'unchanged' %} warn{% endif %}">{{ t }}</span>{% endfor %}
<div class="text">{{ summary(s.type, s.payload if s.payload is mapping else {})|clip(300) }}</div></li>{% endfor %}
</ul>
{% endif %}
{% if view.compacted %}
<h3>Replaced by compaction</h3>
<p>{{ view.compacted|length }} events of the log stood behind a compaction in this request:
{% for e, comp in view.compacted %}{{ event_link(source, e) }}{% if comp %} → {{ event_link(source, comp) }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}</p>
{% endif %}
{% if view.dropped %}
<h3>Dropped by fitting</h3>
<ul class="from">
{% for id, e in view.dropped %}<li>{{ event_link(source, id) }}{% if e %} {{ e.type|replace("_", " ") }} by {{ e.actor }}: {{ summary(e.type, e.payload)|clip(200) }}{% else %} <span class="muted">(not in this store)</span>{% endif %}</li>{% endfor %}
</ul>
{% endif %}
<h2>Raw</h2>
<details><summary>Request JSON, exactly as sent ({{ view.raw_request|length|num }} characters)</summary><pre>{{ view.raw_request or "(not recorded)" }}</pre></details>
<details><summary>Response JSON, as received ({{ view.raw_response|length|num }} characters)</summary><pre>{{ view.raw_response or "(not recorded)" }}</pre></details>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, item_href %}
{% block title %}Event {{ event.id }}{% endblock %}
{% block crumbs %} › {{ src_link(source) }} › <a href="{{ item_href(source, event.work_item) }}">{{ event.work_item or "agent" }}</a> › event {{ event.id }}{% endblock %}
{% block main %}
<h1>Event #{{ event.id }}: {{ event.type|replace("_", " ") }}</h1>
<p class="meta">{{ event.ts|when }} · by {{ event.actor }} · work item {{ event.work_item or "(agent-level)" }}</p>
<pre class="text">{{ summary(event.type, event.payload) }}</pre>
<details><summary>Payload</summary><pre>{{ event.payload|pretty }}</pre></details>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, run_node, turn_row, step, event_link %}
{% set title = view.item.title if view.item else "Agent-level log" %}
{% block title %}{{ title }}{% endblock %}
{% block crumbs %} › {{ src_link(source) }} › {{ view.item.id if view.item else "agent" }}{% endblock %}
{% block main %}
<h1>{{ title }}</h1>
{% if view.item %}
<p class="meta">{{ view.item.id }} · {{ view.item.status }}{% if view.item.outcome %} ({{ view.item.outcome }}){% endif %}
· position {{ view.item.position }} · opened {{ view.item.opened_at|when }}{% if view.item.closed_at %} · closed {{ view.item.closed_at|when }}{% endif %}</p>
<details><summary>Item data</summary><pre>{{ view.item.data|pretty }}</pre></details>
{% endif %}
{% if view.record_note %}<p class="note">{{ view.record_note }}</p>
{% elif view.before_record %}<p class="note">Recording began {{ view.started_at|when }}: steps before then have no requests.</p>{% endif %}
<h2>Steps</h2>
<div class="timeline">
{% for kind, obj in view.timeline %}
{% if kind == "event" %}{{ step(source, obj) }}
{% elif kind == "run" %}{{ run_node(source, obj) }}
{% else %}{{ turn_row(source, obj) }}{% endif %}
{% else %}
<p class="muted">Nothing recorded yet.</p>
{% endfor %}
</div>
<h2>The log</h2>
<details{% if view.record_note %} open{% endif %}>
<summary>All {{ view.log|length }} events, as stored</summary>
<table class="log">
{% for e in view.log %}
<tr><td>{{ event_link(source, e.id) }}</td><td>{{ e.ts|when }}</td><td>{{ e.actor }}</td><td>{{ e.type }}</td><td class="text">{{ summary(e.type, e.payload)|clip(200) }}</td></tr>
{% endfor %}
</table>
</details>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% from "macros.html" import item_href %}
{% block title %}{{ source.name }}{% endblock %}
{% block crumbs %} › {{ source.name }}{% endblock %}
{% block main %}
<h1>{{ source.name }}</h1>
<p class="muted path">{{ source.state_db }}</p>
<p><a href="{{ item_href(source, none) }}">Agent-level log</a>: what happened outside any work item.</p>
<table>
<thead><tr><th>Work item</th><th>Title</th><th>Status</th><th>Position</th><th>Opened</th><th>Updated</th></tr></thead>
<tbody>
{% for item in items %}
<tr{% if item.id == active %} class="active"{% endif %}>
<td><a href="{{ item_href(source, item.id) }}">{{ item.id }}</a>{% if item.id == active %} <span class="tag">active</span>{% endif %}</td>
<td>{{ item.title }}</td>
<td>{{ item.status }}{% if item.outcome %} ({{ item.outcome }}){% endif %}</td>
<td>{{ item.position }}</td>
<td>{{ item.opened_at|when }}</td>
<td>{{ item.updated_at|when }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No work items.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,48 @@
{% macro src_link(source) %}<a href="/s/{{ source.name|u }}/">{{ source.name }}</a>{% endmacro %}
{% macro item_href(source, item_id) %}/s/{{ source.name|u }}/item/{{ (item_id if item_id is not none else AGENT_LEVEL)|u }}{% endmacro %}
{% macro event_link(source, id) %}{% if id > 0 %}<a href="/s/{{ source.name|u }}/event/{{ id }}">#{{ id }}</a>{% else %}<span class="muted">(no id)</span>{% endif %}{% endmacro %}
{% macro flags(names) %}{% for f in names %}<span class="flag">{{ f }}</span>{% endfor %}{% endmacro %}
{% macro turn_row(source, t) %}
<div class="turn{% if t.flags %} flagged{% endif %}">
<a class="turn-no" href="/s/{{ source.name|u }}/call/{{ t.call.id }}">turn {{ t.number }}</a>
<span class="meta">
{% if t.call.purpose %}<span class="tag">{{ t.call.purpose }}</span>{% endif %}
{{ t.call.role or "" }} · {{ t.call.model }}{% if t.call.answered_by and t.call.answered_by != t.call.model %} → {{ t.call.answered_by }}{% endif %}
· {{ "%.1f"|format(t.call.seconds) }}s · {{ t.call.tokens_in|num }} in / {{ t.call.tokens_out|num }} out
{% if t.reused %} · reused {{ t.reused }}{% endif %}
{% if t.call.outcome != "answered" %} · <span class="bad">{{ t.call.outcome }}</span>{% endif %}
</span>
{{ flags(t.flags) }}
{% for told in t.told %}<div class="told">orchestrator: {{ told|clip(160) }}</div>{% endfor %}
<div class="reply">{{ t.reply|reply_line|clip(240) }}</div>
</div>
{% endmacro %}
{% macro run_node(source, node) %}
<details class="run" open>
<summary>
<span class="tag">{{ node.run.kind }}</span> {{ node.run.role }}
· {{ node.run.outcome or "running" }}
· {{ node.run.started_at|when }} → {{ node.run.ended_at|when }}
· {{ node.turns|length }} turns
{% if node.run.note %}<span class="muted"> · {{ node.run.note|clip(160) }}</span>{% endif %}
</summary>
{% for c in node.circles %}
<p class="circle">Going in circles: {{ c.kind }} at turns {% for i in c.turns %}{{ i + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}: {{ c.detail }}</p>
{% endfor %}
{% for kind, obj in node.entries %}
{% if kind == "turn" %}{{ turn_row(source, obj) }}{% elif kind == "event" %}{{ step(source, obj) }}{% else %}{{ run_node(source, obj) }}{% endif %}
{% endfor %}
</details>
{% endmacro %}
{% macro step(source, e) %}
<div class="step step-{{ e.type }}">
<span class="meta">{{ event_link(source, e.id) }} {{ e.ts|when }} · {{ e.actor }} · <span class="tag">{{ e.type|replace("_", " ") }}</span></span>
<div class="text">{{ summary(e.type, e.payload)|clip(600) }}</div>
</div>
{% endmacro %}
@@ -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 %}
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block main %}
<h1>{{ title }}</h1>
<p class="note">{{ detail }}</p>
{% endblock %}
+543
View File
@@ -0,0 +1,543 @@
"""What the pages show, built from the two stores (X6): an item's
walkthrough (its steps, runs and turns) and one turn read against the log it
was made from. Pure reading: nothing here writes."""
import json
from dataclasses import dataclass, field
from typing import Any
from nisse_core.log.canonical import StoredEvent, from_payload
from nisse_core.loop.circles import Circle, Turn, find_circles
from nisse_core.models import calls
from nisse_core.models.render import OpenAIChatRenderer
from nisse_ui.sources import Event, Item, StateReader
# The log events shown as steps between runs: what people and the
# orchestrator said and decided. A model's own turns are shown as turns.
STEP_TYPES = {
"user_message",
"fact",
"gate_opened",
"plan_submitted",
"delegation_started",
"delegation_completed",
"compaction",
}
_TURN_TYPES = {"assistant_text", "tool_call", "tool_result", "action_verdict"}
# --- a model's reply ------------------------------------------------------------
@dataclass(frozen=True)
class Reply:
text: str = ""
reasoning: str = ""
calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent)
finish: str | None = None
def _text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
parts.append(part["text"])
else:
parts.append(json.dumps(part, ensure_ascii=False))
return "\n".join(parts)
return json.dumps(content, ensure_ascii=False)
def _arguments(value: Any) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def reply_of(response: Any) -> Reply:
"""The reply in a raw response, from either route: the OpenAI-shaped chat
route or Anthropic's messages route. Anything else is no reply."""
if not isinstance(response, dict):
return Reply()
choices = response.get("choices")
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
message = choices[0].get("message") or {}
reasoning = message.get("reasoning_content") or message.get("reasoning") or ""
tool_calls = []
for call in message.get("tool_calls") or []:
function = (call.get("function") or {}) if isinstance(call, dict) else {}
tool_calls.append(
(str(function.get("name", "?")), _arguments(function.get("arguments")))
)
return Reply(
text=_text(message.get("content")),
reasoning=_text(reasoning),
calls=tuple(tool_calls),
finish=choices[0].get("finish_reason"),
)
blocks = response.get("content")
if isinstance(blocks, list):
texts, thinking, tool_calls = [], [], []
for block in blocks:
if not isinstance(block, dict):
continue
match block.get("type"):
case "text":
texts.append(str(block.get("text", "")))
case "thinking":
thinking.append(str(block.get("thinking", "")))
case "tool_use":
tool_calls.append(
(str(block.get("name", "?")), _arguments(block.get("input")))
)
return Reply(
text="\n".join(texts),
reasoning="\n".join(thinking),
calls=tuple(tool_calls),
finish=response.get("stop_reason"),
)
return Reply()
# --- an item's walkthrough --------------------------------------------------------
@dataclass
class TurnRow:
"""One call, as a line of the walkthrough."""
call: dict[str, Any]
number: int # within its run, from 1
reply: Reply
told: tuple[str, ...] # corrections given since the turn before
flags: list[str] = field(default_factory=list)
@property
def reused(self) -> str | None:
return reuse(self.call)
@dataclass
class RunNode:
run: dict[str, Any]
entries: list[tuple[str, Any]] = field(default_factory=list) # turns, runs
turns: list[TurnRow] = field(default_factory=list)
circles: list[Circle] = field(default_factory=list)
@dataclass
class Walkthrough:
item: Item | None # None: the agent-level log
timeline: list[
tuple[str, Any]
] # ("event", Event) | ("run", RunNode) | ("turn", TurnRow)
log: list[Event]
record_note: str | None # why there are no requests, when there aren't
started_at: str | None
before_record: bool # some of this work happened before recording began
def reuse(call: dict[str, Any]) -> str | None:
"""Prompt reuse as the server counted it: cached tokens of the prompt."""
cached, fresh = call.get("cache_n"), call.get("prompt_n")
if isinstance(cached, int) and isinstance(fresh, int):
return f"{cached:,} of {cached + fresh:,}"
return None
def _corrections(
reader: calls.CallReader, call: dict[str, Any], before: set[int]
) -> tuple[str, ...]:
"""The orchestrator's statements first sent in this call: a correction
is appended to the log each time it's given, so it is new each time."""
told = []
for event_id, actor, kind, digest in call["sent"]:
if kind == "fact" and actor == "orchestrator" and event_id not in before:
try:
told.append(str(reader.blob(digest).get("text", "")))
except (KeyError, AttributeError):
continue
return tuple(told)
def _response(reader: calls.CallReader, call: dict[str, Any]) -> Any:
try:
return reader.response(call)
except KeyError:
return None
def walkthrough(
state: StateReader,
reader: calls.CallReader | None,
item_id: str | None,
*,
record_note: str | None = None,
) -> Walkthrough:
item = state.item(item_id) if item_id is not None else None
log = state.events(item_id)
steps = [e for e in log if e.type in STEP_TYPES or e.type not in _TURN_TYPES]
timeline: list[tuple[str, str, Any]] = []
started_at = None
if reader is None:
record_note = record_note or "No call record: requests weren't recorded."
else:
started_at = reader.started_at
nodes = {
r["id"]: RunNode(r) for r in reader.runs() if r["work_item"] == item_id
}
seen: dict[str | None, set[int]] = {}
numbers: dict[str | None, int] = {}
loose: list[TurnRow] = []
for call in reader.calls_for(item_id):
run = call["run"] if call["run"] in nodes else None
before = seen.setdefault(run, set())
numbers[run] = numbers.get(run, 0) + 1
row = TurnRow(
call=call,
number=numbers[run],
reply=reply_of(_response(reader, call)),
told=_corrections(reader, call, before),
)
before.update(entry[0] for entry in call["sent"])
if run is None:
loose.append(row)
else:
nodes[run].turns.append(row)
# A step taken while a run was going on (a correction, a fact) is
# shown inside the innermost run it happened during.
inside: dict[str, list[tuple[str, str, Any]]] = {}
for e in steps:
during = [n for n in nodes.values() if _during(n.run, e.ts)]
if during:
innermost = max(during, key=lambda n: n.run["started_at"])
inside.setdefault(innermost.run["id"], []).append(("event", e.ts, e))
else:
timeline.append(("event", e.ts, e))
steps = []
for node in nodes.values():
node.circles = find_circles(
[Turn(t.reply.text, t.reply.calls, t.told) for t in node.turns]
)
for circle in node.circles:
for i in circle.turns:
node.turns[i].flags.append(circle.kind)
children = [n for n in nodes.values() if n.run["parent"] == node.run["id"]]
entries = [("turn", t.call["at"], t) for t in node.turns]
entries += [("run", c.run["started_at"], c) for c in children]
entries += inside.get(node.run["id"], [])
node.entries = [(kind, obj) for kind, _, obj in sorted(entries, key=_when)]
for node in nodes.values():
if node.run["parent"] not in nodes:
timeline.append(("run", node.run["started_at"], node))
timeline += [("turn", t.call["at"], t) for t in loose]
timeline += [("event", e.ts, e) for e in steps]
ordered = [(kind, obj) for kind, _, obj in sorted(timeline, key=_when)]
before_record = reader is None or (
started_at is not None and bool(log) and log[0].ts < started_at
)
return Walkthrough(item, ordered, log, record_note, started_at, before_record)
def _during(run: dict[str, Any], ts: str) -> bool:
ended = run.get("ended_at")
return run["started_at"] <= ts and (ended is None or ts <= ended)
def _when(entry: tuple[str, str, Any]) -> str:
return entry[1] or ""
# --- one turn, read against its log -----------------------------------------------
@dataclass
class SentEvent:
id: int
actor: str
type: str
payload: Any
tags: list[str]
log: Event | None
@dataclass
class Message:
index: int
role: str
text: str
tool_calls: list[tuple[str, str, str]] # (id, tool, arguments)
tool_call_id: str | None
events: list[SentEvent] = field(default_factory=list)
new: bool = False # not in the turn before
first_change: bool = False # where this request first differs from the last
@dataclass
class TurnView:
call: dict[str, Any]
number: int
run: dict[str, Any] | None
previous: int | None
following: int | None
request: dict[str, Any] | None
response: Any
reply: Reply
messages: list[Message]
unmatched: list[SentEvent] # sent events not matched to a message
match_note: str | None
dropped: list[tuple[int, Event | None]]
compacted: list[tuple[int, int | None]]
tools: list[str]
tools_changed: bool
settings: dict[str, Any] # the request's other fields: tool_choice, limits
compared: bool # whether there was a turn before to compare with
flags: list[str]
raw_request: str
raw_response: str
@property
def reused(self) -> str | None:
return reuse(self.call)
def _fields(call: dict[str, Any] | None) -> dict[str, Any]:
if not call or not call.get("request"):
return {}
try:
return dict(json.loads(call["request"])["fields"])
except (ValueError, KeyError, TypeError):
return {}
def _tags(
entry: list[Any], payload: Any, log: Event | None, call: dict[str, Any]
) -> list[str]:
event_id, _, kind, _ = entry
tags: list[str] = []
cut = call["shortened"].get(str(event_id))
if event_id == 0:
tags.append("made for this call, not in the log")
elif kind == "compaction" and isinstance(payload, dict):
first, last = (payload.get("replaces") or [None, None])[:2]
tags.append(f"compaction of events {first}–{last}")
elif log is None:
tags.append("not in this state store")
elif log.payload == payload and log.type == kind:
tags.append("unchanged")
else:
# A role's view says another's work as a labelled statement (L6):
# a tool step narrated, a fact or someone's message labelled.
source = payload.get("source", "") if isinstance(payload, dict) else ""
viewed = kind == "user_message" and str(source).startswith("view:")
was = f"{log.type.replace('_', ' ')} by {log.actor}"
if viewed and log.type in ("tool_call", "tool_result"):
tags.append(f"narrated from {was}")
elif viewed:
tags.append(
"labelled for this role"
+ ("" if log.type == "user_message" else f" (a {was})")
)
elif log.type != kind:
tags.append(f"rewritten from {was}")
elif cut is None:
tags.append("changed for this call")
if cut is not None:
tags.append(f"shortened by {int(cut):,} characters")
return tags
def turn(state: StateReader, reader: calls.CallReader, call_id: int) -> TurnView | None:
call = reader.call(call_id)
if call is None:
return None
run = next((r for r in reader.runs() if r["id"] == call["run"]), None)
siblings = (
reader.calls(run=call["run"])
if call["run"]
else [c for c in reader.calls_for(call["work_item"]) if not c["run"]]
)
at = next(i for i, c in enumerate(siblings) if c["id"] == call_id)
before = siblings[at - 1] if at > 0 else None
after = siblings[at + 1] if at + 1 < len(siblings) else None
try:
request = reader.request(call)
except KeyError:
request = None
response = _response(reader, call)
reply = reply_of(response)
logged = state.events_by_id([entry[0] for entry in call["sent"]])
dropped = state.events_by_id(call["dropped"])
sent: list[SentEvent] = []
for entry in call["sent"]:
try:
payload = reader.blob(entry[3])
except KeyError:
payload = None
log = logged.get(entry[0])
sent.append(
SentEvent(
entry[0],
entry[1],
entry[2],
payload,
_tags(entry, payload, log, call),
log,
)
)
raw_messages = (request or {}).get("messages")
messages: list[Message] = []
if isinstance(raw_messages, list):
for i, m in enumerate(raw_messages):
m = m if isinstance(m, dict) else {"role": "?", "content": m}
calls_made = []
for c in m.get("tool_calls") or []:
function = (c.get("function") or {}) if isinstance(c, dict) else {}
calls_made.append(
(
str(c.get("id", "")) if isinstance(c, dict) else "",
str(function.get("name", "?")),
_arguments(function.get("arguments")),
)
)
if isinstance(m.get("content"), list): # Anthropic content blocks
for block in m["content"]:
if isinstance(block, dict) and block.get("type") == "tool_use":
calls_made.append(
(
str(block.get("id", "")),
str(block.get("name", "?")),
_arguments(block.get("input")),
)
)
messages.append(
Message(
index=i,
role=str(m.get("role", "?")),
text=_text(m.get("content")),
tool_calls=calls_made,
tool_call_id=m.get("tool_call_id"),
)
)
unmatched, note = _match(call, request, sent, messages)
mine, theirs = _fields(call), _fields(before)
compared = bool(theirs)
if compared:
old = theirs.get("messages") or []
new = mine.get("messages") or []
old_set = set(old)
for message, digest in zip(messages, new, strict=False):
message.new = digest not in old_set
change = next(
(i for i, (a, b) in enumerate(zip(old, new, strict=False)) if a != b),
min(len(old), len(new)),
)
if change < len(messages):
messages[change].first_change = True
tools_raw = (request or {}).get("tools") or []
tools = []
for t in tools_raw if isinstance(tools_raw, list) else []:
if isinstance(t, dict):
tools.append(
str((t.get("function") or {}).get("name") or t.get("name", "?"))
)
settings = {
k: v
for k, v in (request or {}).items()
if k not in ("messages", "tools", "system")
}
if isinstance(request, dict) and "system" in request:
messages.insert(
0,
Message(
index=-1,
role="system",
text=_text(request["system"]),
tool_calls=[],
tool_call_id=None,
),
)
flags = []
if run is not None:
node_turns = [
Turn(r.text, r.calls, ())
for r in (reply_of(_response(reader, c)) for c in siblings)
]
flags = sorted({c.kind for c in find_circles(node_turns) if at in c.turns})
return TurnView(
call=call,
number=at + 1,
run=run,
previous=before["id"] if before else None,
following=after["id"] if after else None,
request=request,
response=response,
reply=reply,
messages=messages,
unmatched=unmatched,
match_note=note,
dropped=[(i, dropped.get(i)) for i in call["dropped"]],
compacted=[(int(k), v) for k, v in call["compacted"].items()],
tools=tools,
tools_changed=compared and mine.get("tools") != theirs.get("tools"),
settings=settings,
compared=compared,
flags=flags,
raw_request=json.dumps(request, indent=2, ensure_ascii=False)
if request is not None
else "",
raw_response=json.dumps(response, indent=2, ensure_ascii=False)
if response is not None
else "",
)
def _match(
call: dict[str, Any],
request: dict[str, Any] | None,
sent: list[SentEvent],
messages: list[Message],
) -> tuple[list[SentEvent], str | None]:
"""Each sent event beside the message it went into, found with the same
renderer that built the request; when that can't be done exactly, the
events are listed apart, with why."""
if not sent:
return [], None
if request is None:
return sent, "The request wasn't recorded (the call failed before it)."
if call.get("route") != "chat":
return sent, "Messages-route requests are shown beside their events, unmatched."
try:
events = [
StoredEvent(id=s.id, ts="", event=from_payload(s.type, s.payload))
for s in sent
]
except (ValueError, TypeError):
return sent, "Some events didn't read as this version's event types."
first = messages[0] if messages else None
system = first.text if first is not None and first.role == "system" else None
renderer = OpenAIChatRenderer()
expected = renderer.render(events, None, "m", system)["messages"]
if len(expected) != len(messages):
return sent, (
f"Rendering the sent events gives {len(expected)} messages, the request "
f"has {len(messages)}; shown apart."
)
unmatched = []
for s, index in zip(sent, renderer.message_indexes(events, system), strict=True):
if index is None:
unmatched.append(s)
else:
messages[index].events.append(s)
return unmatched, None
+102
View File
@@ -0,0 +1,102 @@
"""The page and the agents are released together (X6): these fail in the PR
that would break the page, not in the operator's browser."""
import threading
from pathlib import Path
import httpx
import pytest
from ui_fixtures import recorded, store_at
from nisse_core.models import calls
from nisse_core.state.store import StateStore as CoreStore
from nisse_dev.state import StateStore as DevStore
from nisse_review.state import StateStore as ReviewStore
from nisse_ui import main as ui_main
from nisse_ui.app import App
from nisse_ui.sources import KNOWN_SCHEMAS, discover
@pytest.mark.parametrize("store", [CoreStore, DevStore, ReviewStore])
def test_every_agents_schema_is_one_the_page_knows(store: type[CoreStore]) -> None:
# An agent that moves its schema on must teach the page too.
assert store.SCHEMA_VERSION in KNOWN_SCHEMAS
@pytest.mark.parametrize("store_class", [DevStore, ReviewStore])
def test_each_agents_own_store_opens(
tmp_path: Path, store_class: type[CoreStore]
) -> None:
store = store_at(tmp_path, store_class)
store.open_work_item("i1", title="Agent item", position="NEW", cause="t")
store.close()
app = App(discover([("a", tmp_path / "state.db")], []))
assert app.handle("/s/a/").status == 200
assert "Agent item" in app.handle("/s/a/item/i1").body.decode()
def test_runs_the_benchmark_kept_are_sources(tmp_path: Path) -> None:
# As bench --keep-runs leaves them: <dir>/<task>-<id>/state.db + record.
for name in ("fix-bug-1a2b3c4d", "trace-bug-5e6f7a8b"):
_, store = recorded(tmp_path / "kept" / name)
store.close()
(tmp_path / "kept" / "not-a-run").mkdir()
sources = discover([], [tmp_path / "kept"])
assert sorted(s.name for s in sources) == [
"kept.fix-bug-1a2b3c4d",
"kept.trace-bug-5e6f7a8b",
]
assert all(s.kind == "run" and s.calls_db.is_file() for s in sources)
assert sources[0].calls_db == calls.path_for(sources[0].state_db)
def test_source_names_are_safe_and_unique(tmp_path: Path) -> None:
sources = discover(
[("dev agent/1", tmp_path / "a"), ("dev agent/1", tmp_path / "b")], []
)
assert [s.name for s in sources] == ["dev-agent-1", "dev-agent-1-2"]
def test_the_server_listens_on_this_machine_and_sends_the_headers(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a")
store.close()
server = ui_main.ThreadingHTTPServer(
("127.0.0.1", 0), ui_main.handler_for(App([source]))
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
port = server.server_address[1]
response = httpx.get(f"http://127.0.0.1:{port}/s/agent/item/w1")
assert response.status_code == 200
assert "Fix the parser" in response.text
assert "default-src 'none'" in response.headers["content-security-policy"]
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["server"].startswith("nisse-ui")
finally:
server.shutdown()
server.server_close()
def test_the_command_line_defaults_to_this_machine_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert ui_main._source("dev=/var/lib/nisse-dev/state.db") == (
"dev",
Path("/var/lib/nisse-dev/state.db"),
)
with pytest.raises(Exception, match="NAME=STATE_DB"):
ui_main._source("no-equals")
class Bound(Exception):
pass
def server(address: tuple[str, int], handler: object) -> None:
raise Bound(address)
monkeypatch.setattr(ui_main, "ThreadingHTTPServer", server)
with pytest.raises(Bound) as bound:
ui_main.main([])
assert bound.value.args[0] == ("127.0.0.1", 8765)
+460
View File
@@ -0,0 +1,460 @@
"""The overview (X6, M6.6c): what each agent is doing, what waits and why,
from items the agents' own code wrote."""
import hashlib
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from ui_fixtures import HOSTILE, recorded, store_at
from nisse_core.models import calls
from nisse_dev.loop import items as dev_items
from nisse_dev.loop.items import Loop
from nisse_dev.state import StateStore as DevStore
from nisse_review import state as review
from nisse_review.state import StateStore as ReviewStore
from nisse_ui import links, overview
from nisse_ui import main as ui_main
from nisse_ui.app import App
from nisse_ui.links import Links
from nisse_ui.sources import Source
LINKS = Links(
gitea="https://gitea.test",
room="!room:matrix.test",
repos={"web": "dev/web"},
hosts={"web": "root@build-web.test"},
)
def page(app: App, path: str = "/?refresh=0") -> str:
response = app.handle(path)
assert response.status == 200, response.body.decode()[:500]
return response.body.decode()
def new_store(root: Path, store_class: type[DevStore] | type[ReviewStore]):
root.mkdir(parents=True, exist_ok=True)
return store_at(root, store_class)
def dev_item(
store: DevStore, item_id: str, position: str, loop: Loop, scope: str = "web"
) -> None:
store.open_work_item(
item_id, title=f"Item {item_id}", scope=scope, position="ready", cause="t"
)
dev_items.save(store, item_id, position, loop, cause="t")
@pytest.fixture
def dev(tmp_path: Path) -> Source:
"""A nisse-dev store with an item in each place, written by its own
code: the queue, a VM block, waits, a dependency, and a closed item."""
store = new_store(tmp_path / "dev", DevStore)
dev_item(store, "web-1-aaaaaa", "ready", Loop(issue=1, thread="$t1"))
dev_item(store, "web-2-bbbbbb", "ready", Loop(issue=2, first=1))
dev_item(store, "web-3-cccccc", "ready", Loop(issue=3), scope="other")
dev_item(
store,
"web-4-dddddd",
"waiting",
Loop(
issue=4,
wait=dev_items.MERGE,
note="PR #9 is up for review",
pr=9,
pr_url="https://gitea.test/dev/web/pulls/9",
),
)
dev_item(
store,
"web-5-eeeeee",
"waiting",
Loop(wait=dev_items.CHAT, note="asked which branch"),
)
dev_item(
store,
"web-6-ffffff",
"blocked",
Loop(issue=6, blocked_by=("dev/lib#12", "dev/web#2")),
)
dev_item(store, "web-7-000000", "running", Loop(issue=7))
store.set_active("web-7-000000", cause="t")
dev_item(store, "web-8-111111", "closed", Loop(issue=8))
store.close_work_item("web-8-111111", outcome="merged", cause="t")
store.close()
return Source("dev", tmp_path / "dev" / "state.db", "agent")
def view(source: Source, links: Links = LINKS) -> overview.AgentView:
app = App([source], {source.name: links})
return app._agent(source, datetime.now(UTC))
def group(found: overview.AgentView, key: str) -> overview.Group:
return next(g for g in found.groups if g.key == key)
# --- nisse-dev --------------------------------------------------------------------
def test_dev_items_fall_into_their_places_with_their_reasons(dev: Source) -> None:
found = view(dev)
assert found.kind == overview.DEV
assert found.active is not None and found.active.item.id == "web-7-000000"
assert [r.item.id for r in group(found, "running").rows] == ["web-7-000000"]
parked = {r.item.id: r for r in group(found, "waiting").rows}
assert parked["web-4-dddddd"].what == "waiting for merge"
assert "PR #9 is up for review" in parked["web-4-dddddd"].details
assert parked["web-5-eeeeee"].what == "talking; your turn"
[blocked] = group(found, "blocked").rows
assert [(l.text, l.href) for l in blocked.links][1:] == [
("dev/lib#12", "https://gitea.test/dev/lib/issues/12"),
("dev/web#2", "https://gitea.test/dev/web/issues/2"),
]
assert [r.item.id for r in found.closed] == ["web-8-111111"]
assert found.closed[0].what == "merged"
def test_the_queue_is_in_the_order_the_agent_runs_it(dev: Source) -> None:
# The operator's "do #N next" first, then the oldest.
queue = group(view(dev), "ready").rows
assert [(r.item.id, r.mark) for r in queue] == [
("web-2-bbbbbb", "1 of 3"),
("web-1-aaaaaa", "2 of 3"),
("web-3-cccccc", "3 of 3"),
]
assert "you asked for it first" in queue[0].details
def test_an_item_whose_build_vm_is_blocked_is_held_out_of_line(
tmp_path: Path, dev: Source
) -> None:
store = store_at(dev.state_db.parent, DevStore)
store.block_vm(
"root@build-web.test",
cause="unreachable",
reason="no answer on port 22",
work_item="web-1-aaaaaa",
)
dev_item(store, "web-9-222222", "ready", Loop(question=True))
store.close()
found = view(dev)
queue = group(found, "ready").rows
assert [(r.item.id, r.held) for r in queue] == [
("web-3-cccccc", False), # another project's VM
("web-9-222222", False), # a question is answered anyway (L5)
("web-1-aaaaaa", True), # held, oldest first: not in line
("web-2-bbbbbb", True),
]
assert queue[0].mark == "1 of 2"
assert "no answer on port 22" in queue[2].mark
[block] = found.blocks
assert block["host"] == "root@build-web.test" and block["cause"] == "unreachable"
html = page(App([dev], {"dev": LINKS}))
assert "Blocked build VMs" in html and "held: its build VM is blocked" in html
# Without its config the page can't tell which VM a project builds on:
# nothing is held, and the block is still listed.
bare = group(view(dev, Links()), "ready").rows
assert not any(r.held for r in bare)
assert view(dev, Links()).blocks
def test_dev_links_go_to_the_issue_the_pr_and_the_thread(dev: Source) -> None:
rows = {r.item.id: r for g in view(dev).groups for r in g.rows}
first = {l.text: l.href for l in rows["web-1-aaaaaa"].links}
assert first == {
"#1": "https://gitea.test/dev/web/issues/1",
"thread": "https://matrix.to/#/!room:matrix.test/$t1",
}
merge = {l.text: l.href for l in rows["web-4-dddddd"].links}
assert merge["PR #9"] == "https://gitea.test/dev/web/pulls/9"
# A project the config doesn't name: the issue shows, unlinked.
assert [(l.text, l.href) for l in rows["web-3-cccccc"].links] == [("#3", "")]
def test_with_no_config_there_are_no_links_and_no_errors(dev: Source) -> None:
rows = [r for g in view(dev, Links()).groups for r in g.rows]
hrefs = [l.href for r in rows for l in r.links]
# Only the PR's own address, as Gitea gave it, links.
assert set(hrefs) == {"", "https://gitea.test/dev/web/pulls/9"}
html = page(App([dev]))
assert "Item web-1-aaaaaa" in html and 'href="https://matrix.to' not in html
# --- nisse-review -----------------------------------------------------------------
@pytest.fixture
def reviewer(tmp_path: Path) -> Source:
store = new_store(tmp_path / "rv", ReviewStore)
for number, position, pr in (
(1, review.READY, review.PR("dev/web", 1)),
(2, review.REVIEWED, review.PR("dev/web", 2, rounds=2, happy=True)),
(3, review.FAILED, review.PR("dev/web", 3, attempts=3, head="abc123def456")),
(4, review.CAPPED, review.PR("dev/web", 4, rounds=6)),
(5, review.WAITING, review.PR("dev/web", 5)),
(6, review.STOPPED, review.PR("dev/web", 6)),
):
store.open_work_item(
review.item_id("dev/web", number),
title=f"PR {number}",
position=position,
data=review.data(pr),
cause="t",
)
store.close()
return Source("review", tmp_path / "rv" / "state.db", "agent")
def test_review_prs_are_grouped_with_rounds_and_attempts(reviewer: Source) -> None:
found = view(reviewer, Links(gitea="https://gitea.test"))
assert found.kind == overview.REVIEW
by_key = {g.key: [r.item.id for r in g.rows] for g in found.groups}
assert by_key == {
"running": [],
"ready": ["dev/web#1"],
"waiting": ["dev/web#5"],
"reviewed": ["dev/web#2"],
"capped": ["dev/web#4"],
"failed": ["dev/web#3"],
"stopped": ["dev/web#6"],
}
rows = {r.item.id: r for g in found.groups for r in g.rows}
assert rows["dev/web#2"].details == ("2 rounds", "signed off")
assert "3 failed runs at this head" in rows["dev/web#3"].details
assert [(l.text, l.href) for l in rows["dev/web#1"].links] == [
("dev/web#1", "https://gitea.test/dev/web/pulls/1")
]
# --- what's running now -----------------------------------------------------------
def test_an_open_run_shows_as_running_and_a_stale_one_as_interrupted(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a") # its runs all ended
recorder = calls.CallRecorder(source.calls_db)
# A run under way: begun, with no end recorded.
recorder.run_started("open-run", None, "w1", "driver", "work")
recorder.close()
store.close()
now = datetime.now(UTC)
found = App([source])._agent(source, now)
[running] = found.running
assert running.run["work_item"] == "w1" and running.turns == 0
assert not found.interrupted
html = page(App([source], clock=lambda: now))
assert "running" in html and "Idle" not in html
later = App([source])._agent(source, now + timedelta(minutes=16))
assert not later.running and later.interrupted[0].run["work_item"] == "w1"
assert "maybe interrupted" in page(
App([source], clock=lambda: now + timedelta(minutes=16))
)
def test_with_no_run_open_the_agent_is_idle_since_its_last_activity(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a")
store.close()
found = App([source])._agent(source, datetime.now(UTC))
assert not found.running and not found.interrupted
assert found.last_activity is not None
assert "Idle since" in page(App([source]))
def test_an_agent_without_a_call_record_still_shows_its_items(dev: Source) -> None:
found = view(dev)
assert not found.running and "no call record" in found.record_note
assert found.last_activity is not None # from its items' changes
# --- the page ---------------------------------------------------------------------
def test_the_overview_reloads_itself_unless_asked_not_to(dev: Source) -> None:
app = App([dev])
assert '<meta http-equiv="refresh" content="60">' in page(app, "/")
assert 'http-equiv="refresh"' not in page(app, "/?refresh=0")
def test_kept_runs_are_listed_below_the_agents(tmp_path: Path, dev: Source) -> None:
_, store = recorded(tmp_path / "kept" / "fix-bug-1a2b")
store.close()
app = App(
[
dev,
Source(
"kept.fix-bug-1a2b",
tmp_path / "kept" / "fix-bug-1a2b" / "state.db",
"run",
),
]
)
html = page(app)
assert html.index("Item web-1-aaaaaa") < html.index("Recorded runs")
assert "/s/kept.fix-bug-1a2b/" in html
def test_unknown_positions_and_fields_still_show(tmp_path: Path) -> None:
store = new_store(tmp_path / "x", DevStore)
store.open_work_item(
"odd",
title="Odd item",
position="pondering",
data={
"loop": {
"issue": "not a number",
"wait": 3,
"blocked_by": "dev/x#1",
"new_field": [1],
}
},
cause="t",
)
store.open_work_item("bare", title="No data", position="ready", cause="t")
store.close()
source = Source("x", tmp_path / "x" / "state.db", "agent")
found = view(source)
[other] = [g for g in found.groups if g.key == "other"]
assert [(r.item.id, r.what) for r in other.rows] == [("odd", "pondering")]
assert "No data" in page(App([source]))
def test_an_unreadable_store_shows_as_a_problem_not_a_broken_page(
tmp_path: Path, dev: Source
) -> None:
bad = tmp_path / "bad.db"
with sqlite3.connect(bad) as conn:
conn.execute("CREATE TABLE meta (key TEXT, value TEXT)")
conn.execute("INSERT INTO meta VALUES ('schema_version', '99')")
html = page(App([Source("bad", bad, "agent"), dev]))
assert (
"Couldn't read its store: state store schema 99" in html
and "Item web-1-aaaaaa" in html
)
def test_stored_text_is_escaped_and_never_becomes_a_link(tmp_path: Path) -> None:
store = new_store(tmp_path / "h", DevStore)
dev_item(
store,
"h1",
"waiting",
Loop(
wait=HOSTILE,
note=HOSTILE,
pr=3,
pr_url="javascript:alert(1)",
thread='"><script>x</script>',
),
)
store.close()
source = Source("h", tmp_path / "h" / "state.db", "agent")
html = page(App([source], {"h": LINKS}))
assert "<script>" not in html and "javascript:" not in html
assert "&lt;script&gt;" in html
def test_browsing_the_overview_changes_no_file(tmp_path: Path, dev: Source) -> None:
source, store = recorded(tmp_path / "a")
store.close()
def files() -> dict[str, str]:
return {
str(p): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(tmp_path.rglob("*"))
if p.is_file()
}
before = files()
app = App([source, dev], {"dev": LINKS})
page(app, "/")
page(app, "/?refresh=0")
assert files() == before
# --- the config -------------------------------------------------------------------
CONFIG = """
state_db: /var/lib/nisse-dev/state.db
llm:
base_url: http://litellm.test:4000
token: sk-planted-secret-value
matrix:
homeserver: https://matrix.test
token: ${NISSE_MATRIX_TOKEN}
room_id: "!room:matrix.test"
gitea:
url: https://gitea.test/
token: gitea-planted-secret
projects:
web:
repo: dev/web
workspace: /root/work/web
build_host:
address: build-web.test
key: ~/.ssh/nisse-dev
push_token: push-planted-secret
lib:
repo: ${LIB_REPO}
build_host: {address: build-lib.test, user: builder, key: k}
"""
def test_links_come_from_four_settings_and_nothing_else(tmp_path: Path) -> None:
path = tmp_path / "nisse-dev.yaml"
path.write_text(CONFIG)
found = links.load(path)
assert found == Links(
gitea="https://gitea.test",
room="!room:matrix.test",
repos={"web": "dev/web"}, # a ${VAR} isn't resolved: unset
hosts={"web": "root@build-web.test", "lib": "builder@build-lib.test"},
)
held = repr(found)
for secret in ("planted", "sk-", "NISSE_MATRIX_TOKEN", "litellm"):
assert secret not in held
def test_a_gitea_address_that_isnt_http_gives_no_links(tmp_path: Path) -> None:
path = tmp_path / "c.yaml"
path.write_text("gitea: {url: 'javascript:alert(1)'}\n")
found = links.load(path)
assert found.gitea == "" and found.pull("dev/web", 1) == ""
assert links.web_address("HTTPS://x.test") == "HTTPS://x.test"
assert links.web_address(" data:text/html,x") == ""
def test_the_command_line_takes_a_config_for_a_named_source(
tmp_path: Path, dev: Source, monkeypatch: pytest.MonkeyPatch
) -> None:
path = tmp_path / "nisse-dev.yaml"
path.write_text(CONFIG)
built: dict[str, App] = {}
class Bound(Exception):
pass
def server(address: tuple[str, int], handler: object) -> None:
raise Bound
real_app = ui_main.App
def app(*args: object, **kwargs: object) -> App:
built["app"] = real_app(*args, **kwargs) # type: ignore[arg-type]
return built["app"]
monkeypatch.setattr(ui_main, "ThreadingHTTPServer", server)
monkeypatch.setattr(ui_main, "App", app)
with pytest.raises(Bound):
ui_main.main(["--source", f"dev={dev.state_db}", "--config", f"dev={path}"])
assert built["app"].links["dev"].repos == {"web": "dev/web"}
for bad in (["--config", f"nope={path}"], ["--config", f"dev={tmp_path}/none"]):
with pytest.raises(SystemExit):
ui_main.main(["--source", f"dev={dev.state_db}", *bad])
+316
View File
@@ -0,0 +1,316 @@
"""The walkthrough (X6, M6.6b): an item's steps, its runs' turns, and each
turn's request read against the log it was made from."""
import hashlib
import sqlite3
from pathlib import Path
import pytest
from ui_fixtures import TOKEN, circling, recorded
from nisse_core.log.canonical import UserMessage
from nisse_core.models import calls
from nisse_ui import walk
from nisse_ui.app import App
from nisse_ui.sources import Source, StateReader, call_record
@pytest.fixture
def agent(tmp_path: Path) -> Source:
source, store = recorded(tmp_path / "agent")
store.close()
return source
def page(app: App, path: str, status: int = 200) -> str:
response = app.handle(path)
assert response.status == status, response.body.decode()[:500]
return response.body.decode()
def view(source: Source, call_id: int) -> walk.TurnView:
with StateReader(source.state_db) as state, call_record(source) as reader:
assert reader is not None
found = walk.turn(state, reader, call_id)
assert found is not None
return found
def steps(source: Source, item: str | None) -> walk.Walkthrough:
with StateReader(source.state_db) as state, call_record(source) as reader:
return walk.walkthrough(state, reader, item)
# --- the way in -------------------------------------------------------------------
def test_the_sources_and_an_agents_items_are_listed(agent: Source) -> None:
app = App([agent])
home = page(app, "/")
assert "/s/agent/" in home and "Fix the parser" in home
items = page(app, "/s/agent/")
assert "Fix the parser" in items and "/s/agent/item/w1" in items
assert "/s/agent/item/-" in items # the agent-level log
# --- the walkthrough --------------------------------------------------------------
def test_an_items_steps_hold_its_runs_turns_and_what_the_orchestrator_said(
agent: Source,
) -> None:
found = steps(agent, "w1")
kinds = [kind for kind, _ in found.timeline]
assert kinds == ["event", "event", "run"] # the restatement, "go", the run
[run] = [obj for kind, obj in found.timeline if kind == "run"]
assert run.run["outcome"] == "wait"
assert [t.number for t in run.turns] == [1, 2, 3]
# The correction for the prose reply came during the run: it's shown
# inside it, between the turns it fell between.
inner = [kind for kind, _ in run.entries]
assert inner == ["turn", "turn", "event", "turn"]
assert any("prose" in told for told in run.turns[2].told)
assert run.turns[0].reply.calls == (("read_file", '{"path": "parser.py"}'),)
assert run.turns[1].reused == "280 of 340"
html = page(App([agent]), "/s/agent/item/w1")
assert "turn 3" in html and "reused 330 of 350" in html
def test_agent_level_calls_have_their_own_log(agent: Source) -> None:
found = steps(agent, None)
[run] = [obj for kind, obj in found.timeline if kind == "run"]
assert (run.run["kind"], run.run["role"]) == ("classification", "classifier")
assert "harmless" in page(App([agent]), "/s/agent/item/-")
def test_work_from_before_recording_has_its_steps_but_no_requests(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a")
# The agent restarts: a fresh, empty record; the log persists.
calls.CallRecorder(source.calls_db).close()
store.close()
found = steps(source, "w1")
assert found.before_record
# The restatement, "go" and the correction: steps, with no runs to hold them.
assert [kind for kind, _ in found.timeline] == ["event", "event", "event"]
html = page(App([source]), "/s/agent/item/w1")
assert "Recording began" in html
assert "All 8 events" in html # the log is all still there
def test_without_a_call_record_the_log_is_shown(tmp_path: Path) -> None:
source, store = recorded(tmp_path / "a")
store.close()
source.calls_db.unlink()
html = page(App([source]), "/s/agent/item/w1")
assert "No call record" in html and "All 8 events" in html
page(App([source]), "/s/agent/call/1", 404)
def test_a_run_going_in_circles_is_flagged(tmp_path: Path) -> None:
source = circling(tmp_path / "c")
found = steps(source, "pr-7")
[run] = [obj for kind, obj in found.timeline if kind == "run"]
[circle] = run.circles
assert (circle.kind, circle.turns) == ("repeated reply", (0, 1, 2, 3))
assert run.turns[0].flags == ["repeated reply"] and not run.turns[4].flags
assert "Going in circles: repeated reply at turns 1, 2, 3, 4" in page(
App([source]), "/s/circling/item/pr-7"
)
assert view(source, 2).flags == ["repeated reply"]
# --- one turn ---------------------------------------------------------------------
def test_each_message_is_shown_beside_the_log_events_it_came_from(
agent: Source,
) -> None:
turn = view(agent, 2)
assert turn.match_note is None and turn.unmatched == []
roles = [m.role for m in turn.messages]
assert roles == ["system", "user", "assistant", "tool"]
# The restatement and "go" fold into one user message.
assert [s.id for s in turn.messages[1].events] == [1, 2]
assert [s.id for s in turn.messages[2].events] == [3]
assert [s.id for s in turn.messages[3].events] == [4]
assert all(s.tags == ["unchanged"] for m in turn.messages for s in m.events)
assert calls.NOTICE in turn.messages[0].text
def test_what_changed_since_the_turn_before_is_marked(agent: Source) -> None:
first, second = view(agent, 1), view(agent, 2)
assert not first.compared and not any(m.new for m in first.messages)
assert second.compared
assert [m.new for m in second.messages] == [False, False, True, True]
assert [m.first_change for m in second.messages] == [False, False, True, False]
assert not second.tools_changed
assert (second.previous, second.following) == (1, 3)
def test_the_turn_page_shows_the_reply_reasoning_and_raw_json(agent: Source) -> None:
html = page(App([agent]), "/s/agent/call/1")
assert "First, read the parser." in html # the reasoning
assert "Request JSON, exactly as sent" in html
assert "local-backing" in html # the model that answered
assert "reused" not in html or "0 of 300" in html
def test_how_rendering_changed_an_event_is_named() -> None:
call = {"shortened": {"5": 1200}}
logged = walk.Event(5, "t", "tool_result", {"result": "long"}, "w", "driver")
sent = {"result": "lo"}
assert walk._tags([5, "driver", "tool_result", "h"], sent, logged, call) == [
"shortened by 1,200 characters"
]
told = walk.Event(6, "t", "tool_call", {"tool": "x"}, "w", "helper")
narrated = {"text": "x → ok", "source": "view:helper"}
assert walk._tags(
[6, "helper", "user_message", "h"], narrated, told, {"shortened": {}}
) == ["narrated from tool call by helper"]
fact = walk.Event(8, "t", "fact", {"text": "agreed"}, "w", "orchestrator")
as_said = {"text": "[orchestrator] agreed", "source": "view:orchestrator"}
assert walk._tags(
[8, "orchestrator", "user_message", "h"], as_said, fact, {"shortened": {}}
) == ["labelled for this role (a fact by orchestrator)"]
said = walk.Event(
7, "t", "user_message", {"text": "hi", "source": "m"}, "w", "operator"
)
labelled = {"text": "[operator, matrix] hi", "source": "view:operator"}
assert walk._tags(
[7, "operator", "user_message", "h"], labelled, said, {"shortened": {}}
) == ["labelled for this role"]
assert walk._tags(
[0, "harness", "fact", "h"], {"text": "x"}, None, {"shortened": {}}
) == ["made for this call, not in the log"]
assert walk._tags(
[9, "driver", "compaction", "h"], {"replaces": [2, 8]}, None, {"shortened": {}}
) == ["compaction of events 2–8"]
def test_a_reply_reads_from_either_route() -> None:
chat = {
"choices": [
{
"message": {
"content": "ok",
"reasoning_content": "hm",
"tool_calls": [{"function": {"name": "f", "arguments": "{}"}}],
},
"finish_reason": "tool_calls",
}
]
}
assert walk.reply_of(chat) == walk.Reply("ok", "hm", (("f", "{}"),), "tool_calls")
messages = {
"content": [
{"type": "thinking", "thinking": "hm"},
{"type": "text", "text": "ok"},
{"type": "tool_use", "name": "f", "input": {"a": 1}},
],
"stop_reason": "tool_use",
}
assert walk.reply_of(messages) == walk.Reply(
"ok", "hm", (("f", '{"a": 1}'),), "tool_use"
)
assert walk.reply_of("garbage") == walk.Reply()
# --- untrusted text and read-only -------------------------------------------------
def test_everything_shown_is_escaped_and_no_script_may_run(agent: Source) -> None:
app = App([agent])
for path in (
"/s/agent/item/w1",
"/s/agent/call/2",
"/s/agent/call/3",
"/s/agent/event/4",
):
response = app.handle(path)
html = response.body.decode()
assert "<script>alert" not in html
assert "&lt;script&gt;alert" in html
assert "&lt;/pre&gt;&lt;/details&gt;" in html # can't close the page's own
csp = response.headers["Content-Security-Policy"]
assert "default-src 'none'" in csp and "script-src" not in csp
assert TOKEN not in html
def _digests(root: Path) -> dict[str, str]:
"""Each file's content, except SQLite's shared-memory index, which
readers of a live WAL database take part in by design."""
return {
p.name: ""
if p.name.endswith("-shm")
else hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(root.iterdir())
if p.is_file()
}
def test_browsing_reads_only_and_leaves_no_lock_while_the_agent_writes(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a") # the store stays open, as live
before = _digests(tmp_path / "a")
app = App([source])
for path in (
"/",
"/s/agent/",
"/s/agent/item/w1",
"/s/agent/item/-",
"/s/agent/call/1",
"/s/agent/call/2",
"/s/agent/event/1",
):
page(app, path)
# No file changed, and none was created: the closed call record has no
# -wal or -shm, and opening it made none.
assert _digests(tmp_path / "a") == before
assert not Path(f"{source.calls_db}-wal").exists()
# The agent can still write at once: nothing here holds a lock.
store.transcript("w1", "operator").append(UserMessage(text="more", source="m:2"))
store.close()
assert "more" in page(app, "/s/agent/item/w1")
# --- versions -----------------------------------------------------------------------
def test_a_store_of_an_unknown_version_is_named_not_guessed(agent: Source) -> None:
with sqlite3.connect(agent.state_db) as conn:
conn.execute("UPDATE meta SET value = '99' WHERE key = 'schema_version'")
html = page(App([agent]), "/s/agent/item/w1", 409)
assert "schema 99, not known to this page" in html
assert "schema 99" in page(App([agent]), "/")
def test_a_call_record_of_an_unknown_layout_leaves_the_log_readable(
agent: Source,
) -> None:
with sqlite3.connect(agent.calls_db) as conn:
conn.execute("UPDATE meta SET value = '2' WHERE key = 'layout_version'")
html = page(App([agent]), "/s/agent/item/w1")
assert "call record layout 2" in html and "All 8 events" in html
page(App([agent]), "/s/agent/call/1", 409)
def test_item_data_that_doesnt_parse_still_renders(agent: Source) -> None:
with sqlite3.connect(agent.state_db) as conn:
conn.execute("UPDATE work_items SET data = 'not json', position = 'NEW_STATE'")
html = page(App([agent]), "/s/agent/item/w1")
assert "not json" in html and "NEW_STATE" in html
assert "NEW_STATE" in page(App([agent]), "/s/agent/")
def test_unknown_paths_are_not_found(agent: Source) -> None:
app = App([agent])
page(app, "/s/nobody/", 404)
page(app, "/s/agent/item/nope", 404)
page(app, "/s/agent/call/999", 404)
page(app, "/s/agent/event/999", 404)
page(app, "/s/agent/call/x", 404)
page(app, "/etc/passwd", 404)
assert app.handle("/static/style.css").content_type.startswith("text/css")
+149
View File
@@ -0,0 +1,149 @@
"""Stores written by the agents' own code, for the page to read (X6): a real
state store and a real call record from a driver run against a fake
LiteLLM, so a change in nisse-core that breaks the page fails here."""
import io
import json
from pathlib import Path
from typing import Any
import httpx
from nisse_core.log.canonical import Fact, UserMessage
from nisse_core.loop.driver import Driver
from nisse_core.loop.tools import tool
from nisse_core.models import calls
from nisse_core.models.context import BudgetedClient
from nisse_core.models.llm import LiteLLMClient
from nisse_core.oplog import OpLog
from nisse_core.state.store import StateStore
from nisse_ui.sources import Source
TOKEN = "sk-secret-litellm-token"
HOSTILE = "<script>alert('x')</script></pre></details>"
READ = tool("read_file", "read a file", path="the file")
POST = tool("post_comment", "post a comment", body="the comment")
FINISH = tool("finish", "finish the work", summary="what was done")
def reply(
content: str = "",
*,
call: tuple[str, dict[str, Any]] | None = None,
reasoning: str = "",
timings: dict[str, int] | None = None,
) -> dict[str, Any]:
message: dict[str, Any] = {"role": "assistant", "content": content}
if reasoning:
message["reasoning_content"] = reasoning
if call:
message["tool_calls"] = [
{
"id": f"x{abs(hash(json.dumps(call)))}",
"type": "function",
"function": {"name": call[0], "arguments": json.dumps(call[1])},
}
]
data: dict[str, Any] = {
"model": "local-backing",
"choices": [{"message": message, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 120, "completion_tokens": 7},
}
if timings is not None:
data["timings"] = timings
return data
class Server:
"""A fake LiteLLM answering each request in turn."""
def __init__(self, *answers: dict[str, Any]) -> None:
self.answers = list(answers)
def __call__(self, request: httpx.Request) -> httpx.Response:
data = self.answers.pop(0) if self.answers else reply("ok")
return httpx.Response(200, json=data)
def client(server: Server, recorder: calls.CallRecorder) -> BudgetedClient:
inner = LiteLLMClient(
"https://litellm.test", TOKEN, transport=httpx.MockTransport(server)
)
return BudgetedClient(inner, lambda m: (32768, 1024), call_recorder=recorder)
def store_at(root: Path, store_class: type[StateStore] = StateStore) -> StateStore:
return store_class(root / "state.db", oplog=OpLog("test", io.StringIO()))
def recorded(root: Path) -> tuple[Source, StateStore]:
"""One work item, one driver run of three turns: a tool call whose
result is hostile text, a prose reply the driver corrects, then the
finish. And one agent-level call. The store stays open, as a live
agent's does."""
root.mkdir(parents=True, exist_ok=True)
recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
store = store_at(root)
store.open_work_item("w1", title="Fix the parser", position="RUNNING", cause="t")
store.transcript("w1", "orchestrator").append(
Fact(text="The operator agreed to this restatement: fix the parser.")
)
store.transcript("w1", "operator").append(UserMessage(text="go", source="m:1"))
server = Server(
reply(
call=("read_file", {"path": "parser.py"}),
reasoning="First, read the parser.",
timings={"cache_n": 0, "prompt_n": 300},
),
reply(
"I believe the parser is fine now.",
timings={"cache_n": 280, "prompt_n": 60},
),
reply(
call=("finish", {"summary": "fixed"}),
timings={"cache_n": 330, "prompt_n": 20},
),
)
Driver(client(server, recorder), "local").run(
store.transcript("w1", "driver"),
system="You are the driver.",
tools=[READ, FINISH],
waiting=("finish",),
workspace=None,
handlers={"read_file": lambda args: (f"contents {HOSTILE}", True)},
)
store.transcript(None, "operator").append(UserMessage(text="hello", source="m:9"))
with calls.run(recorder, work_item=None, role="classifier", kind="classification"):
client(Server(reply("harmless")), recorder).complete(
store.transcript(None, "operator").read(), None, "local", system="Classify."
)
recorder.close()
return Source("agent", root / "state.db", "agent"), store
def circling(root: Path) -> Source:
"""A run that posts the same comment over and over, as Flash-Next did
on 2026-10-24, until the finish."""
root.mkdir(parents=True, exist_ok=True)
recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
store = store_at(root)
store.open_work_item("pr-7", title="Review #7", position="RUNNING", cause="t")
store.transcript("pr-7", "operator").append(UserMessage(text="review", source="m"))
answers = [
reply(call=("post_comment", {"body": f"Duplicate, please disregard ({n})."}))
for n in range(4)
]
Driver(
client(Server(*answers, reply(call=("finish", {"summary": "x"}))), recorder),
"local",
).run(
store.transcript("pr-7", "driver"),
system="You review.",
tools=[POST, FINISH],
waiting=("finish",),
workspace=None,
handlers={"post_comment": lambda args: ("posted", True)},
)
recorder.close()
store.close()
return Source("circling", root / "state.db", "agent")
+3 -2
View File
@@ -1,5 +1,5 @@
[tool.uv.workspace]
members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review"]
members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review", "nisse-ui"]
[dependency-groups]
dev = [
@@ -14,6 +14,7 @@ testpaths = [
"nisse-dev/tests",
"nisse-ops/tests",
"nisse-review/tests",
"nisse-ui/tests",
"tests",
]
# The seed is a test project's own code, pushed to each run's repository.
@@ -33,4 +34,4 @@ extend-exclude = ["tests/integration/seed"] # a test project's own code
extend-select = ["I"]
[tool.ruff.lint.isort]
known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review"]
known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review", "nisse_ui"]
Generated
+143
View File
@@ -8,6 +8,7 @@ members = [
"nisse-dev",
"nisse-ops",
"nisse-review",
"nisse-ui",
]
[manifest.dependency-groups]
@@ -112,6 +113,131 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
{ url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
{ url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
{ url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
{ url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
{ url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
{ url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
{ url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" },
{ url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" },
{ url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" },
{ url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" },
{ url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" },
{ url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" },
{ url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" },
{ url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" },
{ url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" },
{ url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" },
{ url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" },
{ url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
{ url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
{ url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
{ url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
{ url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
{ url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
{ url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
{ url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
{ url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
{ url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
{ url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
{ url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
{ url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
{ url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
{ url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
{ url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
{ url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
{ url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
{ url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
{ url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
{ url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
{ url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
{ url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
{ url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
{ url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
{ url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
{ url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
{ url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
{ url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
{ url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
{ url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
{ url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
{ url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
{ url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
{ url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
{ url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
{ url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
{ url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
{ url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
{ url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
]
[[package]]
name = "nisse-core"
version = "0.1.0"
@@ -166,6 +292,23 @@ dependencies = [
[package.metadata]
requires-dist = [{ name = "nisse-core", editable = "nisse-core" }]
[[package]]
name = "nisse-ui"
version = "0.1.0"
source = { editable = "nisse-ui" }
dependencies = [
{ name = "jinja2" },
{ name = "nisse-core" },
{ name = "pyyaml" },
]
[package.metadata]
requires-dist = [
{ name = "jinja2", specifier = ">=3.1" },
{ name = "nisse-core", editable = "nisse-core" },
{ name = "pyyaml", specifier = ">=6" },
]
[[package]]
name = "packaging"
version = "26.3"