Merge feature/workbench-w4-resources into unify/skills-20261112
Conflicts combined W1 (Inbox leaves the Stream for Tasks, Waits on Operator) with W4 (Resources tab): five tabs with Resources, Stream keeps only the Firewall, the inbox hash keeps its redirect; index.html.j2 includes both diffs.js and resources.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
515fe61009
21 files changed
+1937
-18
No files matched your search
Executable
+590
@@ -0,0 +1,590 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
sample: append one row to <state>/samples.jsonl: {t, disks: [{mount,
|
||||
used, total}], cpu, ram, swap, load, gpus: [{name, util, vram_used,
|
||||
vram_total} | {name, asleep: true}], queue: {waiting, active}}; t in
|
||||
epoch seconds, used, total and vram in bytes, cpu, ram, swap and util
|
||||
in percent, load the 1-minute load average, a GPU named by its PCI
|
||||
address. cpu is the busy share of the /proc/stat ticks since those
|
||||
the run before kept in <state>/cpu.json as [t, idle, total], None on
|
||||
the first run and after a gap of more than 15 minutes. An NVIDIA GPU
|
||||
whose PCI runtime_status is not active is recorded asleep, and
|
||||
nvidia-smi is not run for it; an awake one nvidia-smi gives no
|
||||
reading for (no driver, a driver mismatch) keeps its name with util
|
||||
and vram None. In the first minute of a 15-minute bucket, rows older
|
||||
than 7 days fold into 15-minute means and means older than 90 days
|
||||
are dropped.
|
||||
tokens: add the input, output, cache-read and cache-write tokens of the
|
||||
usage fields in the Claude Code transcripts under --transcripts since
|
||||
each file's byte offset to <state>/tokens.jsonl as {t, agent, model,
|
||||
input, output, cache_read, cache_write}, t the hour; agent is the
|
||||
name the council claim of the session carries, else the session id's
|
||||
first 8 characters. A message counts once by its message id and
|
||||
request id, also when written over several lines or copied into a
|
||||
fork's transcript. <state>/offsets.json holds {files: {path: offset},
|
||||
seen: {hour: [key digest]}}, seen keeping the hours this run read and
|
||||
the last two, and is replaced only when it changes. In the first
|
||||
minute of an hour, rows of the same hour, agent and model merge and
|
||||
rows older than 90 days are dropped.
|
||||
--state: the directory of these files, the unit passes
|
||||
~/.local/state/agents-council/resources.
|
||||
--council: the council directory with queue.json and the claims.
|
||||
--transcripts (tokens): Claude Code's projects directory.
|
||||
--proc, --sys (sample): where /proc and /sys are mounted.
|
||||
|
||||
Returns:
|
||||
Runs on the host every minute through kvb-orchestrator-resources.timer,
|
||||
because the hub's container sees neither the host's disks, memory and
|
||||
GPUs nor its transcripts' offsets; nothing here calls a language model.
|
||||
The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
INTERVAL = 60
|
||||
BUCKET = 900
|
||||
HOUR = 3600
|
||||
FINE = 7 * 86400
|
||||
COARSE = 90 * 86400
|
||||
COMMAND_SECONDS = 10
|
||||
KEY_BYTES = 9
|
||||
KIB = 1 << 10
|
||||
MIB = 1 << 20
|
||||
JSON_BYTES = 1 << 24
|
||||
BUDGET = 64 << 20
|
||||
NVIDIA = "0x10de"
|
||||
DISPLAY = "0x03"
|
||||
NVIDIA_QUERY = "pci.bus_id,utilization.gpu,memory.used,memory.total"
|
||||
UNREAD = {"util": None, "vram_used": None, "vram_total": None}
|
||||
COUNTS = (("input", "input_tokens"), ("output", "output_tokens"), ("cache_read", "cache_read_input_tokens"), ("cache_write", "cache_creation_input_tokens"))
|
||||
NAMES = tuple(name for name, _ in COUNTS)
|
||||
HOST_DIGITS = (("cpu", 1), ("ram", 1), ("swap", 1), ("load", 2))
|
||||
CLAIMS = ("*/claim.json", "*/*/claim.json")
|
||||
SAMPLES = "samples.jsonl"
|
||||
TOKENS = "tokens.jsonl"
|
||||
OFFSETS = "offsets.json"
|
||||
TICKS = "cpu.json"
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def read_json(path: Path) -> object:
|
||||
"""Returns:
|
||||
The parsed JSON of path, read without following a symlink and only
|
||||
from a regular file of at most JSON_BYTES; None when that fails.
|
||||
"""
|
||||
try:
|
||||
with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK), "rb") as handle:
|
||||
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
||||
return None
|
||||
data = handle.read(JSON_BYTES + 1)
|
||||
return None if len(data) > JSON_BYTES else json.loads(data.decode("utf-8"))
|
||||
except (OSError, ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
def number(text: str) -> float | None:
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def percent(part: float, whole: float | None) -> float | None:
|
||||
return round(100 * part / whole, 1) if whole else None
|
||||
|
||||
|
||||
def command(argv: list[str]) -> str | None:
|
||||
if shutil.which(argv[0]) is None:
|
||||
return None
|
||||
try:
|
||||
done = subprocess.run(argv, capture_output=True, text=True, timeout=COMMAND_SECONDS, check=False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return done.stdout if done.returncode == 0 else None
|
||||
|
||||
|
||||
def parse_df(text: str) -> list[dict]:
|
||||
"""Returns:
|
||||
One {mount, used, total} in bytes per block device in the output of
|
||||
df -P -k, at the first mount df lists for it; loop devices and
|
||||
filesystems without a device are left out.
|
||||
"""
|
||||
found, seen = [], set()
|
||||
for line in text.splitlines()[1:]:
|
||||
parts = line.split(None, 5)
|
||||
if len(parts) < 6 or not parts[0].startswith("/dev/") or parts[0].startswith("/dev/loop") or parts[0] in seen:
|
||||
continue
|
||||
total, used = number(parts[1]), number(parts[2])
|
||||
if total is None or used is None:
|
||||
continue
|
||||
seen.add(parts[0])
|
||||
found.append({"mount": parts[5], "used": int(used) * KIB, "total": int(total) * KIB})
|
||||
return found
|
||||
|
||||
|
||||
def parse_meminfo(text: str) -> dict[str, int]:
|
||||
values = {}
|
||||
for line in text.splitlines():
|
||||
key, _, rest = line.partition(":")
|
||||
fields = rest.split()
|
||||
if fields and fields[0].isdigit():
|
||||
values[key.strip()] = int(fields[0])
|
||||
return values
|
||||
|
||||
|
||||
def memory(values: dict[str, int]) -> tuple[float | None, float | None]:
|
||||
"""Returns:
|
||||
RAM in use ((MemTotal - MemAvailable) / MemTotal) and swap in use in
|
||||
percent; swap is 0 without a swap device, None when unknown.
|
||||
"""
|
||||
total, available = values.get("MemTotal"), values.get("MemAvailable")
|
||||
ram = percent(total - available, total) if total and available is not None else None
|
||||
swap_total, swap_free = values.get("SwapTotal"), values.get("SwapFree")
|
||||
if swap_total == 0:
|
||||
return ram, 0.0
|
||||
return ram, percent(swap_total - swap_free, swap_total) if swap_total and swap_free is not None else None
|
||||
|
||||
|
||||
def cpu_ticks(text: str) -> tuple[int, int] | None:
|
||||
"""Returns:
|
||||
(idle, total) ticks of the summary line of /proc/stat: idle counts
|
||||
idle and iowait, total the first eight fields, which already hold
|
||||
guest time.
|
||||
"""
|
||||
fields = text.split("\n", 1)[0].split()
|
||||
if len(fields) < 6 or fields[0] != "cpu" or not all(field.isdigit() for field in fields[1:9]):
|
||||
return None
|
||||
values = [int(field) for field in fields[1:9]]
|
||||
return values[3] + values[4], sum(values)
|
||||
|
||||
|
||||
def cpu_percent(before: tuple[int, int] | None, after: tuple[int, int] | None) -> float | None:
|
||||
if before is None or after is None:
|
||||
return None
|
||||
idle, total = after[0] - before[0], after[1] - before[1]
|
||||
return percent(total - idle, total) if total > 0 else None
|
||||
|
||||
|
||||
def parse_loadavg(text: str) -> float | None:
|
||||
fields = text.split()
|
||||
return number(fields[0]) if fields else None
|
||||
|
||||
|
||||
def parse_nvidia(text: str) -> dict[str, dict]:
|
||||
"""Returns:
|
||||
Per PCI address (domain of 4 hex digits) {util, vram_used,
|
||||
vram_total} from the CSV nvidia-smi prints for NVIDIA_QUERY with
|
||||
noheader,nounits; vram in bytes, an unknown value None.
|
||||
"""
|
||||
found = {}
|
||||
for line in text.splitlines():
|
||||
parts = [part.strip() for part in line.split(",")]
|
||||
if len(parts) != 4:
|
||||
continue
|
||||
util, used, total = (number(part) for part in parts[1:])
|
||||
found[parts[0].lower()[-12:]] = {"util": util, "vram_used": None if used is None else int(used * MIB), "vram_total": None if total is None else int(total * MIB)}
|
||||
return found
|
||||
|
||||
|
||||
def nvidia_gpus(sys_root: Path) -> list[tuple[str, bool]]:
|
||||
"""Returns:
|
||||
The PCI address of every NVIDIA display controller under sys_root and
|
||||
whether its runtime_status reads active.
|
||||
"""
|
||||
found = []
|
||||
for device in sorted((sys_root / "bus" / "pci" / "devices").glob("*")):
|
||||
if read_text(device / "vendor").strip() == NVIDIA and read_text(device / "class").strip().startswith(DISPLAY):
|
||||
found.append((device.name, read_text(device / "power" / "runtime_status").strip() == "active"))
|
||||
return found
|
||||
|
||||
|
||||
def gpus(sys_root: Path, run: Callable[[list[str]], str | None]) -> list[dict]:
|
||||
devices = nvidia_gpus(sys_root)
|
||||
awake = [address for address, active in devices if active]
|
||||
readings = parse_nvidia(run(["nvidia-smi", f"--id={','.join(awake)}", f"--query-gpu={NVIDIA_QUERY}", "--format=csv,noheader,nounits"]) or "") if awake else {}
|
||||
return [{"name": address, **readings.get(address, UNREAD)} if active else {"name": address, "asleep": True} for address, active in devices]
|
||||
|
||||
|
||||
def queue(council: Path) -> dict:
|
||||
data = read_json(council / "queue.json")
|
||||
entries = data.get("entries") if isinstance(data, dict) else None
|
||||
statuses = [entry.get("status") for entry in entries if isinstance(entry, dict)] if isinstance(entries, list) else []
|
||||
return {"waiting": statuses.count("waiting"), "active": statuses.count("active")}
|
||||
|
||||
|
||||
def integers(value: object, size: int) -> bool:
|
||||
return isinstance(value, list) and len(value) == size and all(isinstance(item, int) and not isinstance(item, bool) for item in value)
|
||||
|
||||
|
||||
def cpu_since(proc: Path, state: Path, t: int) -> float | None:
|
||||
"""Returns:
|
||||
The busy share of the ticks in proc's stat since those the run
|
||||
before kept in <state>/cpu.json at most BUCKET seconds before t,
|
||||
else None; this run's ticks replace them.
|
||||
"""
|
||||
ticks = cpu_ticks(read_text(proc / "stat"))
|
||||
stored = read_json(state / TICKS)
|
||||
before = (stored[1], stored[2]) if integers(stored, 3) and t - BUCKET <= stored[0] <= t else None
|
||||
if ticks is not None:
|
||||
replace(state / TICKS, json.dumps([t, *ticks]))
|
||||
return cpu_percent(before, ticks)
|
||||
|
||||
|
||||
def sample(proc: Path, sys_root: Path, council: Path, run: Callable[[list[str]], str | None], state: Path) -> dict:
|
||||
t = int(time.time())
|
||||
ram, swap = memory(parse_meminfo(read_text(proc / "meminfo")))
|
||||
return {
|
||||
"t": t,
|
||||
"disks": parse_df(run(["df", "-P", "-k", "-l"]) or ""),
|
||||
"cpu": cpu_since(proc, state, t),
|
||||
"ram": ram,
|
||||
"swap": swap,
|
||||
"load": parse_loadavg(read_text(proc / "loadavg")),
|
||||
"gpus": gpus(sys_root, run),
|
||||
"queue": queue(council),
|
||||
}
|
||||
|
||||
|
||||
def average(values: list[object], digits: int) -> float | None:
|
||||
found = [value for value in values if isinstance(value, int | float) and not isinstance(value, bool)]
|
||||
return round(sum(found) / len(found), digits) if found else None
|
||||
|
||||
|
||||
def whole(values: list[object]) -> int | None:
|
||||
found = average(values, 0)
|
||||
return None if found is None else int(found)
|
||||
|
||||
|
||||
def listed(rows: list[dict], key: str) -> list[dict]:
|
||||
return [item for row in rows if isinstance(row.get(key), list) for item in row[key] if isinstance(item, dict)]
|
||||
|
||||
|
||||
def grouped(items: list[dict], key: str) -> dict[object, list[dict]]:
|
||||
found: dict[object, list[dict]] = {}
|
||||
for item in items:
|
||||
found.setdefault(item.get(key), []).append(item)
|
||||
return found
|
||||
|
||||
|
||||
def gpu_mean(name: object, readings: list[dict]) -> dict:
|
||||
awake = [reading for reading in readings if reading.get("asleep") is not True]
|
||||
if not awake:
|
||||
return {"name": name, "asleep": True}
|
||||
return {"name": name, "util": average([reading.get("util") for reading in awake], 1), "vram_used": whole([reading.get("vram_used") for reading in awake]), "vram_total": whole([reading.get("vram_total") for reading in awake])}
|
||||
|
||||
|
||||
def disk_means(rows: list[dict]) -> list[dict]:
|
||||
return [{"mount": mount, "used": whole([disk.get("used") for disk in disks]), "total": whole([disk.get("total") for disk in disks])} for mount, disks in grouped(listed(rows, "disks"), "mount").items()]
|
||||
|
||||
|
||||
def queue_mean(rows: list[dict]) -> dict:
|
||||
queues = [row["queue"] for row in rows if isinstance(row.get("queue"), dict)]
|
||||
return {key: average([entry.get(key) for entry in queues], 2) for key in ("waiting", "active")}
|
||||
|
||||
|
||||
def mean_row(start: int, rows: list[dict]) -> dict:
|
||||
"""Returns:
|
||||
The 15-minute mean of rows at start: per mount and per GPU the mean
|
||||
of the rows that list it, a GPU asleep in every row stays asleep;
|
||||
a single row already at start comes back unchanged.
|
||||
"""
|
||||
if len(rows) == 1 and rows[0]["t"] == start:
|
||||
return rows[0]
|
||||
host = {key: average([row.get(key) for row in rows], digits) for key, digits in HOST_DIGITS}
|
||||
gpu_rows = [gpu_mean(name, readings) for name, readings in grouped(listed(rows, "gpus"), "name").items()]
|
||||
return {"t": start, "disks": disk_means(rows), **host, "gpus": gpu_rows, "queue": queue_mean(rows)}
|
||||
|
||||
|
||||
def timed(row: object) -> bool:
|
||||
return isinstance(row, dict) and isinstance(row.get("t"), int | float) and not isinstance(row.get("t"), bool)
|
||||
|
||||
|
||||
def retain(rows: list[dict], now: float) -> list[dict]:
|
||||
"""Returns:
|
||||
rows without those older than 90 days, every 15-minute bucket that
|
||||
ended more than 7 days ago folded into one mean at its start, the
|
||||
newer rows unchanged and in order.
|
||||
"""
|
||||
buckets: dict[int, list[dict]] = {}
|
||||
recent = []
|
||||
for row in rows:
|
||||
if not timed(row) or row["t"] < now - COARSE:
|
||||
continue
|
||||
start = int(row["t"] // BUCKET * BUCKET)
|
||||
if start + BUCKET <= now - FINE:
|
||||
buckets.setdefault(start, []).append(row)
|
||||
else:
|
||||
recent.append(row)
|
||||
return [mean_row(start, buckets[start]) for start in sorted(buckets)] + recent
|
||||
|
||||
|
||||
def read_rows(path: Path) -> list[dict]:
|
||||
rows = []
|
||||
for line in read_text(path).splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except (ValueError, RecursionError):
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def encoded(rows: list[dict]) -> str:
|
||||
return "".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows)
|
||||
|
||||
|
||||
def append_rows(path: Path, rows: list[dict]) -> None:
|
||||
if rows:
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(encoded(rows))
|
||||
|
||||
|
||||
def replace(path: Path, text: str) -> None:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False) as handle:
|
||||
try:
|
||||
handle.write(text)
|
||||
except BaseException:
|
||||
os.unlink(handle.name)
|
||||
raise
|
||||
os.replace(handle.name, path)
|
||||
|
||||
|
||||
def rewrite(path: Path, keep: Callable[[list[dict], float], list[dict]], now: float) -> None:
|
||||
rows = read_rows(path)
|
||||
kept = keep(rows, now)
|
||||
if kept != rows:
|
||||
replace(path, encoded(kept))
|
||||
|
||||
|
||||
def record(state: Path, row: dict, now: float) -> None:
|
||||
path = state / SAMPLES
|
||||
append_rows(path, [row])
|
||||
if now % BUCKET < INTERVAL:
|
||||
rewrite(path, retain, now)
|
||||
|
||||
|
||||
def claim_names(council: Path) -> dict[str, str]:
|
||||
names = {}
|
||||
for pattern in CLAIMS:
|
||||
for path in sorted(council.glob(pattern)):
|
||||
data = read_json(path)
|
||||
if isinstance(data, dict) and isinstance(data.get("session"), str) and isinstance(data.get("name"), str) and data["name"].strip():
|
||||
names[data["session"]] = data["name"].strip()
|
||||
return names
|
||||
|
||||
|
||||
def epoch(value: object) -> float | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=datetime.UTC)).timestamp()
|
||||
|
||||
|
||||
def count(value: object) -> int:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else 0
|
||||
|
||||
|
||||
def assistant(raw: bytes) -> tuple[dict, dict, dict] | None:
|
||||
"""Returns:
|
||||
(row, message, usage) of an assistant transcript line whose message
|
||||
carries a usage map and whose row a session id, else None.
|
||||
"""
|
||||
try:
|
||||
row = json.loads(raw)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
message = row.get("message") if isinstance(row, dict) and row.get("type") == "assistant" else None
|
||||
fields = message.get("usage") if isinstance(message, dict) else None
|
||||
return (row, message, fields) if isinstance(fields, dict) and isinstance(row.get("sessionId"), str) else None
|
||||
|
||||
|
||||
def message_key(ident: object, request: object) -> str:
|
||||
"""Returns:
|
||||
A short digest of (message id, request id), "" without a message id.
|
||||
"""
|
||||
if not isinstance(ident, str) or not ident:
|
||||
return ""
|
||||
text = f"{ident}\0{request if isinstance(request, str) else ''}"
|
||||
return base64.urlsafe_b64encode(hashlib.blake2b(text.encode("utf-8"), digest_size=KEY_BYTES).digest()).decode("ascii")
|
||||
|
||||
|
||||
def usage(raw: bytes) -> tuple[str, float, str, str, tuple[int, ...]] | None:
|
||||
"""Returns:
|
||||
(message key, time, session, model, counts in COUNTS order) of an
|
||||
assistant transcript line with a usage field, a time and a model,
|
||||
else None.
|
||||
"""
|
||||
found = assistant(raw)
|
||||
if found is None:
|
||||
return None
|
||||
row, message, fields = found
|
||||
ts, model = epoch(row.get("timestamp")), message.get("model")
|
||||
if ts is None or not isinstance(model, str):
|
||||
return None
|
||||
return message_key(message.get("id"), row.get("requestId")), ts, row["sessionId"], model, tuple(count(fields.get(field)) for _, field in COUNTS)
|
||||
|
||||
|
||||
def first_sight(key: str, ts: float, seen: dict[int, set[str]], keys: dict[int, set[str]]) -> bool:
|
||||
"""Returns:
|
||||
Whether the message key at ts is in neither seen nor keys at its hour
|
||||
or a neighbouring one, then files it in keys at its hour; a line
|
||||
without a key is always a first sight. The neighbours catch a copy a
|
||||
fork wrote seconds after the original across the hour.
|
||||
"""
|
||||
if not key:
|
||||
return True
|
||||
hour = int(ts // HOUR * HOUR)
|
||||
known = any(key in found.get(near, ()) for found in (seen, keys) for near in (hour - HOUR, hour, hour + HOUR))
|
||||
keys.setdefault(hour, set()).add(key)
|
||||
return not known
|
||||
|
||||
|
||||
def scan(path: Path, start: int, budget: int, seen: dict[int, set[str]]) -> tuple[int, list[tuple[float, str, str, tuple[int, ...]]], dict[int, set[str]]]:
|
||||
"""Returns:
|
||||
The offset after the last complete line read from start, at most
|
||||
about budget bytes on, the usage of every message seen holds no key
|
||||
of, and per hour the keys of every message read.
|
||||
"""
|
||||
found = []
|
||||
keys: dict[int, set[str]] = {}
|
||||
position = start
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(start)
|
||||
for raw in handle:
|
||||
if not raw.endswith(b"\n") or position - start >= budget:
|
||||
break
|
||||
position += len(raw)
|
||||
read = usage(raw) if b'"usage"' in raw else None
|
||||
if read is not None and first_sight(read[0], read[1], seen, keys) and any(read[4]):
|
||||
found.append(read[1:])
|
||||
return position, found, keys
|
||||
|
||||
|
||||
def token_rows(sums: dict[tuple, list[int]]) -> list[dict]:
|
||||
return [{"t": t, "agent": agent, "model": model, **dict(zip(NAMES, counts, strict=True))} for (t, agent, model), counts in sorted(sums.items())]
|
||||
|
||||
|
||||
def add(sums: dict[tuple, list[int]], key: tuple, counts: tuple[int, ...]) -> None:
|
||||
total = sums.setdefault(key, [0] * len(COUNTS))
|
||||
for index, value in enumerate(counts):
|
||||
total[index] += value
|
||||
|
||||
|
||||
def compact(rows: list[dict], now: float) -> list[dict]:
|
||||
sums: dict[tuple, list[int]] = {}
|
||||
for row in rows:
|
||||
if timed(row) and row["t"] >= now - COARSE and isinstance(row.get("agent"), str) and isinstance(row.get("model"), str):
|
||||
add(sums, (row["t"], row["agent"], row["model"]), tuple(count(row.get(name)) for name in NAMES))
|
||||
return token_rows(sums)
|
||||
|
||||
|
||||
def start_of(entry: object, info: os.stat_result, now: float) -> int:
|
||||
if isinstance(entry, int) and not isinstance(entry, bool) and entry >= 0:
|
||||
return entry if entry <= info.st_size else 0
|
||||
return info.st_size if info.st_mtime < now - COARSE else 0
|
||||
|
||||
|
||||
def read_transcript(path: Path, entry: object, budget: int, now: float, seen: dict[int, set[str]]) -> tuple[int, int, list, dict[int, set[str]]]:
|
||||
"""Returns:
|
||||
(start, end, usages, keys per hour) of path read from the offset
|
||||
entry records, without reading once budget is spent.
|
||||
"""
|
||||
info = path.stat()
|
||||
start = start_of(entry, info, now)
|
||||
if budget <= 0 or start >= info.st_size:
|
||||
return start, start, [], {}
|
||||
return start, *scan(path, start, budget, seen)
|
||||
|
||||
|
||||
def seen_of(value: object) -> dict[int, set[str]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {int(hour): {key for key in keys if isinstance(key, str)} for hour, keys in value.items() if hour.isdigit() and isinstance(keys, list)}
|
||||
|
||||
|
||||
def kept_seen(seen: dict[int, set[str]], touched: set[int], now: float) -> dict[str, list[str]]:
|
||||
floor = now // HOUR * HOUR - HOUR
|
||||
return {str(hour): sorted(keys) for hour, keys in sorted(seen.items()) if hour in touched or hour >= floor}
|
||||
|
||||
|
||||
def count_tokens(transcripts: Path, council: Path, state: Path, now: float, budget: int) -> list[dict]:
|
||||
"""Returns:
|
||||
The rows appended to <state>/tokens.jsonl by this run.
|
||||
"""
|
||||
stored = read_json(state / OFFSETS)
|
||||
offsets = stored.get("files") if isinstance(stored, dict) and isinstance(stored.get("files"), dict) else {}
|
||||
seen = seen_of(stored.get("seen") if isinstance(stored, dict) else None)
|
||||
names = claim_names(council)
|
||||
sums: dict[tuple, list[int]] = {}
|
||||
files, touched = {}, set()
|
||||
for path in sorted(transcripts.rglob("*.jsonl")):
|
||||
key = path.relative_to(transcripts).as_posix()
|
||||
try:
|
||||
start, end, found, keys = read_transcript(path, offsets.get(key), budget, now, seen)
|
||||
except OSError:
|
||||
if key in offsets:
|
||||
files[key] = offsets[key]
|
||||
continue
|
||||
files[key] = end
|
||||
budget -= end - start
|
||||
for hour, read in keys.items():
|
||||
seen.setdefault(hour, set()).update(read)
|
||||
touched.add(hour)
|
||||
for ts, session, model, counts in found:
|
||||
add(sums, (int(ts // HOUR * HOUR), names.get(session) or session[:8], model), counts)
|
||||
rows = token_rows(sums)
|
||||
append_rows(state / TOKENS, rows)
|
||||
kept = {"files": files, "seen": kept_seen(seen, touched, now)}
|
||||
if kept != stored:
|
||||
replace(state / OFFSETS, json.dumps(kept, sort_keys=True, separators=(",", ":")))
|
||||
if now % HOUR < INTERVAL:
|
||||
rewrite(state / TOKENS, compact, now)
|
||||
return rows
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Sample the host's resources and count the transcripts' tokens for the Agent Orchestrator.")
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
sampling, counting = commands.add_parser("sample"), commands.add_parser("tokens")
|
||||
for sub in (sampling, counting):
|
||||
sub.add_argument("--state", type=Path, required=True)
|
||||
sub.add_argument("--council", type=Path, required=True)
|
||||
sampling.add_argument("--proc", type=Path, required=True)
|
||||
sampling.add_argument("--sys", type=Path, required=True)
|
||||
counting.add_argument("--transcripts", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.state.mkdir(parents=True, exist_ok=True)
|
||||
if args.command == "sample":
|
||||
row = sample(args.proc, args.sys, args.council, command, args.state)
|
||||
record(args.state, row, row["t"])
|
||||
else:
|
||||
count_tokens(args.transcripts, args.council, args.state, time.time(), BUDGET)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlsplit
|
||||
@@ -9,6 +10,7 @@ from urllib.parse import urlsplit
|
||||
from .diffs import DIFF_BODY_LIMIT
|
||||
from .engines import STT_LIMIT
|
||||
from .exchange import PLAIN_CSP, Exchange
|
||||
from .resources import RESOURCES, sampled
|
||||
|
||||
BODY_LIMIT = 1 << 20
|
||||
UPLOAD_LIMIT = 50 << 20
|
||||
@@ -114,6 +116,8 @@ class Handler(Exchange):
|
||||
self.answer(lambda: hub.events(self.query()), HTTPStatus.OK)
|
||||
elif path == "/api/tree":
|
||||
self.answer(hub.repositories, HTTPStatus.OK)
|
||||
elif path == "/api/resources":
|
||||
self.answer(lambda: sampled(hub.kept / RESOURCES, hub.span(self.query(), time.time())), HTTPStatus.OK)
|
||||
elif path == "/api/subagents":
|
||||
self.answer(lambda: hub.subagents(self.query()), HTTPStatus.OK)
|
||||
elif path == "/api/runner/jobs":
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .records import epoch, parse_line, read_regular
|
||||
|
||||
RESOURCES = "resources"
|
||||
SAMPLES = "samples.jsonl"
|
||||
TOKENS = "tokens.jsonl"
|
||||
HOUR = 3600
|
||||
FILE_LIMIT = 64 << 20
|
||||
|
||||
|
||||
def rows_between(path: Path, begin: float, end: float, width: float) -> list[dict]:
|
||||
try:
|
||||
lines = read_regular(path, FILE_LIMIT).splitlines()
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
found = []
|
||||
for row in map(parse_line, lines):
|
||||
t = None if row is None else epoch(row.get("t"))
|
||||
if t is not None and begin <= t + width and t <= end:
|
||||
found.append(row)
|
||||
return found
|
||||
|
||||
|
||||
def sampled(folder: Path, span: tuple[float, float]) -> dict:
|
||||
"""Args:
|
||||
folder: the host sampler's directory, mounted read-only.
|
||||
span: (begin, end) of the page's time window in epoch seconds.
|
||||
|
||||
Returns:
|
||||
{"since", "until", "samples", "tokens"}: the rows of samples.jsonl
|
||||
within the span and those of tokens.jsonl whose hour overlaps it, as
|
||||
the host unit wrote them; the hub reads nothing of the host itself.
|
||||
"""
|
||||
begin, end = span
|
||||
return {"since": begin, "until": end, "samples": rows_between(folder / SAMPLES, begin, end, 0), "tokens": rows_between(folder / TOKENS, begin, end, HOUR)}
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator resources: disks, CPU, RAM, swap, load, GPU, the queue and the transcripts' tokens, into the files the orchestrator reads
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=PATH=/usr/bin:/bin
|
||||
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
|
||||
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/resources.py sample --state ${KVB_ORCHESTRATOR_KEPT}/resources --council ${KVB_ORCHESTRATOR_COUNCIL} --proc /proc --sys /sys
|
||||
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/resources.py tokens --state ${KVB_ORCHESTRATOR_KEPT}/resources --council ${KVB_ORCHESTRATOR_COUNCIL} --transcripts ${KVB_ORCHESTRATOR_PROJECTS}
|
||||
Nice=10
|
||||
IOSchedulingClass=idle
|
||||
TimeoutStartSec=300
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator resources every minute
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* *:*:00
|
||||
AccuracySec=1s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -406,6 +406,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
|
||||
{% include "style-hierarchy.css" %}
|
||||
|
||||
{% include "style-resources.css" %}
|
||||
|
||||
{% include "style-inbox.css" %}
|
||||
|
||||
{% include "style-ui.css" %}
|
||||
@@ -463,6 +465,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "diffs.js" %}
|
||||
|
||||
{% include "resources.js" %}
|
||||
|
||||
{% include "nav.js" %}
|
||||
|
||||
{% include "ancestry.js" %}
|
||||
@@ -578,6 +582,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-alerts.js" %}
|
||||
|
||||
{% include "page-resources.js" %}
|
||||
|
||||
{% include "page-rules.js" %}
|
||||
|
||||
{% include "page-keyboard.js" %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const Nav = (() => {
|
||||
"use strict";
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"], ["resources", "Resources"]];
|
||||
const SUBS = {stream: [["rules", "Firewall"]]};
|
||||
const PANES = {inbox: ["tasks", "operator"]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
@@ -10,6 +10,7 @@ const Nav = (() => {
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", gantt: "bi-calendar-range", burn: "bi-graph-down-arrow",
|
||||
resources: "bi-speedometer2",
|
||||
};
|
||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
const RESOURCE = {left: 46, right: 10, top: 6, bottom: 6, axis: 18, poll: 5000, stale: 60, tones: 6, hour: 3600, narrow: 600};
|
||||
const RESOURCE_PANELS = [["disks", "bi-hdd-stack", "Disks"], ["host", "bi-cpu", "CPU, RAM and swap"], ["gpu", "bi-gpu-card", "GPU and VRAM"], ["tokens", "bi-coin", "Tokens per agent"]];
|
||||
const RESOURCE_ZONES = {idle: "Idle: below 30 %", saturated: "Saturated: above 90 %"};
|
||||
const RESOURCE_MARKS = {
|
||||
wait: ["#bi-hourglass-split", (mark) => `${mark.peak} jobs waited while CPU and RAM stayed under 50 %`],
|
||||
asleep: ["#bi-moon", (mark) => `${mark.name} asleep`],
|
||||
unknown: ["#bi-question-circle", (mark) => `${mark.name} unknown: no reading`],
|
||||
};
|
||||
const resourceView = {data: null, dom: null, busy: false, key: "", at: 0, timer: 0};
|
||||
const resourceKey = () => JSON.stringify([ui.preset, ui.from, ui.to, ui.asOf]);
|
||||
const compactCount = (value) => (value >= 1e6 ? `${fixed(value / 1e6)}M` : value >= 1e3 ? `${fixed(value / 1e3)}k` : String(Math.round(value)));
|
||||
const resourceLabel = (line) => (line.kind === "vram" ? `${line.name} VRAM` : line.name);
|
||||
|
||||
function resourcesScreen() {
|
||||
const count = h("span", {class: "count", title: "Samples", "aria-label": "Samples"}, "0");
|
||||
const panels = RESOURCE_PANELS.map(([kind, name, label]) => {
|
||||
const legend = h("div", {class: "res-legend"});
|
||||
const plot = h("div", {class: "res-plot"});
|
||||
const panel = h("section", {class: `card res-panel res-${kind}`, "data-panel": kind, "aria-label": label},
|
||||
h("div", {class: "res-head"}, h("span", {class: "res-title", title: label, "aria-label": label}, icon(name)), legend), plot);
|
||||
return {kind, label, legend, plot, panel};
|
||||
});
|
||||
resourceView.dom = {count, panels};
|
||||
if (!resourceView.timer) {
|
||||
resourceView.timer = window.setInterval(pollResources, RESOURCE.poll);
|
||||
window.addEventListener("resize", drawResources);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
drawResources();
|
||||
fetchResources();
|
||||
}, 0);
|
||||
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchResources, "Reload: read the sampler files again");
|
||||
return h("div", {class: "graph resources"},
|
||||
h("div", {class: "card controls"}, h("div", {class: "toolbar", role: "toolbar", "aria-label": "Resources"}, reload, count)),
|
||||
h("div", {class: "res-panels"}, panels.map((entry) => entry.panel)));
|
||||
}
|
||||
|
||||
function pollResources() {
|
||||
const dom = resourceView.dom;
|
||||
if (!dom || !dom.count.isConnected || document.hidden || resourceView.busy) {
|
||||
return;
|
||||
}
|
||||
if (resourceKey() !== resourceView.key || nowSeconds() - resourceView.at >= RESOURCE.stale) {
|
||||
fetchResources();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchResources() {
|
||||
if (resourceView.busy) {
|
||||
return;
|
||||
}
|
||||
resourceView.busy = true;
|
||||
resourceView.key = resourceKey();
|
||||
try {
|
||||
const response = await fetch(`/api/resources?${windowQuery()}`, {cache: "no-store"});
|
||||
if (response.ok) {
|
||||
resourceView.data = await response.json();
|
||||
}
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
}
|
||||
resourceView.at = nowSeconds();
|
||||
resourceView.busy = false;
|
||||
drawResources();
|
||||
}
|
||||
|
||||
function resourceFrame(entry, span, size) {
|
||||
const axis = entry.kind === "tokens";
|
||||
const bottom = size.height - (axis ? RESOURCE.axis : RESOURCE.bottom);
|
||||
const x = (t) => RESOURCE.left + ((t - span.from) / (span.to - span.from)) * (size.width - RESOURCE.left - RESOURCE.right);
|
||||
const svg = s("svg", {class: `chart res-chart res-${entry.kind}`, "data-panel": entry.kind, "data-from": span.from, "data-to": span.to, viewBox: `0 0 ${size.width} ${size.height}`, role: "img", "aria-label": entry.label});
|
||||
const ticks = size.width < RESOURCE.narrow ? 2 : 4;
|
||||
for (let index = 0; index <= ticks; index += 1) {
|
||||
const t = span.from + ((span.to - span.from) * index) / ticks;
|
||||
svg.append(s("line", {class: "grid", x1: fixed(x(t)), x2: fixed(x(t)), y1: RESOURCE.top, y2: fixed(bottom)}));
|
||||
if (axis) {
|
||||
svg.append(s("text", {class: "axis", x: fixed(x(t)), y: size.height - 4, "text-anchor": index === 0 ? "start" : index === ticks ? "end" : "middle"}, (span.to - span.from > DAY ? absolute : clock).format(stampOf(t))));
|
||||
}
|
||||
}
|
||||
return {svg, x, top: RESOURCE.top, bottom, right: size.width - RESOURCE.right};
|
||||
}
|
||||
|
||||
function resourceScale(frame, values, label) {
|
||||
const y = (value) => frame.top + (1 - value / values[values.length - 1]) * (frame.bottom - frame.top);
|
||||
for (const value of values) {
|
||||
frame.svg.append(s("line", {class: "grid", x1: RESOURCE.left, x2: fixed(frame.right), y1: fixed(y(value)), y2: fixed(y(value))}));
|
||||
frame.svg.append(s("text", {class: "axis", x: RESOURCE.left - 6, y: fixed(y(value)), dy: "0.35em", "text-anchor": "end"}, label(value)));
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
function resourceMarks(frame, marks, span) {
|
||||
for (const mark of marks) {
|
||||
const left = frame.x(Math.max(mark.from, span.from));
|
||||
const right = frame.x(Math.min(mark.to, span.to));
|
||||
if (right <= left) {
|
||||
continue;
|
||||
}
|
||||
const [href, words] = RESOURCE_MARKS[mark.kind];
|
||||
frame.svg.append(s("rect", {class: `res-mark ${mark.kind}`, "data-from": mark.from, "data-to": mark.to, "data-peak": mark.peak, x: fixed(left), width: fixed(Math.max(right - left, 2)), y: frame.top, height: fixed(frame.bottom - frame.top)}, s("title", {}, words(mark))));
|
||||
frame.svg.append(s("use", {class: `res-icon ${mark.kind}`, href, x: fixed(left + 2), y: frame.top + 2, width: 11, height: 11}));
|
||||
}
|
||||
}
|
||||
|
||||
function resourcePercent(entry, list, marks, span, size) {
|
||||
const frame = resourceFrame(entry, span, size);
|
||||
const y = resourceScale(frame, [0, 50, 100], (value) => `${value} %`);
|
||||
for (const zone of Resources.ZONES) {
|
||||
frame.svg.append(s("rect", {class: `band ${zone.kind}`, "data-zone": zone.kind, x: RESOURCE.left, width: fixed(frame.right - RESOURCE.left), y: fixed(y(zone.to)), height: fixed(y(zone.from) - y(zone.to))}, s("title", {}, RESOURCE_ZONES[zone.kind])));
|
||||
}
|
||||
resourceMarks(frame, marks, span);
|
||||
const tones = new Map();
|
||||
const legend = [];
|
||||
for (const line of list) {
|
||||
if (!tones.has(line.name)) {
|
||||
tones.set(line.name, tones.size % RESOURCE.tones);
|
||||
}
|
||||
const tone = `res-c${tones.get(line.name)} k-${line.kind}`;
|
||||
const last = [...line.points].reverse().find(([, value]) => value !== null);
|
||||
const words = `${resourceLabel(line)} ${dash(last === undefined ? null : last[1])} %`;
|
||||
for (const part of Resources.segments(line.points)) {
|
||||
const d = part.map(([t, value], index) => `${index ? "L" : "M"}${fixed(frame.x(t))},${fixed(y(Math.min(Math.max(value, 0), 100)))}`).join(" ");
|
||||
frame.svg.append(s("path", {class: `res-line ${tone}`, "data-series": line.name, "data-kind": line.kind, "data-points": part.length, d: part.length === 1 ? `${d} h1` : d}, s("title", {}, words)));
|
||||
}
|
||||
legend.push(h("span", {class: `res-key ${tone}`, "data-series": line.name, "data-kind": line.kind}, words));
|
||||
}
|
||||
return {svg: frame.svg, legend};
|
||||
}
|
||||
|
||||
function resourceTokens(entry, stack, span, size) {
|
||||
const frame = resourceFrame(entry, span, size);
|
||||
const y = resourceScale(frame, [0, Math.max(stack.top, 1)], compactCount);
|
||||
const tones = new Map(stack.agents.map((agent, index) => [agent, index % RESOURCE.tones]));
|
||||
for (const bar of stack.bars) {
|
||||
const left = frame.x(Math.max(bar.t, span.from));
|
||||
const right = frame.x(Math.min(bar.t + RESOURCE.hour, span.to));
|
||||
if (right <= left) {
|
||||
continue;
|
||||
}
|
||||
for (const part of bar.parts) {
|
||||
const words = `${part.agent} · ${absolute.format(stampOf(bar.t))}: ${part.total} tokens (input ${part.input}, output ${part.output}, cache read ${part.cache_read}, cache write ${part.cache_write}) · ${part.models.join(", ")}`;
|
||||
frame.svg.append(s("rect", {class: `res-bar res-c${tones.get(part.agent)}`, "data-agent": part.agent, "data-t": bar.t, "data-total": part.total, x: fixed(left), width: fixed(Math.max(right - left - 1, 1)), y: fixed(y(part.to)), height: fixed(Math.max(y(part.from) - y(part.to), 0.5))}, s("title", {}, words)));
|
||||
}
|
||||
}
|
||||
const legend = stack.agents.map((agent) => h("span", {class: `res-key res-c${tones.get(agent)}`, "data-series": agent}, `${agent} ${compactCount(stack.totals[agent])}`));
|
||||
return {svg: frame.svg, legend};
|
||||
}
|
||||
|
||||
function drawResources() {
|
||||
const dom = resourceView.dom;
|
||||
const data = resourceView.data;
|
||||
if (!dom || !dom.count.isConnected || data === null) {
|
||||
return;
|
||||
}
|
||||
const span = {from: data.since, to: Math.max(data.until, data.since + 60)};
|
||||
const lines = Resources.lines(data.samples);
|
||||
const marks = {
|
||||
disks: [],
|
||||
host: Resources.waits(data.samples).map((mark) => ({...mark, kind: "wait"})),
|
||||
gpu: ["asleep", "unknown"].flatMap((kind) => Resources[kind](data.samples).flatMap((gpu) => gpu.spans.map((mark) => ({...mark, kind, name: gpu.name})))),
|
||||
};
|
||||
dom.count.textContent = String(data.samples.length);
|
||||
for (const entry of dom.panels) {
|
||||
const box = entry.plot.getBoundingClientRect();
|
||||
const size = {width: Math.round(box.width), height: Math.round(box.height)};
|
||||
if (size.width < RESOURCE.left + RESOURCE.right + 10 || size.height < RESOURCE.axis + 10) {
|
||||
entry.plot.replaceChildren();
|
||||
continue;
|
||||
}
|
||||
const drawn = entry.kind === "tokens" ? resourceTokens(entry, Resources.stack(data.tokens), span, size) : resourcePercent(entry, lines[entry.kind], marks[entry.kind], span, size);
|
||||
entry.plot.replaceChildren(drawn.svg);
|
||||
entry.legend.replaceChildren(...drawn.legend);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
}
|
||||
|
||||
function screenOf() {
|
||||
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen]();
|
||||
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen, resources: resourcesScreen}[ui.screen]();
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
@@ -73,7 +73,7 @@
|
||||
const main = document.getElementById("main");
|
||||
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
|
||||
main.replaceChildren(screenOf());
|
||||
document.body.classList.toggle("fit", ["tasks", "inbox"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
|
||||
document.body.classList.toggle("fit", ["tasks", "inbox", "resources"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
|
||||
drawn.screen = ui.screen;
|
||||
drawn.sig = sig;
|
||||
if (g3.host && !g3.host.isConnected) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const Resources = (() => {
|
||||
"use strict";
|
||||
const IDLE = 30;
|
||||
const SATURATED = 90;
|
||||
const CALM = 50;
|
||||
const GAP = 1800;
|
||||
const STEP = 60;
|
||||
const COUNTS = ["input", "output", "cache_read", "cache_write"];
|
||||
const ZONES = [{kind: "idle", from: 0, to: IDLE}, {kind: "saturated", from: SATURATED, to: 100}];
|
||||
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
||||
const share = (part, whole) => (finite(part) && finite(whole) && whole > 0 ? Math.round((1000 * part) / whole) / 10 : null);
|
||||
const listOf = (row, key) => (Array.isArray(row[key]) ? row[key].filter((item) => item && typeof item === "object") : []);
|
||||
const timed = (samples) => samples.filter((row) => row && finite(row.t));
|
||||
|
||||
function series(samples, pick) {
|
||||
const found = new Map();
|
||||
for (const row of timed(samples)) {
|
||||
for (const [name, kind, value] of pick(row)) {
|
||||
const key = `${kind}\u0000${name}`;
|
||||
if (!found.has(key)) {
|
||||
found.set(key, {name: String(name), kind, points: []});
|
||||
}
|
||||
found.get(key).points.push([row.t, finite(value) ? value : null]);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
}
|
||||
|
||||
function lines(samples) {
|
||||
return {
|
||||
disks: series(samples, (row) => listOf(row, "disks").map((disk) => [disk.mount, "disk", share(disk.used, disk.total)])),
|
||||
host: series(samples, (row) => [["CPU", "host", row.cpu], ["RAM", "host", row.ram], ["Swap", "host", row.swap]]),
|
||||
gpu: series(samples, (row) => listOf(row, "gpus").flatMap((gpu) => [[gpu.name, "util", gpu.asleep ? null : gpu.util], [gpu.name, "vram", gpu.asleep ? null : share(gpu.vram_used, gpu.vram_total)]])),
|
||||
};
|
||||
}
|
||||
|
||||
function segments(points) {
|
||||
const found = [];
|
||||
let current = [];
|
||||
let last = null;
|
||||
for (const [t, value] of points) {
|
||||
if (current.length && (value === null || t - last > GAP)) {
|
||||
found.push(current);
|
||||
current = [];
|
||||
}
|
||||
if (value !== null) {
|
||||
current.push([t, value]);
|
||||
}
|
||||
last = t;
|
||||
}
|
||||
if (current.length) {
|
||||
found.push(current);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function spans(samples, test, value) {
|
||||
const rows = timed(samples);
|
||||
const found = [];
|
||||
let open = null;
|
||||
rows.forEach((row, index) => {
|
||||
if (open && index && row.t - rows[index - 1].t > GAP) {
|
||||
found.push(open);
|
||||
open = null;
|
||||
}
|
||||
if (!test(row)) {
|
||||
if (open) {
|
||||
found.push(open);
|
||||
}
|
||||
open = null;
|
||||
return;
|
||||
}
|
||||
const next = rows[index + 1];
|
||||
const end = row.t + (next && next.t - row.t <= GAP ? next.t - row.t : STEP);
|
||||
const peak = value(row);
|
||||
open = open ? {from: open.from, to: end, peak: Math.max(open.peak, peak)} : {from: row.t, to: end, peak};
|
||||
});
|
||||
if (open) {
|
||||
found.push(open);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function waits(samples) {
|
||||
const waiting = (row) => (row.queue && finite(row.queue.waiting) ? row.queue.waiting : 0);
|
||||
return spans(samples, (row) => waiting(row) > 0 && finite(row.cpu) && finite(row.ram) && row.cpu < CALM && row.ram < CALM, waiting);
|
||||
}
|
||||
|
||||
function gpuSpans(samples, test) {
|
||||
const names = [...new Set(timed(samples).flatMap((row) => listOf(row, "gpus").filter(test).map((gpu) => String(gpu.name))))];
|
||||
return names.map((name) => ({name, spans: spans(samples, (row) => listOf(row, "gpus").some((gpu) => String(gpu.name) === name && test(gpu)), () => 1)}));
|
||||
}
|
||||
|
||||
const asleep = (samples) => gpuSpans(samples, (gpu) => gpu.asleep === true);
|
||||
const unknown = (samples) => gpuSpans(samples, (gpu) => gpu.asleep !== true && !finite(gpu.util) && !finite(gpu.vram_used));
|
||||
|
||||
function stack(tokens) {
|
||||
const totals = new Map();
|
||||
const hours = new Map();
|
||||
for (const row of tokens) {
|
||||
if (!row || !finite(row.t) || typeof row.agent !== "string") {
|
||||
continue;
|
||||
}
|
||||
const counts = COUNTS.map((name) => (finite(row[name]) && row[name] > 0 ? row[name] : 0));
|
||||
const sum = counts.reduce((total, value) => total + value, 0);
|
||||
if (!totals.has(row.agent)) {
|
||||
totals.set(row.agent, 0);
|
||||
}
|
||||
totals.set(row.agent, totals.get(row.agent) + sum);
|
||||
if (!hours.has(row.t)) {
|
||||
hours.set(row.t, new Map());
|
||||
}
|
||||
const hour = hours.get(row.t);
|
||||
if (!hour.has(row.agent)) {
|
||||
hour.set(row.agent, {agent: row.agent, total: 0, models: [], ...Object.fromEntries(COUNTS.map((name) => [name, 0]))});
|
||||
}
|
||||
const entry = hour.get(row.agent);
|
||||
COUNTS.forEach((name, index) => {
|
||||
entry[name] += counts[index];
|
||||
});
|
||||
entry.total += sum;
|
||||
if (typeof row.model === "string" && !entry.models.includes(row.model)) {
|
||||
entry.models.push(row.model);
|
||||
}
|
||||
}
|
||||
const agents = [...totals].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([agent]) => agent);
|
||||
const bars = [...hours.keys()].sort((a, b) => a - b).map((t) => {
|
||||
let base = 0;
|
||||
const parts = agents.filter((agent) => hours.get(t).has(agent)).map((agent) => {
|
||||
const entry = hours.get(t).get(agent);
|
||||
const part = {...entry, from: base, to: base + entry.total};
|
||||
base += entry.total;
|
||||
return part;
|
||||
});
|
||||
return {t, total: base, parts};
|
||||
});
|
||||
return {agents, totals: Object.fromEntries(totals), bars, top: Math.max(0, ...bars.map((bar) => bar.total))};
|
||||
}
|
||||
|
||||
return {IDLE, SATURATED, CALM, GAP, COUNTS, ZONES, lines, segments, spans, waits, asleep, unknown, stack};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Resources;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
body.fit .graph.resources > .res-panels { flex: 1; }
|
||||
.res-panels { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
|
||||
.res-panel { display: flex; flex: 1 1 0; flex-direction: column; gap: 2px; min-height: 72px; padding: 6px 10px; overflow: hidden; }
|
||||
.res-head { display: flex; align-items: center; gap: 4px 12px; min-width: 0; overflow: hidden; color: var(--muted); font-size: var(--fs-xs); white-space: nowrap; }
|
||||
.res-title { display: inline-flex; color: var(--text); font-size: var(--fs-md); }
|
||||
.res-legend { display: flex; gap: 4px 12px; min-width: 0; overflow: hidden; font-variant-numeric: tabular-nums; }
|
||||
.res-key { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.res-key::before { content: ""; width: 12px; height: 3px; border-radius: 2px; background: var(--c); }
|
||||
.res-key.k-vram::before { background: repeating-linear-gradient(90deg, var(--c) 0 4px, transparent 4px 6px); }
|
||||
.res-plot { position: relative; flex: 1; min-height: 0; }
|
||||
.res-plot > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
svg .band.idle { fill: var(--ok-soft); }
|
||||
svg .band.saturated { fill: var(--bad-soft); }
|
||||
svg .res-line { fill: none; stroke: var(--c); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
svg .res-line.k-vram { stroke-dasharray: 5 3; }
|
||||
svg .res-mark.wait { fill: var(--warn-soft); stroke: var(--warn); stroke-dasharray: 3 3; }
|
||||
svg .res-mark.asleep { fill: var(--neutral-soft); }
|
||||
svg .res-mark.unknown { fill: var(--neutral-soft); stroke: var(--muted); stroke-dasharray: 2 3; }
|
||||
svg .res-icon.wait { fill: var(--warn); }
|
||||
svg .res-icon.asleep, svg .res-icon.unknown { fill: var(--muted); }
|
||||
svg .res-bar { fill: var(--c); }
|
||||
.res-c0 { --c: var(--info); }
|
||||
.res-c1 { --c: var(--ok); }
|
||||
.res-c2 { --c: var(--accent); }
|
||||
.res-c3 { --c: var(--warn); }
|
||||
.res-c4 { --c: var(--violet); }
|
||||
.res-c5 { --c: var(--bad); }
|
||||
Reference in new issue
Block a user