Merge feature/orchestrator/s53-agent-presence into master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # docs/requirements/001-agent-orchestrator.md # orchestrator/templates/page-board.js # tests/e2e/test_orchestrator_e2e.py
This commit is contained in:
commit
afebf24703
24 files changed
+798
-13
No files matched your search
@@ -334,6 +334,34 @@ from three views down to any agent, branch or artefact.
|
||||
remaining digits and hashes. Rejected for paths: every segment with
|
||||
"slash", the file name only, mapping to known repositories, not speaking
|
||||
paths.
|
||||
61. **Agent states** (operator 2026-11-10, decisions 61 to 64): the hub
|
||||
derives six states per agent session: sleeping (drain or suspend part,
|
||||
idle),
|
||||
suspending (drain or suspend part, still busy), working (process alive,
|
||||
busy), alive (process alive, idle), dead (process gone while a claim or
|
||||
task still names it) and unreachable (busy without an update for 15
|
||||
minutes, or a call without a receipt for 10 minutes). Inputs: the Claude
|
||||
Code session files (status, pid and its start tick), the night state and
|
||||
the radio receipts; a runtime without a session file shows alive or dead
|
||||
by its claim heartbeat. The hub's container sees neither the session
|
||||
files nor the host's processes, so a host timer
|
||||
(`kvb-orchestrator-sessions.timer`, every 15 seconds while the page
|
||||
polls) writes a snapshot with each process checked into the hub's
|
||||
state; one older than 5 minutes counts as absent. Rejected: an agent
|
||||
hook reporting its own state (a hook and a reinstall on every agent),
|
||||
the claim heartbeat alone (no working versus alive), the host's PID
|
||||
namespace or `/proc` in the container (every process's command line in
|
||||
reach of the web-facing hub).
|
||||
62. **State symbol**: an animated Bootstrap icon from the vendored sprite
|
||||
next to every agent in Orbit, Chord, Swimlane, Tree and 3D and in the
|
||||
agent panel; `prefers-reduced-motion` stops the animation. Rejected:
|
||||
emoji (font-dependent), a colored ring only (six states hard to tell
|
||||
apart).
|
||||
63. **Status menu**: a "Status" menu in the Agents toolbar holds one checkbox
|
||||
per state and "Faces in 3D", stored per browser like the saved views,
|
||||
no passkey. Rejected: hub-wide settings with a signature.
|
||||
64. **3D faces**: agent nodes in the 3D view show their face, on by default
|
||||
and switchable; repositories and branches stay spheres.
|
||||
|
||||
## Approach
|
||||
|
||||
@@ -785,6 +813,19 @@ flowchart TD
|
||||
- [ ] The spot shows the speaking caption, the last five sent messages and
|
||||
the stations with play and stop.
|
||||
|
||||
### Phase 6: agent states and 3D faces (S5.3)
|
||||
|
||||
- [ ] The state of every fixture session matches decision 61 for each of the
|
||||
six states and for a session without a session file (unit test).
|
||||
- [ ] Orbit, Chord, Swimlane, Tree, 3D and the agent panel show the state
|
||||
symbol of each fixture agent.
|
||||
- [ ] Unticking a state in the Status menu hides its symbols in every view;
|
||||
the choice survives a reload.
|
||||
- [ ] The 3D view shows agent faces by default and spheres after unticking
|
||||
"Faces in 3D".
|
||||
- [ ] `make orchestrator-install` enables `kvb-orchestrator-sessions.timer`,
|
||||
and `make orchestrator-check` reports it active.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Unit**
|
||||
@@ -812,6 +853,10 @@ flowchart TD
|
||||
redirecting to `#stream` with the spot flag.
|
||||
- [ ] S5.5: ICAO digit words, Zulu times, DTG with offset conversion,
|
||||
dates, decimals and spoken paths.
|
||||
- [ ] S5.3: each agent state from a fixture snapshot, night state and
|
||||
receipts, a session without a record; the host client's snapshot
|
||||
from fixture session files with a reused pid; a stale or malformed
|
||||
snapshot read as absent.
|
||||
- **Integration**
|
||||
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
|
||||
entry points (JSON on stdin): the post appears only on the callee's
|
||||
@@ -844,3 +889,6 @@ flowchart TD
|
||||
class sends from it, the icon-only Cancel mayday, the speaking
|
||||
caption and the stations inside the spot, in Chromium and Firefox at
|
||||
1920, 1366 and 390 px.
|
||||
- [ ] S5.3: state symbols in every Agents view and the panel, the Status
|
||||
menu toggles through a reload, 3D faces on and off, in Chromium and
|
||||
Firefox at 1920, 1366 and 390 px.
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Args:
|
||||
--sessions: Claude Code's sessions directory, one <pid>.json per session,
|
||||
e.g. ~/.claude/sessions.
|
||||
--output: the snapshot the Agent Orchestrator reads, e.g.
|
||||
~/.local/state/kvb-orchestrator/agent-sessions.json.
|
||||
|
||||
Returns:
|
||||
Runs on the host, because the hub's container sees neither these files
|
||||
nor the host's processes: replaces --output with {"written": <epoch>,
|
||||
"sessions": {<session id>: {"name", "status", "statusUpdatedAt",
|
||||
"running"}}}, running being whether the record's pid still runs the
|
||||
process it started. The exit code is 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROC = Path("/proc")
|
||||
KEPT = ("name", "status", "statusUpdatedAt")
|
||||
RECORD_BYTES = 65536
|
||||
VALUE_CHARS = 200
|
||||
|
||||
|
||||
def read_record(path: Path) -> object:
|
||||
"""Returns:
|
||||
The parsed JSON of path, read without following a symlink and only
|
||||
when it is a regular file of at most RECORD_BYTES.
|
||||
|
||||
Raises:
|
||||
OSError: path is missing, a symlink, no regular file or too big.
|
||||
ValueError, RecursionError: its content is no JSON or nested too deep.
|
||||
"""
|
||||
handle = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(handle, "rb") as stream:
|
||||
info = os.fstat(stream.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > RECORD_BYTES:
|
||||
raise OSError(f"{path}: no regular file of at most {RECORD_BYTES} bytes")
|
||||
return json.loads(stream.read(RECORD_BYTES + 1).decode("utf-8"))
|
||||
|
||||
|
||||
def kept(record: dict) -> dict:
|
||||
return {
|
||||
key: record[key]
|
||||
for key in KEPT
|
||||
if isinstance(record.get(key), str | int | float) and not isinstance(record[key], bool) and len(str(record[key])) <= VALUE_CHARS
|
||||
}
|
||||
|
||||
|
||||
def read_sessions(folder: Path) -> dict[str, dict]:
|
||||
"""Returns:
|
||||
The session records under folder by session id; unreadable files and
|
||||
records without a session id are left out.
|
||||
"""
|
||||
found = {}
|
||||
for path in sorted(folder.glob("*.json")):
|
||||
try:
|
||||
data = read_record(path)
|
||||
except (OSError, ValueError, RecursionError):
|
||||
continue
|
||||
if isinstance(data, dict) and isinstance(data.get("sessionId"), str):
|
||||
found[data["sessionId"]] = data
|
||||
return found
|
||||
|
||||
|
||||
def running(record: dict, proc: Path) -> bool:
|
||||
"""Returns:
|
||||
Whether the record's pid still runs the process it started: the
|
||||
start tick in /proc/<pid>/stat (field 22) equals procStart, so a
|
||||
reused pid counts as gone.
|
||||
"""
|
||||
pid, start = record.get("pid"), record.get("procStart")
|
||||
if isinstance(pid, bool) or not isinstance(pid, int) or not isinstance(start, str):
|
||||
return False
|
||||
try:
|
||||
fields = (proc / str(pid) / "stat").read_text(encoding="utf-8").rpartition(")")[2].split()
|
||||
except OSError:
|
||||
return False
|
||||
return len(fields) > 19 and fields[19] == start
|
||||
|
||||
|
||||
def snapshot(folder: Path, proc: Path, now: float) -> dict:
|
||||
return {
|
||||
"written": now,
|
||||
"sessions": {
|
||||
sid: {**kept(record), "running": running(record, proc)}
|
||||
for sid, record in read_sessions(folder).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write(output: Path, data: dict) -> None:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=output.parent, prefix=f".{output.name}.", delete=False) as handle:
|
||||
try:
|
||||
json.dump(data, handle)
|
||||
except BaseException:
|
||||
os.unlink(handle.name)
|
||||
raise
|
||||
os.replace(handle.name, output)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Snapshot Claude Code's sessions for the Agent Orchestrator.")
|
||||
parser.add_argument("--sessions", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
write(args.output, snapshot(args.sessions, PROC, time.time()))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -169,6 +169,25 @@ def night_mode(council: Path) -> str:
|
||||
return mode if mode in MODES else "day"
|
||||
|
||||
|
||||
def night_parts(council: Path) -> dict[str, str]:
|
||||
"""Args:
|
||||
council: the council directory with night.json.
|
||||
|
||||
Returns:
|
||||
Per session the part that differs from night_mode: drain or suspend
|
||||
for one set alone, day for the active and the resumed sessions while
|
||||
a mode holds.
|
||||
"""
|
||||
data = read_json(council / "night.json")
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
resumed = data.get("resumed", data.get("awake"))
|
||||
working = [data.get("active"), *(resumed if isinstance(resumed, list) else [])]
|
||||
parts = {sid: "day" for sid in working if isinstance(sid, str)} if night_mode(council) != "day" else {}
|
||||
alone = data.get("parts")
|
||||
return {**parts, **({sid: mode for sid, mode in alone.items() if mode in MODES} if isinstance(alone, dict) else {})}
|
||||
|
||||
|
||||
def silence_of(council: Path, now: float) -> dict | None:
|
||||
data = read_json(council / "radio-silence.json")
|
||||
if not isinstance(data, dict):
|
||||
|
||||
@@ -25,6 +25,7 @@ from .mcp import Mcp
|
||||
from .messaging import Messaging
|
||||
from .names import Names
|
||||
from .offices import Offices
|
||||
from .presence import SNAPSHOT
|
||||
from .redirects import Redirects
|
||||
from .requesting import Requesting
|
||||
from .reviewing import Reviewing
|
||||
@@ -85,6 +86,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Reque
|
||||
names: Names
|
||||
watch: Watch
|
||||
tree: Tree
|
||||
agent_sessions: Path
|
||||
model: Model
|
||||
timers: Path
|
||||
settings: Settings
|
||||
@@ -132,6 +134,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
||||
redirects=redirects,
|
||||
watch=Watch(),
|
||||
tree=Tree(file=places.tree_file, heartbeat=state / TREE_HEARTBEAT, lock=threading.Lock(), cached=None, stamp=None),
|
||||
agent_sessions=state / SNAPSHOT,
|
||||
model=model,
|
||||
timers=places.timers,
|
||||
council=council,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .council import HUMAN, MODES, agent
|
||||
from .records import epoch, read_json
|
||||
|
||||
STATES = ("sleeping", "suspending", "alive", "working", "dead", "unreachable")
|
||||
STALE = 15 * 60
|
||||
UNANSWERED = 10 * 60
|
||||
SNAPSHOT = "agent-sessions.json"
|
||||
SNAPSHOT_STALE = 5 * 60
|
||||
|
||||
|
||||
def read_snapshot(path: Path, now: float) -> dict[str, dict]:
|
||||
"""Args:
|
||||
path: the snapshot client/sessions.py writes on the host.
|
||||
now: the moment to judge at.
|
||||
|
||||
Returns:
|
||||
Its session records by session id; empty when it is missing,
|
||||
malformed, written more than SNAPSHOT_STALE ago or dated in the
|
||||
future.
|
||||
"""
|
||||
data = read_json(path)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("sessions"), dict):
|
||||
return {}
|
||||
written = data.get("written")
|
||||
if isinstance(written, bool) or not isinstance(written, int | float) or not 0 <= now - written <= SNAPSHOT_STALE:
|
||||
return {}
|
||||
return {sid: record for sid, record in data["sessions"].items() if isinstance(record, dict)}
|
||||
|
||||
|
||||
def unanswered(news: list[dict], now: float) -> list[tuple[str, float]]:
|
||||
"""Returns:
|
||||
(callee name, call time) of every call older than UNANSWERED that its
|
||||
callee never receipted.
|
||||
"""
|
||||
receipts = {(agent(row.get("from")), round(epoch(row.get("call")) or 0, 3)) for row in news if row.get("class") == "receipt"}
|
||||
late = []
|
||||
for row in news:
|
||||
ts = epoch(row.get("ts"))
|
||||
if row.get("class") != "call" or ts is None or now - ts < UNANSWERED or not isinstance(row.get("to"), list):
|
||||
continue
|
||||
late += [(name, ts) for name in map(agent, row["to"]) if name and (name, round(ts, 3)) not in receipts]
|
||||
return late
|
||||
|
||||
|
||||
def presence(sessions: dict[str, dict], parts: dict[str, str], mode: str, claims: list[dict], news: list[dict], now: float) -> dict[str, str]:
|
||||
"""Args:
|
||||
sessions: the session records of read_snapshot; running says
|
||||
whether a record's process still runs.
|
||||
parts: night_parts, a session's own part over the mode.
|
||||
mode: night_mode, drain, suspend or day.
|
||||
claims: the council claims; a session without a record shows alive
|
||||
or dead by its freshest beat.
|
||||
news: the radio rows, for calls without a receipt.
|
||||
now: the moment to judge at.
|
||||
|
||||
Returns:
|
||||
One of STATES per session id (decision 61).
|
||||
"""
|
||||
late = unanswered(news, now)
|
||||
beats: dict[str, list[float]] = {}
|
||||
names: dict[str, set[str]] = {}
|
||||
for claim in claims:
|
||||
if claim["session"] != "-" and claim["agent"] != HUMAN:
|
||||
beats.setdefault(claim["session"], []).append(claim["beat"] or 0)
|
||||
names.setdefault(claim["session"], set()).add(claim["agent"])
|
||||
states = {sid: "alive" if now - max(beat) < STALE else "dead" for sid, beat in beats.items() if sid not in sessions}
|
||||
for sid, record in sessions.items():
|
||||
if record.get("running") is not True:
|
||||
states[sid] = "dead"
|
||||
continue
|
||||
busy = record.get("status") == "busy"
|
||||
stamp = record.get("statusUpdatedAt")
|
||||
updated = (epoch(stamp / 1000) if isinstance(stamp, int | float) and not isinstance(stamp, bool) else None) or 0
|
||||
called = names.get(sid, set()) | {agent(record.get("name"))}
|
||||
if (busy and now - updated > STALE) or any(name in called and ts > updated for name, ts in late):
|
||||
states[sid] = "unreachable"
|
||||
continue
|
||||
part = parts.get(sid, mode)
|
||||
if part in MODES:
|
||||
states[sid] = "suspending" if busy else "sleeping"
|
||||
else:
|
||||
states[sid] = "working" if busy else "alive"
|
||||
return states
|
||||
@@ -8,6 +8,7 @@ from .council import (
|
||||
NEWS,
|
||||
grants,
|
||||
night_mode,
|
||||
night_parts,
|
||||
radio_rows,
|
||||
read_claims,
|
||||
sessions,
|
||||
@@ -22,6 +23,7 @@ from .jobs import JOB_OPEN
|
||||
from .messaging import message_row
|
||||
from .orbits import TRACK_STEPS, track
|
||||
from .phrasing import phrase
|
||||
from .presence import presence, read_snapshot
|
||||
from .records import epoch, iso_epoch, read_jsonl
|
||||
from .register import (
|
||||
AGENT_ORDER,
|
||||
@@ -138,6 +140,8 @@ class View:
|
||||
"claims": claims,
|
||||
"grants": grant_rows,
|
||||
"night": night_mode(self.council),
|
||||
"night_parts": night_parts(self.council),
|
||||
"presence": presence(read_snapshot(self.agent_sessions, now), night_parts(self.council), night_mode(self.council), claims, news, now),
|
||||
"silence": silence_of(self.council, now),
|
||||
"graph": drawn,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator sessions: Claude Code's sessions and whether their process runs, into the file the orchestrator reads, while its page polls
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=PATH=/usr/bin:/bin
|
||||
ExecCondition=/bin/sh -c 'test -n "$(find %h/.local/state/kvb-orchestrator/tree-heartbeat -mmin -5 2>/dev/null)"'
|
||||
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/sessions.py --sessions %h/.claude/sessions --output %h/.local/state/kvb-orchestrator/agent-sessions.json
|
||||
Nice=10
|
||||
TimeoutStartSec=60
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator sessions 15 seconds after its last run
|
||||
|
||||
[Timer]
|
||||
OnActiveSec=5s
|
||||
OnUnitInactiveSec=15s
|
||||
AccuracySec=1s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -15,6 +15,7 @@
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
agentDom.info.replaceChildren(
|
||||
...[presenceMark(ui.agent, true)].filter(Boolean),
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
h("h3", {}, "Tasks", counter(tasks.length)),
|
||||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||||
|
||||
@@ -91,10 +91,32 @@
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
|
||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
||||
return {bar, mode};
|
||||
}
|
||||
|
||||
function lookBox(id, label, on, run) {
|
||||
const state = Object.hasOwn(PRESENCE, id) ? {class: "check presence", "data-state": id, "data-tone": PRESENCE[id][2]} : {class: "check"};
|
||||
return h("label", state, h("input", {type: "checkbox", checked: on, "data-look": id, on: {change: (event) => run(event.target.checked)}}), state["data-state"] ? icon(PRESENCE[id][1], "glyph") : null, label);
|
||||
}
|
||||
|
||||
function setLook(change) {
|
||||
change();
|
||||
keep(STORE.status, {hidden: [...look.hidden], faces: look.faces});
|
||||
render(false);
|
||||
drawAgent();
|
||||
}
|
||||
|
||||
function statusMenu() {
|
||||
return h("details", {class: "menu"}, h("summary", named("Status", "Status: which agent states show their symbol, and faces in 3D"), icon("bi-activity"), caret()),
|
||||
h("div", {class: "menu-box"}, h("h3", {}, "Agent states"),
|
||||
Object.entries(PRESENCE).map(([id, [label]]) => lookBox(id, label, !look.hidden.has(id), (on) => setLook(() => (on ? look.hidden.delete(id) : look.hidden.add(id))))),
|
||||
h("h3", {}, "3D"),
|
||||
lookBox("faces", "Faces in 3D", look.faces, (on) => setLook(() => {
|
||||
look.faces = on;
|
||||
}))));
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
drawSpot();
|
||||
const toolbar = graphDom && graphDom.toolbar;
|
||||
|
||||
@@ -37,7 +37,15 @@
|
||||
const MOVE = 2000;
|
||||
const RELAYOUT = 60000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const STORE = {views: "kvb-orchestrator.views"};
|
||||
const STORE = {views: "kvb-orchestrator.views", status: "kvb-orchestrator.status"};
|
||||
const PRESENCE = {
|
||||
working: ["Working", "bi-gear-fill", "info"],
|
||||
alive: ["Alive", "bi-heart-pulse-fill", "ok"],
|
||||
sleeping: ["Sleeping", "bi-moon-stars-fill", "violet"],
|
||||
suspending: ["Suspending", "bi-hourglass-split", "warn"],
|
||||
unreachable: ["Unreachable", "bi-wifi-off", "bad"],
|
||||
dead: ["Dead", "bi-x-octagon-fill", "bad"],
|
||||
};
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
|
||||
const SPEECH_QUEUE = 20;
|
||||
|
||||
@@ -75,6 +75,20 @@
|
||||
return h("span", {class: `avatar${big ? " big" : ""}${talking(key) ? " speaking" : ""}`, "data-face": key, "aria-hidden": "true"},
|
||||
s("svg", {class: "face-art", viewBox: `0 0 ${Faces.SIZE} ${Faces.SIZE}`, focusable: "false"}, faceParts(key)));
|
||||
};
|
||||
const presenceOf = (key) => {
|
||||
const value = (state.presence || {})[key];
|
||||
return Object.hasOwn(PRESENCE, value) && !look.hidden.has(value) ? value : null;
|
||||
};
|
||||
const presenceMark = (key, labelled) => {
|
||||
const value = presenceOf(key);
|
||||
return value ? h("span", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], role: "img", ...named(PRESENCE[value][0])}, icon(PRESENCE[value][1], "glyph"), labelled ? PRESENCE[value][0] : null) : null;
|
||||
};
|
||||
const presenceGlyph = (key, x, y, size) => {
|
||||
const value = presenceOf(key);
|
||||
return value ? s("g", {class: "presence", "data-state": value, "data-tone": PRESENCE[value][2], transform: `translate(${fixed(x)} ${fixed(y)})`},
|
||||
s("title", {}, PRESENCE[value][0]), s("rect", {class: "presence-dot", x: fixed(-size * 0.65), y: fixed(-size * 0.65), width: fixed(size * 1.3), height: fixed(size * 1.3), rx: fixed(size * 0.65)}),
|
||||
s("g", {class: "glyph"}, s("use", {href: `#${PRESENCE[value][1]}`, x: fixed(-size / 2), y: fixed(-size / 2), width: fixed(size), height: fixed(size)}))) : null;
|
||||
};
|
||||
const person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||
const people = () => state.people || {};
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
lanes.forEach((node, index) => {
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
|
||||
svg.append(...[presenceGlyph(node.id, left - 14, top + index * lane + lane / 2, 12)].filter(Boolean));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("circle", {r}),
|
||||
node.kind === "subagent" ? null : s("g", {class: "face-art", transform: `translate(${-r} ${-r}) scale(${(2 * r) / Faces.SIZE})`}, faceParts(node.id)),
|
||||
node.kind === "subagent" ? null : presenceGlyph(node.id, r * 0.8, -r * 0.8, Math.max(10, r * 0.7)),
|
||||
caption(node, at, r, gap));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
const SPAWNED = new Set(["twig", "subagent"]);
|
||||
const DEPTH_GAP = 90;
|
||||
const TIME_DEPTH = 640;
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false};
|
||||
const TOP = {operator: 6, chair: 4, session: 2.5};
|
||||
const g3 = {lib: null, three: null, THREE: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false, badges: new Map(), faces: new Set(), textures: new Map(), pulse: 0};
|
||||
|
||||
const layoutsOf = (tab) => (tab === "tasks" ? LAYOUTS.filter(([id]) => id !== "spawn") : LAYOUTS);
|
||||
const layoutOf = (tab) => (layoutsOf(tab).some(([id]) => id === space.views.graph.axis) ? space.views.graph.axis : "none");
|
||||
@@ -84,6 +85,7 @@
|
||||
const nodes = state.graph.nodes.map((node) => ({
|
||||
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since,
|
||||
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
|
||||
face: look.faces && Object.hasOwn(TOP, node.kind), badge: node.kind === "chair" || node.kind === "session",
|
||||
}));
|
||||
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
|
||||
const walk = (twig, parent, depth) => {
|
||||
@@ -196,7 +198,135 @@
|
||||
}
|
||||
|
||||
function signature3d(tab, data) {
|
||||
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone, node.face]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||
}
|
||||
|
||||
function canvasTexture(size, paint) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
paint(canvas.getContext("2d"));
|
||||
const texture = new g3.THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = g3.THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function cached(key, make) {
|
||||
if (!g3.textures.has(key)) {
|
||||
g3.textures.set(key, make());
|
||||
}
|
||||
return g3.textures.get(key);
|
||||
}
|
||||
|
||||
function paintFace(context, key) {
|
||||
context.scale(128 / Faces.SIZE, 128 / Faces.SIZE);
|
||||
for (const [tag, part] of Faces.of(key).parts) {
|
||||
const path = tag === "path" ? new Path2D(part.d) : new Path2D();
|
||||
if (tag === "ellipse") {
|
||||
path.ellipse(part.cx, part.cy, part.rx, part.ry, 0, 0, 2 * Math.PI);
|
||||
} else if (tag === "rect") {
|
||||
path.roundRect(part.x, part.y, part.width, part.height, part.rx || 0);
|
||||
}
|
||||
context.globalAlpha = part.opacity ?? 1;
|
||||
if (part.fill && part.fill !== "none") {
|
||||
context.fillStyle = part.fill;
|
||||
context.fill(path);
|
||||
}
|
||||
if (part.stroke) {
|
||||
context.globalAlpha *= part["stroke-opacity"] ?? 1;
|
||||
context.strokeStyle = part.stroke;
|
||||
context.lineWidth = part["stroke-width"] || 1;
|
||||
context.lineCap = part["stroke-linecap"] || "butt";
|
||||
context.lineJoin = part["stroke-linejoin"] || "miter";
|
||||
context.stroke(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function badgeTexture(value) {
|
||||
const [, name, tone] = PRESENCE[value];
|
||||
const color = g3.colors[tone] || g3.colors.text;
|
||||
return cached(`${value}:${color}`, () => canvasTexture(64, (context) => {
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.92)";
|
||||
context.arc(32, 32, 31, 0, 2 * Math.PI);
|
||||
context.fill();
|
||||
context.fillStyle = color;
|
||||
context.translate(12, 12);
|
||||
context.scale(2.5, 2.5);
|
||||
for (const path of document.querySelectorAll(`#${name} path`)) {
|
||||
context.fill(new Path2D(path.getAttribute("d")), path.getAttribute("fill-rule") || "nonzero");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function count3d() {
|
||||
const box = g3.host && g3.host.querySelector(".graph3d");
|
||||
if (box) {
|
||||
box.dataset.faces = String(g3.faces.size);
|
||||
box.dataset.badges = String([...g3.badges.values()].filter((badge) => badge.visible).length);
|
||||
}
|
||||
if (!g3.pulse && g3.badges.size && !calm.matches) {
|
||||
g3.pulse = window.requestAnimationFrame(pulse3d);
|
||||
}
|
||||
}
|
||||
|
||||
function paintBadge(key, badge) {
|
||||
const value = presenceOf(key);
|
||||
badge.visible = value !== null;
|
||||
badge.userData.state = value;
|
||||
const map = value ? badgeTexture(value) : null;
|
||||
if (map && badge.material.map !== map) {
|
||||
badge.material.map = map;
|
||||
badge.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function badges3d() {
|
||||
for (const [key, badge] of g3.badges) {
|
||||
paintBadge(key, badge);
|
||||
}
|
||||
count3d();
|
||||
}
|
||||
|
||||
function pulse3d(now) {
|
||||
g3.pulse = 0;
|
||||
if (!g3.graph || !g3.badges.size || calm.matches) {
|
||||
return;
|
||||
}
|
||||
for (const badge of g3.badges.values()) {
|
||||
const {state: value, size} = badge.userData;
|
||||
const beat = value === "alive" ? 1 + 0.2 * Math.max(0, Math.sin(now / 150)) : 1;
|
||||
badge.scale.set(size * beat, size * beat, 1);
|
||||
badge.material.rotation = value === "working" ? now / 500 : value === "suspending" ? Math.PI * (Math.floor(now / 1200) % 2) : 0;
|
||||
badge.material.opacity = ["sleeping", "dead", "unreachable"].includes(value) ? 0.6 + 0.4 * Math.cos(now / (value === "unreachable" ? 200 : 900)) ** 2 : 1;
|
||||
}
|
||||
g3.pulse = window.requestAnimationFrame(pulse3d);
|
||||
}
|
||||
|
||||
function object3d(node) {
|
||||
const THREE = g3.THREE;
|
||||
if (!THREE || !(node.face || node.badge)) {
|
||||
return null;
|
||||
}
|
||||
const radius = Math.cbrt(TOP[node.kind]) * 5;
|
||||
const group = new THREE.Group();
|
||||
if (node.face) {
|
||||
const face = new THREE.Sprite(new THREE.SpriteMaterial({map: cached(`face:${node.id}`, () => canvasTexture(128, (context) => paintFace(context, node.id)))}));
|
||||
face.scale.set(radius * 2.4, radius * 2.4, 1);
|
||||
group.add(face);
|
||||
g3.faces.add(node.id);
|
||||
}
|
||||
if (node.badge) {
|
||||
const badge = new THREE.Sprite(new THREE.SpriteMaterial());
|
||||
badge.userData.size = radius * 1.1;
|
||||
badge.scale.set(badge.userData.size, badge.userData.size, 1);
|
||||
badge.position.set(0, radius * 1.7, 0);
|
||||
paintBadge(node.id, badge);
|
||||
g3.badges.set(node.id, badge);
|
||||
group.add(badge);
|
||||
}
|
||||
count3d();
|
||||
return group;
|
||||
}
|
||||
|
||||
async function stage3d(tab, host) {
|
||||
@@ -209,6 +339,7 @@
|
||||
const data = model3d(tab);
|
||||
const sig = signature3d(tab, data);
|
||||
if (g3.host === host && g3.sig === sig) {
|
||||
badges3d();
|
||||
return;
|
||||
}
|
||||
if (g3.host !== host) {
|
||||
@@ -232,6 +363,12 @@
|
||||
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
|
||||
return;
|
||||
}
|
||||
if (tab === "agents" && !g3.THREE) {
|
||||
g3.THREE = await threeModule();
|
||||
if (g3.host !== host) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!g3.graph) {
|
||||
try {
|
||||
g3.graph = Graph({controlType: "orbit"})(box);
|
||||
@@ -246,6 +383,7 @@
|
||||
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[node.tone || KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
|
||||
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
|
||||
.linkDirectionalArrowLength((link) => (DEPENDENCIES.has(link.kind) ? 5 : 0)).linkDirectionalArrowRelPos(1)
|
||||
.nodeThreeObject(object3d).nodeThreeObjectExtend((node) => !node.face)
|
||||
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(builtFit);
|
||||
g3.fit = true;
|
||||
g3.graph.warmupTicks(calm.matches ? 160 : 80);
|
||||
@@ -257,6 +395,8 @@
|
||||
}
|
||||
const layout = layoutOf(tab);
|
||||
place(data.nodes, layout);
|
||||
g3.badges = new Map();
|
||||
g3.faces = new Set();
|
||||
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
|
||||
drawPlanes(layout, data.nodes);
|
||||
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
|
||||
@@ -271,10 +411,14 @@
|
||||
g3.graph._destructor();
|
||||
}
|
||||
}
|
||||
window.cancelAnimationFrame(g3.pulse);
|
||||
g3.pulse = 0;
|
||||
g3.graph = null;
|
||||
g3.host = null;
|
||||
g3.sig = "";
|
||||
g3.planes = [];
|
||||
g3.badges = new Map();
|
||||
g3.faces = new Set();
|
||||
}
|
||||
|
||||
function refresh3d() {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
function hierarchySig() {
|
||||
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
|
||||
return JSON.stringify([held, state.agent_order, state.people]);
|
||||
return JSON.stringify([held, state.agent_order, state.people, state.presence, [...look.hidden]]);
|
||||
}
|
||||
|
||||
function drawHierarchy(force) {
|
||||
@@ -56,6 +56,7 @@
|
||||
return h("div", {class: "org-row", "data-office": claim.office},
|
||||
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
claim.session === "-" ? null : presenceMark(claim.session, false),
|
||||
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
|
||||
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||
h("span", {class: "org-actions"},
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
|
||||
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
|
||||
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
|
||||
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, entry.kind === "session" || entry.kind === "chair" ? presenceMark(entry.id, false) : null, running, meta),
|
||||
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
.map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""}));
|
||||
}
|
||||
|
||||
function validLook(value) {
|
||||
const hidden = value && Array.isArray(value.hidden) ? value.hidden.filter((name) => Object.hasOwn(PRESENCE, name)) : [];
|
||||
return {hidden: new Set(hidden), faces: !(value && value.faces === false)};
|
||||
}
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
@@ -46,6 +51,7 @@
|
||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
|
||||
};
|
||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||
const look = validLook(stored(STORE.status, null));
|
||||
let settings = state.settings;
|
||||
let settingsSig = JSON.stringify(settings);
|
||||
let agentDom = null;
|
||||
|
||||
@@ -18,6 +18,30 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; }
|
||||
}
|
||||
.presence { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--muted); font-size: var(--fs-sm); font-weight: 600; }
|
||||
.presence[data-tone="ok"] { color: var(--ok); }
|
||||
.presence[data-tone="bad"] { color: var(--bad); }
|
||||
.presence[data-tone="warn"] { color: var(--warn); }
|
||||
.presence[data-tone="info"] { color: var(--info); }
|
||||
.presence[data-tone="violet"] { color: var(--violet); }
|
||||
svg .presence { fill: currentColor; }
|
||||
svg .presence-dot { fill: var(--panel); stroke: currentColor; stroke-width: 1; }
|
||||
.presence .glyph { transform-box: fill-box; transform-origin: center; }
|
||||
.presence[data-state="working"] .glyph { animation: presence-spin 3s linear infinite; }
|
||||
.presence[data-state="alive"] .glyph { animation: presence-beat 1.6s ease-in-out infinite; }
|
||||
.presence[data-state="sleeping"] .glyph { animation: presence-drift 4s ease-in-out infinite; }
|
||||
.presence[data-state="suspending"] .glyph { animation: presence-flip 2.4s ease-in-out infinite; }
|
||||
.presence[data-state="unreachable"] .glyph { animation: presence-blink 1.2s ease-in-out infinite; }
|
||||
.presence[data-state="dead"] .glyph { animation: presence-fade 5s ease-in-out infinite; }
|
||||
@keyframes presence-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes presence-beat { 15%, 45% { transform: scale(1.25); } 30% { transform: scale(1); } }
|
||||
@keyframes presence-drift { 50% { transform: translateY(-15%); opacity: .6; } }
|
||||
@keyframes presence-flip { 50%, 100% { transform: rotate(180deg); } }
|
||||
@keyframes presence-blink { 50% { opacity: .25; } }
|
||||
@keyframes presence-fade { 50% { opacity: .35; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.presence .glyph { animation: none; }
|
||||
}
|
||||
.stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||||
.station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); }
|
||||
.station-name, .station-when { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
+21
-2
@@ -4,7 +4,7 @@
|
||||
# write its .env, build and start the container, register the MCP
|
||||
# server for Claude Code, build pkgmgr from the newest signed tag of
|
||||
# PKGMGR_REPO and enable the tree timer and the cleanup path unit,
|
||||
# enable the offices path unit; re-enable the runner and re-register
|
||||
# enable the sessions timer and the offices path unit; re-enable the runner and re-register
|
||||
# the hook when the migration took them over; an enabled runner gets
|
||||
# the fresh unit and a restart
|
||||
# migrate move every trace of the earlier name to the kvb-orchestrator names
|
||||
@@ -39,6 +39,7 @@ RUNNER="${NAME}-runner.service"
|
||||
TREE_UNIT="${NAME}-tree"
|
||||
CLEANUP_UNIT="${NAME}-cleanup"
|
||||
OFFICES_UNIT="${NAME}-offices"
|
||||
SESSIONS_UNIT="${NAME}-sessions"
|
||||
INSTALLED_SKILLS="${HOME}/.claude/skills"
|
||||
HOST_SCRIPTS=(kvb-org-organize/scripts/council.py kvb-org-organize/scripts/nofollow.py kvb-org-rasci/scripts/rasci.py)
|
||||
TREE_DIR="${HOME}/.local/state/pkgmgr/tree"
|
||||
@@ -154,7 +155,7 @@ install_hub() {
|
||||
cp -a "${REPO_ROOT}/orchestrator/." "${HUB}/"
|
||||
# Python loads a cached .pyc whose header matches the source mtime and size, and cp -a keeps the mtime: one planted in the sandbox-writable tree would run on the host
|
||||
find "${HUB}" -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py" "${HUB}/client/offices.py"
|
||||
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py" "${HUB}/client/offices.py" "${HUB}/client/sessions.py"
|
||||
log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.py"
|
||||
log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL=<blob>'; until then every executor refuses"
|
||||
printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \
|
||||
@@ -230,6 +231,18 @@ tree_timer() {
|
||||
log "skills: ${CLEANUP_UNIT}.path deletes the merged branches the operator signs in the Artifacts Tree, locally only"
|
||||
}
|
||||
|
||||
sessions_timer() {
|
||||
if ! has_systemd; then
|
||||
warn "skills: no systemd user session; the agent states stay unknown."
|
||||
return 0
|
||||
fi
|
||||
cp -f "${HUB}/systemd/${SESSIONS_UNIT}.service" "${HUB}/systemd/${SESSIONS_UNIT}.timer" "${UNITS}/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "${SESSIONS_UNIT}.timer"
|
||||
systemctl --user restart "${SESSIONS_UNIT}.timer"
|
||||
log "skills: ${SESSIONS_UNIT}.timer writes ${STATE}/agent-sessions.json every 15 seconds while the Agent Orchestrator page polls"
|
||||
}
|
||||
|
||||
skills_current() {
|
||||
local script
|
||||
for script in "${HOST_SCRIPTS[@]}"; do
|
||||
@@ -257,6 +270,7 @@ install_all() {
|
||||
migrate || return 0
|
||||
install_hub
|
||||
tree_timer
|
||||
sessions_timer
|
||||
offices_unit
|
||||
if ((TOOK_RUNNER)) || runner_enabled; then runner || true; fi
|
||||
if ((TOOK_HOOK)); then
|
||||
@@ -335,6 +349,11 @@ check() {
|
||||
else
|
||||
todo "${CLEANUP_UNIT}.path off: deleting merged branches from the Artifacts Tree waits for make orchestrator-install"
|
||||
fi
|
||||
if [[ "$(systemctl --user is-active "${SESSIONS_UNIT}.timer" 2>/dev/null || true)" == "active" ]]; then
|
||||
ok "${SESSIONS_UNIT}.timer active"
|
||||
else
|
||||
todo "${SESSIONS_UNIT}.timer off: agent states wait for make orchestrator-install"
|
||||
fi
|
||||
if [[ "$(systemctl --user is-active "${OFFICES_UNIT}.path" 2>/dev/null || true)" == "active" ]]; then
|
||||
ok "${OFFICES_UNIT}.path active"
|
||||
else
|
||||
|
||||
@@ -751,6 +751,62 @@ RADIO_JUMP = """(async () => {@HELPERS@
|
||||
header: all("header.top button").map((button) => [button.id, name(button), text(button)]), radioTab: Boolean(document.getElementById("tab-radio")),
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
PRESENCE_HELPERS = """
|
||||
const states = () => all("#main .presence:not(label)").map((node) => node.dataset.state).sort();
|
||||
const statusMenu = () => {
|
||||
const menu = all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Status");
|
||||
menu.open = true;
|
||||
return menu;
|
||||
};
|
||||
const looks = (menu) => all("input[data-look]", menu).map((input) => [input.dataset.look, input.checked]);
|
||||
const graph3d = async (faces) => {
|
||||
const box = await until(() => {
|
||||
const found = document.querySelector("#main .graph3d");
|
||||
return found && found.dataset.faces !== undefined && (faces === null || found.dataset.faces === faces) ? found : null;
|
||||
}, 15000);
|
||||
return box ? [Number(box.dataset.faces), Number(box.dataset.badges)] : null;
|
||||
};
|
||||
"""
|
||||
PRESENCE_WALK = """(async () => {@HELPERS@@PRESENCE@
|
||||
const views = {};
|
||||
for (const view of ["orbit", "chord", "swimlane", "tree", "hierarchy"]) {
|
||||
location.hash = `#agents/${view}`;
|
||||
await until(() => all("#main .presence:not(label)").length, 6000);
|
||||
await sleep(300);
|
||||
views[view] = states();
|
||||
}
|
||||
location.hash = "#agents/orbit";
|
||||
await sleep(700);
|
||||
const node = document.querySelector('#main svg .node[data-pick="s1"]');
|
||||
const motion = getComputedStyle(node.querySelector(".presence .glyph")).animationName;
|
||||
node.dispatchEvent(new MouseEvent("click", {bubbles: true}));
|
||||
const mark = await until(() => document.querySelector("#popup .presence"), 4000);
|
||||
const profile = mark ? [mark.dataset.state, text(mark), mark.getAttribute("aria-label")] : null;
|
||||
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||
await sleep(300);
|
||||
location.hash = "#agents/3d";
|
||||
const shown = await graph3d(null);
|
||||
const menu = statusMenu();
|
||||
const before = looks(menu);
|
||||
menu.querySelector('input[data-look="working"]').click();
|
||||
menu.querySelector('input[data-look="faces"]').click();
|
||||
const hidden = await graph3d("0");
|
||||
location.hash = "#agents/orbit";
|
||||
await sleep(700);
|
||||
return {views, motion, profile, shown, before, hidden, orbit: states(), @FITS@};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS).replace("@FITS@", FITS)
|
||||
PRESENCE_READ = """(async () => {@HELPERS@@PRESENCE@
|
||||
await until(() => document.querySelector('#main svg .node[data-pick="s2"]'), 8000);
|
||||
await sleep(300);
|
||||
const menu = statusMenu();
|
||||
const kept = {boxes: looks(menu), orbit: states()};
|
||||
menu.querySelector('input[data-look="working"]').click();
|
||||
menu.querySelector('input[data-look="faces"]').click();
|
||||
await sleep(300);
|
||||
const back = states();
|
||||
location.hash = "#agents/3d";
|
||||
return {...kept, back, restored: await graph3d(null)};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@PRESENCE@", PRESENCE_HELPERS)
|
||||
GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@
|
||||
const spot = document.getElementById("radio-spot");
|
||||
const gear = all(".spot-head button", spot).find((button) => name(button) === "Radio settings");
|
||||
@@ -1642,7 +1698,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
|
||||
agents = by_hash["#agents/orbit"]
|
||||
self.assertEqual(agents["toolbar"], ["Priority", "Day/Night"])
|
||||
self.assertEqual(agents["toolbar"], ["Priority", "Day/Night", "Status"])
|
||||
self.assertEqual(agents["spot"], {"hidden": True, "channels": CHOICE_CHANNELS})
|
||||
hierarchy = by_hash["#agents/hierarchy"]
|
||||
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
||||
@@ -1799,7 +1855,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.assertNotIn("error", result)
|
||||
self.assertEqual(result["toolbar"], ["Priority", "Day/Night", "Drain", "Suspend", "Resume"])
|
||||
self.assertEqual(result["toolbar"], ["Priority", "Day/Night", "Drain", "Suspend", "Resume", "Status"])
|
||||
self.assertEqual((result["title"], result["shown"]), ("Cancel mayday: lift the radio silence so the agents write again; your passkey signs it", ""))
|
||||
self.assertEqual(result["declined"], {"question": [question], "posted": [], "toasts": []})
|
||||
self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]})
|
||||
@@ -1836,7 +1892,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True]))
|
||||
self.assertEqual(sent["inline"], ["static", True, True])
|
||||
self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"]))
|
||||
self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Priority", "Day/Night", "Status"], CHOICE_CHANNELS, ["Record"]))
|
||||
self.assertEqual(sent["heads"], [["Radio settings", "Radio settings: tones, squelch and read aloud"], ["Cancel mayday", "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it"]])
|
||||
self.assertEqual((sent["tall"], sent["wide"]), (False, False))
|
||||
self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS)
|
||||
@@ -2509,6 +2565,46 @@ class TestOrchestratorE2E(HubCase):
|
||||
def test_firefox_draws_the_trend_forecast_point_tips_eta_and_tasks_3d_at_three_widths(self):
|
||||
self.check_trend_eta_3d("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||
|
||||
def check_presence(self, browser: str, driver: str, binary: str) -> None:
|
||||
"""Args:
|
||||
browser: the file name prefix of the screenshots and the result.
|
||||
driver: the browser driver script next to this file.
|
||||
binary: the browser to drive.
|
||||
"""
|
||||
record = {"status": "busy", "statusUpdatedAt": int(time.time() * 1000), "name": "Skills session", "running": True}
|
||||
self.httpd.hub.agent_sessions.write_text(json.dumps({"written": time.time(), "sessions": {"s1": record}}), encoding="utf-8")
|
||||
steps = []
|
||||
for width, height in WIDTHS:
|
||||
steps += [
|
||||
{"width": width, "height": height, "url": f"{self.base}?{width}#agents/orbit", "wait": 1200, "probe": PRESENCE_WALK, "shot": str(OUT / f"{browser}-{width}-agents-presence.png")},
|
||||
{"width": width, "height": height, "url": f"{self.base}?{width}-reloaded#agents/orbit", "wait": 1200, "probe": PRESENCE_READ, "shot": str(OUT / f"{browser}-{width}-agents-presence-3d.png")},
|
||||
]
|
||||
data = self.drive(steps, None, driver, binary)
|
||||
(OUT / f"{browser}-presence.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||
pair = ["dead", "working"]
|
||||
order = ["working", "alive", "sleeping", "suspending", "unreachable", "dead", "faces"]
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
walk, read = data["results"][2 * index : 2 * index + 2]
|
||||
with self.subTest(width=width):
|
||||
self.assertNotIn("error", walk)
|
||||
self.assertNotIn("error", read)
|
||||
self.assertEqual(walk["views"], {"orbit": pair, "chord": pair, "swimlane": pair, "tree": pair, "hierarchy": ["dead", "dead", "working", "working"]})
|
||||
self.assertEqual((walk["motion"], walk["profile"]), ("presence-spin", ["working", "Working", "Working"]))
|
||||
self.assertGreaterEqual(walk["shown"][0], 3, walk["shown"])
|
||||
self.assertEqual(walk["shown"][1], 2)
|
||||
self.assertEqual(walk["before"], [[look, True] for look in order])
|
||||
self.assertEqual((walk["hidden"], walk["orbit"], walk["wide"]), ([0, 1], ["dead"], False))
|
||||
self.assertEqual(read["boxes"], [[look, look not in ("working", "faces")] for look in order])
|
||||
self.assertEqual((read["orbit"], read["back"], read["restored"]), (["dead"], pair, walk["shown"]))
|
||||
|
||||
def test_chromium_shows_agent_states_and_3d_faces_switchable_through_a_reload_at_three_widths(self):
|
||||
self.check_presence("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_shows_agent_states_and_3d_faces_switchable_through_a_reload_at_three_widths(self):
|
||||
self.check_presence("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||
|
||||
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
|
||||
signer = self.pin()
|
||||
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Pin decision 61: each agent state from the host's session snapshot, the night state, radio receipts and claim beats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import hub_support # noqa: F401
|
||||
import sessions as client
|
||||
from kvb_orchestrator.presence import SNAPSHOT_STALE, presence, read_snapshot
|
||||
|
||||
NOW = 1_800_000_000.0
|
||||
FRESH = int((NOW - 60) * 1000)
|
||||
OLD = int((NOW - 1800) * 1000)
|
||||
|
||||
|
||||
def record(sid: str, status: str, updated: int, name: str) -> dict:
|
||||
return {"status": status, "statusUpdatedAt": updated, "name": name, "running": sid != "gone"}
|
||||
|
||||
|
||||
def claim(session: str, name: str, beat: float | None) -> dict:
|
||||
return {"session": session, "agent": name, "beat": beat}
|
||||
|
||||
|
||||
class TestPresence(unittest.TestCase):
|
||||
def states(self, sessions: dict[str, dict], parts: dict[str, str], mode: str, claims: list[dict], news: list[dict]) -> dict[str, str]:
|
||||
return presence(sessions, parts, mode, claims, news, NOW)
|
||||
|
||||
def test_each_state_by_day_and_by_night(self):
|
||||
sessions = {sid: record(sid, status, updated, name) for sid, status, updated, name in (
|
||||
("work", "busy", FRESH, "Worker"),
|
||||
("idle", "idle", OLD, "Idler"),
|
||||
("gone", "idle", FRESH, "Ghost"),
|
||||
("stuck", "busy", OLD, "Stuck"),
|
||||
("called", "idle", OLD, "Callee"),
|
||||
("answered", "idle", OLD, "Answerer"),
|
||||
("sleep", "idle", FRESH, "Sleeper"),
|
||||
("park", "busy", FRESH, "Parker"),
|
||||
)}
|
||||
call = NOW - 700
|
||||
news = [
|
||||
{"ts": call, "from": "Operator", "to": ["Callee"], "class": "call"},
|
||||
{"ts": call, "from": "Operator", "to": ["Answerer"], "class": "call"},
|
||||
{"ts": call + 2, "from": "Answerer", "to": ["Operator"], "class": "receipt", "call": call},
|
||||
{"ts": NOW - 60, "from": "Operator", "to": ["Idler"], "class": "call"},
|
||||
]
|
||||
claims = [claim("codex-on", "Codex", NOW - 60), claim("codex-off", "Codex old", NOW - 3600), claim("-", "Nobody", NOW), claim("op", "Operator", NOW)]
|
||||
day = self.states(sessions, {"sleep": "suspend", "park": "drain"}, "day", claims, news)
|
||||
self.assertEqual(day, {
|
||||
"work": "working", "idle": "alive", "gone": "dead", "stuck": "unreachable", "called": "unreachable",
|
||||
"answered": "alive", "sleep": "sleeping", "park": "suspending", "codex-on": "alive", "codex-off": "dead",
|
||||
})
|
||||
night = self.states(sessions, {"idle": "day", "work": "suspend"}, "drain", [], [])
|
||||
self.assertEqual({sid: night[sid] for sid in ("work", "idle", "called", "sleep", "park")}, {
|
||||
"work": "suspending", "idle": "alive", "called": "sleeping", "sleep": "sleeping", "park": "suspending",
|
||||
})
|
||||
|
||||
def test_a_status_update_after_the_call_counts_as_reached(self):
|
||||
news = [{"ts": NOW - 900, "from": "Operator", "to": ["Alias"], "class": "call"}]
|
||||
claims = [claim("later", "Alias", NOW)]
|
||||
self.assertEqual(self.states({"later": record("later", "idle", FRESH, "Later")}, {}, "day", claims, news), {"later": "alive"})
|
||||
self.assertEqual(self.states({"later": record("later", "idle", OLD, "Later")}, {}, "day", claims, news), {"later": "unreachable"})
|
||||
|
||||
def test_a_record_without_running_true_is_dead(self):
|
||||
for value in (False, None, "yes", 1):
|
||||
with self.subTest(value=value):
|
||||
entry = {**record("s", "busy", FRESH, "S"), "running": value}
|
||||
self.assertEqual(self.states({"s": entry}, {}, "day", [], []), {"s": "dead"})
|
||||
|
||||
|
||||
class TestSnapshot(unittest.TestCase):
|
||||
def test_the_host_client_keeps_name_status_and_whether_the_process_runs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
proc, folder = base / "proc", base / "sessions"
|
||||
(proc / "4242").mkdir(parents=True)
|
||||
folder.mkdir()
|
||||
(proc / "4242" / "stat").write_text("4242 (claude (main)) S " + " ".join(str(n) for n in range(1, 19)) + " 777 0 0\n", encoding="utf-8")
|
||||
live = {"sessionId": "s1", "pid": 4242, "procStart": "777", "status": "busy", "statusUpdatedAt": FRESH, "name": "One", "cwd": "/secret"}
|
||||
(folder / "1.json").write_text(json.dumps(live), encoding="utf-8")
|
||||
(folder / "2.json").write_text(json.dumps({**live, "sessionId": "s2", "procStart": "778"}), encoding="utf-8")
|
||||
(folder / "3.json").write_text(json.dumps({"status": "idle"}), encoding="utf-8")
|
||||
(folder / "4.json").write_text("{broken", encoding="utf-8")
|
||||
(folder / "5.json").write_text(json.dumps(["s5"]), encoding="utf-8")
|
||||
output = base / "agent-sessions.json"
|
||||
client.write(output, client.snapshot(folder, proc, NOW))
|
||||
kept = {"status": "busy", "statusUpdatedAt": FRESH, "name": "One"}
|
||||
self.assertEqual(json.loads(output.read_text(encoding="utf-8")), {
|
||||
"written": NOW, "sessions": {"s1": {**kept, "running": True}, "s2": {**kept, "running": False}},
|
||||
})
|
||||
self.assertEqual(sorted(path.name for path in base.iterdir()), ["agent-sessions.json", "proc", "sessions"])
|
||||
self.assertEqual(client.snapshot(folder / "missing", proc, NOW), {"written": NOW, "sessions": {}})
|
||||
|
||||
def test_the_host_client_skips_hostile_records_and_leaves_no_temp_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
proc, folder = base / "proc", base / "sessions"
|
||||
proc.mkdir()
|
||||
folder.mkdir()
|
||||
good = {"sessionId": "s1", "pid": 1, "procStart": "1", "status": "busy", "statusUpdatedAt": FRESH, "name": "One"}
|
||||
(base / "outside.json").write_text(json.dumps({**good, "sessionId": "s9"}), encoding="utf-8")
|
||||
(folder / "1.json").write_text(json.dumps({**good, "name": {"x": 1}, "status": "y" * 201, "statusUpdatedAt": True}), encoding="utf-8")
|
||||
(folder / "2.json").symlink_to(base / "outside.json")
|
||||
os.mkfifo(folder / "3.json")
|
||||
(folder / "4.json").write_text("[" * 30000 + "]" * 30000, encoding="utf-8")
|
||||
(folder / "5.json").write_text(json.dumps({**good, "sessionId": "s5", "name": "z" * client.RECORD_BYTES}), encoding="utf-8")
|
||||
self.assertEqual(client.snapshot(folder, proc, NOW), {"written": NOW, "sessions": {"s1": {"running": False}}})
|
||||
output = base / "agent-sessions.json"
|
||||
with self.assertRaises(TypeError):
|
||||
client.write(output, {"written": object()})
|
||||
self.assertEqual(sorted(path.name for path in base.iterdir()), ["outside.json", "proc", "sessions"])
|
||||
|
||||
def test_running_needs_an_int_pid_and_the_same_process_start(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proc = Path(tmp)
|
||||
(proc / "4242").mkdir()
|
||||
(proc / "4242" / "stat").write_text("4242 (claude (main)) S " + " ".join(str(n) for n in range(1, 19)) + " 777 0 0\n", encoding="utf-8")
|
||||
entry = {"pid": 4242, "procStart": "777"}
|
||||
self.assertTrue(client.running(entry, proc))
|
||||
for bad in ({**entry, "procStart": "778"}, {**entry, "pid": 4243}, {**entry, "pid": True}, {**entry, "pid": "4242"}, {"pid": 4242}):
|
||||
with self.subTest(bad=bad):
|
||||
self.assertFalse(client.running(bad, proc))
|
||||
|
||||
def test_the_hub_reads_only_a_fresh_well_formed_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "agent-sessions.json"
|
||||
self.assertEqual(read_snapshot(path, NOW), {})
|
||||
entry = record("s1", "busy", FRESH, "One")
|
||||
for data, expected in (
|
||||
({"written": NOW - SNAPSHOT_STALE, "sessions": {"s1": entry, "s2": "x"}}, {"s1": entry}),
|
||||
({"written": NOW - SNAPSHOT_STALE - 1, "sessions": {"s1": entry}}, {}),
|
||||
({"written": True, "sessions": {"s1": entry}}, {}),
|
||||
({"written": NOW + 1, "sessions": {"s1": entry}}, {}),
|
||||
({"written": float("nan"), "sessions": {"s1": entry}}, {}),
|
||||
({"written": NOW, "sessions": ["s1"]}, {}),
|
||||
(["s1"], {}),
|
||||
):
|
||||
with self.subTest(data=data):
|
||||
path.write_text(json.dumps(data), encoding="utf-8")
|
||||
self.assertEqual(read_snapshot(path, NOW), expected)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -151,7 +151,7 @@ class TestHubState(HubCase):
|
||||
set(state),
|
||||
{
|
||||
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
||||
"register", "tasks", "task_edges", "agent_order", "radio", "claims", "grants", "night", "silence", "graph",
|
||||
"register", "tasks", "task_edges", "agent_order", "radio", "claims", "grants", "night", "night_parts", "presence", "silence", "graph",
|
||||
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
||||
"badges", "kinds",
|
||||
},
|
||||
|
||||
@@ -122,6 +122,7 @@ class TestOrchestratorMigration(unittest.TestCase):
|
||||
self.assertEqual((local / f"{NEW}-approvals" / "runner.json").read_text(encoding="utf-8"), '{"used": {}}\n')
|
||||
self.assertEqual(((config / NEW).stat().st_mode & 0o777, (config / NEW / "approvers.json").stat().st_mode & 0o777), (0o700, 0o600))
|
||||
self.assertTrue((units / f"{NEW}-runner.service").is_file())
|
||||
self.assertTrue((units / f"{NEW}-sessions.timer").is_file())
|
||||
self.assertIn(f"KVB_ORCHESTRATOR_STATE={local / NEW}", (share / NEW / ".env").read_text(encoding="utf-8"))
|
||||
settings = self.home / ".claude" / "settings.json"
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
@@ -130,7 +131,7 @@ class TestOrchestratorMigration(unittest.TestCase):
|
||||
self.assertIn(f"{share / NEW}/client/permission_hook.py", hooks[1]["hooks"][0]["command"])
|
||||
self.assertEqual(settings.stat().st_mode & 0o777, 0o640)
|
||||
self.assertIn(f"{OLD}/client", (self.home / ".claude" / f"settings.json.before-{NEW}-migration").read_text(encoding="utf-8"))
|
||||
self.assertEqual((first["images"], first["mcp"], first["enabled"]), ([f"{NEW}:local"], {NEW: "http://127.0.0.1:8737/mcp"}, [f"{NEW}-offices.path", f"{NEW}-runner.service"]))
|
||||
self.assertEqual((first["images"], first["mcp"], first["enabled"]), ([f"{NEW}:local"], {NEW: "http://127.0.0.1:8737/mcp"}, [f"{NEW}-offices.path", f"{NEW}-runner.service", f"{NEW}-sessions.timer"]))
|
||||
self.assertIn(["docker", "compose", "--file", str(share / OLD / "compose.yml"), "down", "--remove-orphans"], first["calls"])
|
||||
|
||||
before = snapshot(self.home)
|
||||
|
||||
Reference in new issue
Block a user