feat(orchestrator): hierarchy view from council offices with a separate agent order
The Agents views gain a Hierarchie view: an org chart of the chair and the line offices read from the council claims. The operator appoints, hands over and recalls an office there; the page signs each change with the passkey and a host unit (client/offices.py, offices path unit) verifies the signature, removes a live holder itself and runs a plain council.py claim for the successor. council.py loses the --operator flag, so no agent can override a live holder. The agent order is now separate from task priority: rasci.py agents order stores it in agent-order.json beside the register through the nofollow helpers and never writes a --prio pin. It only breaks ties of equal task rank, also among the queue grants. The Hierarchie view edits it as a drag list. Host units run the installed skill scripts, so orchestrator.sh keeps the offices unit off while the installed council.py, nofollow.py or rasci.py differ from the tree. Job files, council files and hub records are read with O_NOFOLLOW and O_NONBLOCK and size caps; nofollow.rewrite replaces a claim without creating directories, so a beat never revives a released office. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 79 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8e180be256
commit
4540494c30
53 files changed
+1820
-210
No files matched your search
@@ -407,6 +407,8 @@ The global install also runs the Agent Orchestrator, a local review page in whic
|
|||||||
|
|
||||||
The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Löschen" on a `merged-clean` branch without skip reason, or "Alle gemergten löschen", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch <name>` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`.
|
The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Löschen" on a `merged-clean` branch without skip reason, or "Alle gemergten löschen", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch <name>` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`.
|
||||||
|
|
||||||
|
The Agents view "Hierarchie" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/<slug>`) and presidents (`work/<slug>`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim <office>` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin.
|
||||||
|
|
||||||
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
||||||
|
|
||||||
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
|
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
|
||||||
|
|||||||
Executable
+309
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Args:
|
||||||
|
--port: port of the Agent Orchestrator; the signature's origin is
|
||||||
|
http://localhost:<port>.
|
||||||
|
--jobs: the directory the hub writes signed office orders into, e.g.
|
||||||
|
~/.local/state/kvb-orchestrator/offices/jobs.
|
||||||
|
--results: the directory each job's result goes to as <id>.json.
|
||||||
|
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||||
|
--replay: this runner's replay store, outside the hub's reach, e.g.
|
||||||
|
~/.local/state/kvb-orchestrator-approvals/offices.json.
|
||||||
|
--council: the council directory every agent shares, e.g.
|
||||||
|
/tmp/agents-council.
|
||||||
|
--script: the installed council.py of the kvb-org-organize skill.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Runs once over every job in --jobs, as the operator on the host: per item
|
||||||
|
it verifies the operator's passkey signature over action, office, holder,
|
||||||
|
session and work, refuses a council directory others can write and an
|
||||||
|
office path holding a link, and checks that the office still has the
|
||||||
|
signed holder. A takeover or recall then moves the office aside, reads its
|
||||||
|
claim there and removes it only while that claim names the signed holder,
|
||||||
|
else it puts the office back; an appoint or takeover ends in a plain
|
||||||
|
council.py claim for the successor, run as an argument list, never through
|
||||||
|
a shell: a live Claude Code successor goes in as CLAUDE_CODE_SESSION_ID,
|
||||||
|
any other as --session. Writes each item's result and removes the job.
|
||||||
|
The exit code is 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import errno
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import signing
|
||||||
|
import verifier
|
||||||
|
|
||||||
|
JOB = re.compile(r"[0-9a-f]{16}\.json")
|
||||||
|
SAFE_PATH = "/usr/local/bin:/usr/bin:/bin"
|
||||||
|
CLAIM = "claim.json"
|
||||||
|
CLAIM_SHAPE = (("session", str), ("runtime", str), ("beat", (int, float)))
|
||||||
|
DIRECTORY = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||||
|
FILE = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
|
||||||
|
READ_LIMIT = 1 << 20
|
||||||
|
OUTPUT_LIMIT = 4000
|
||||||
|
TIMEOUT = 120
|
||||||
|
|
||||||
|
|
||||||
|
def read_regular(name: str | Path, dir_fd: int | None) -> bytes:
|
||||||
|
"""Args:
|
||||||
|
name: the file, relative to dir_fd or a path; a link, FIFO or other
|
||||||
|
non-regular file raises OSError, so does one over READ_LIMIT bytes.
|
||||||
|
dir_fd: the open directory name is relative to, or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The file's bytes.
|
||||||
|
"""
|
||||||
|
fd = os.open(name, FILE, dir_fd=dir_fd)
|
||||||
|
with open(fd, "rb") as handle:
|
||||||
|
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||||
|
raise OSError(errno.EINVAL, "not a regular file", str(name))
|
||||||
|
data = handle.read(READ_LIMIT + 1)
|
||||||
|
if len(data) > READ_LIMIT:
|
||||||
|
raise OSError(errno.EFBIG, f"larger than {READ_LIMIT} bytes", str(name))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def claim_in(office_fd: int) -> dict | None:
|
||||||
|
"""Returns:
|
||||||
|
The claim of the open office directory in the shape council.py
|
||||||
|
reads, or None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
claim = json.loads(read_regular(CLAIM, office_fd))
|
||||||
|
except (OSError, ValueError, RecursionError):
|
||||||
|
return None
|
||||||
|
shaped = isinstance(claim, dict) and all(isinstance(claim.get(key), kind) for key, kind in CLAIM_SHAPE)
|
||||||
|
return claim if shaped else None
|
||||||
|
|
||||||
|
|
||||||
|
def parent_of(council: Path, office: str) -> tuple[int, str]:
|
||||||
|
"""Returns:
|
||||||
|
The open directory holding the office, without following a link on
|
||||||
|
the way, and the office's last name; the caller closes it.
|
||||||
|
"""
|
||||||
|
*dirs, name = office.split("/")
|
||||||
|
fd = os.open(council, DIRECTORY)
|
||||||
|
try:
|
||||||
|
for part in dirs:
|
||||||
|
child = os.open(part, DIRECTORY, dir_fd=fd)
|
||||||
|
os.close(fd)
|
||||||
|
fd = child
|
||||||
|
except BaseException:
|
||||||
|
os.close(fd)
|
||||||
|
raise
|
||||||
|
return fd, name
|
||||||
|
|
||||||
|
|
||||||
|
def unsafe(council: Path, office: str) -> str | None:
|
||||||
|
"""Returns:
|
||||||
|
Why the council directory or the office's path below it must not be
|
||||||
|
used, or None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = council.lstat()
|
||||||
|
except OSError as error:
|
||||||
|
return f"the council directory is unreadable: {error.strerror}"
|
||||||
|
if not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o022:
|
||||||
|
return f"{council} is no directory of uid {os.getuid()}, or others can write it"
|
||||||
|
path = council
|
||||||
|
for part in office.split("/"):
|
||||||
|
path = path / part
|
||||||
|
try:
|
||||||
|
found = path.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if not stat.S_ISDIR(found.st_mode):
|
||||||
|
return f"{path} is a link or no directory"
|
||||||
|
try:
|
||||||
|
found = (path / CLAIM).lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
return None if stat.S_ISREG(found.st_mode) else f"{path / CLAIM} is a link or no file"
|
||||||
|
|
||||||
|
|
||||||
|
def holder_of(council: Path, office: str) -> str | None:
|
||||||
|
"""Returns:
|
||||||
|
The session holding the office, empty while it is vacant, None while
|
||||||
|
its claim is missing, unreadable or not in the shape council.py reads.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parent, name = parent_of(council, office)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
fd = os.open(name, DIRECTORY, dir_fd=parent)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
os.close(parent)
|
||||||
|
try:
|
||||||
|
claim = claim_in(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return claim["session"] if claim else None
|
||||||
|
|
||||||
|
|
||||||
|
def vacate(council: Path, office: str, holder: str) -> str | None:
|
||||||
|
"""Args:
|
||||||
|
council: the council directory.
|
||||||
|
office: the office to empty.
|
||||||
|
holder: the session the operator signed as its holder.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None once the office is gone, else why nothing changed.
|
||||||
|
"""
|
||||||
|
parent, name = parent_of(council, office)
|
||||||
|
try:
|
||||||
|
tomb = f"{name}.recalled-{os.getpid()}-{time.time_ns()}"
|
||||||
|
try:
|
||||||
|
os.rename(name, tomb, src_dir_fd=parent, dst_dir_fd=parent)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return f"the holder changed since the signature: signed {holder}, now vacant; nothing changed"
|
||||||
|
try:
|
||||||
|
fd = os.open(tomb, DIRECTORY, dir_fd=parent)
|
||||||
|
except OSError:
|
||||||
|
claim = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
claim = claim_in(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
if claim is not None and claim["session"] == holder:
|
||||||
|
shutil.rmtree(tomb, dir_fd=parent)
|
||||||
|
return None
|
||||||
|
now_held = "unreadable" if claim is None else claim["session"]
|
||||||
|
try:
|
||||||
|
os.lstat(name, dir_fd=parent)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Python has no RENAME_NOREPLACE: rename(2) replaces an empty office a claimant made after the lstat.
|
||||||
|
os.rename(tomb, name, src_dir_fd=parent, dst_dir_fd=parent)
|
||||||
|
return f"the holder changed since the signature: signed {holder}, now {now_held}; nothing changed"
|
||||||
|
kept = council.joinpath(*office.split("/")[:-1], tomb)
|
||||||
|
return f"the holder changed since the signature: signed {holder}, now {now_held}; a new claim arrived meanwhile, the old office waits at {kept}"
|
||||||
|
finally:
|
||||||
|
os.close(parent)
|
||||||
|
|
||||||
|
|
||||||
|
def live(home: Path, session: str) -> bool:
|
||||||
|
for path in (home / ".claude" / "sessions").glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
if isinstance(data, dict) and data.get("sessionId") == session:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Offices:
|
||||||
|
def __init__(self, trust: verifier.Trust, council: Path, script: Path, home: Path) -> None:
|
||||||
|
self.trust = trust
|
||||||
|
self.council = council
|
||||||
|
self.script = script
|
||||||
|
self.home = home
|
||||||
|
|
||||||
|
def command(self, item: dict) -> tuple[list[str], dict[str, str]]:
|
||||||
|
argv = [sys.executable, "-I", "-B", str(self.script), "claim", item["office"], f"--work={item['work']}"]
|
||||||
|
env = {"PATH": SAFE_PATH, "HOME": str(self.home), "AGENTS_COUNCIL_ROOT": str(self.council)}
|
||||||
|
if live(self.home, item["session"]):
|
||||||
|
return argv, {**env, "CLAUDE_CODE_SESSION_ID": item["session"]}
|
||||||
|
return [*argv, f"--session={item['session']}"], env
|
||||||
|
|
||||||
|
def item(self, item: dict, proof: object, now: float) -> dict:
|
||||||
|
"""Args:
|
||||||
|
item: one well-formed office order.
|
||||||
|
proof: the job's {"payload", "assertion"}.
|
||||||
|
now: the current time in epoch seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The order with status done, failed or refused, the reason, the
|
||||||
|
exit code and the output of council.py; a refused order changed
|
||||||
|
nothing.
|
||||||
|
"""
|
||||||
|
def ended(status: str, reason: str, code: int | None, output: str) -> dict:
|
||||||
|
return {**item, "status": status, "reason": reason, "code": code, "output": output}
|
||||||
|
|
||||||
|
try:
|
||||||
|
verifier.check(proof, signing.office_item(**item), "", self.trust, now)
|
||||||
|
except (signing.Refused, OSError) as error:
|
||||||
|
return ended("refused", f"no valid operator signature: {error}", None, "")
|
||||||
|
reason = unsafe(self.council, item["office"])
|
||||||
|
if reason:
|
||||||
|
return ended("refused", reason, None, "")
|
||||||
|
current = holder_of(self.council, item["office"])
|
||||||
|
if current != item["holder"]:
|
||||||
|
now_held = "unreadable" if current is None else current or "vacant"
|
||||||
|
return ended("refused", f"the holder changed since the signature: signed {item['holder'] or 'vacant'}, now {now_held}", None, "")
|
||||||
|
if item["action"] != "appoint":
|
||||||
|
try:
|
||||||
|
reason = vacate(self.council, item["office"], item["holder"])
|
||||||
|
except OSError as error:
|
||||||
|
return ended("failed", f"the office could not be emptied: {error.strerror or error}; nothing changed", None, "")
|
||||||
|
if reason:
|
||||||
|
return ended("refused", reason, None, "")
|
||||||
|
if item["action"] == "recall":
|
||||||
|
return ended("done", "", None, f"recalled {item['office']} from {item['holder']}")
|
||||||
|
argv, env = self.command(item)
|
||||||
|
try:
|
||||||
|
done = subprocess.run(argv, cwd="/", env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace", timeout=TIMEOUT, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
return ended("failed", f"{error}; the office is vacant now", 127, "")
|
||||||
|
output = (done.stdout + done.stderr)[-OUTPUT_LIMIT:]
|
||||||
|
if done.returncode == 0:
|
||||||
|
return ended("done", "", 0, output)
|
||||||
|
vacant = "; the office is vacant now" if item["action"] == "takeover" else ""
|
||||||
|
return ended("failed", f"{output.strip()[-500:]}{vacant}", done.returncode, output)
|
||||||
|
|
||||||
|
def job(self, path: Path, now: float) -> dict:
|
||||||
|
try:
|
||||||
|
job = json.loads(read_regular(path, None))
|
||||||
|
except (OSError, ValueError, RecursionError) as error:
|
||||||
|
return {"id": path.stem, "finished": now, "items": [], "error": f"unreadable job: {error}"}
|
||||||
|
items = job.get("items") if isinstance(job, dict) else None
|
||||||
|
if not isinstance(items, list) or not items or any(signing.office_problem(item) for item in items) or job.get("id") != path.stem:
|
||||||
|
return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing changed"}
|
||||||
|
results = [self.item(item, job.get("proof"), now) for item in items]
|
||||||
|
return {"id": path.stem, "finished": time.time(), "items": results}
|
||||||
|
|
||||||
|
|
||||||
|
def cycle(jobs: Path, results: Path, offices: Offices) -> int:
|
||||||
|
handled = 0
|
||||||
|
for path in sorted(jobs.iterdir()):
|
||||||
|
if JOB.fullmatch(path.name):
|
||||||
|
verifier.write_private(results / path.name, offices.job(path, time.time()))
|
||||||
|
handled += 1
|
||||||
|
if path.is_dir() and not path.is_symlink():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return handled
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="offices")
|
||||||
|
parser.add_argument("--port", type=int, required=True)
|
||||||
|
for name in ("--jobs", "--results", "--approvers", "--replay", "--council", "--script"):
|
||||||
|
parser.add_argument(name, type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||||
|
args.results.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
cycle(args.jobs, args.results, Offices(trust, args.council, args.script, Path.home()))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -16,6 +16,12 @@ HASH = re.compile(r"[0-9a-f]{64}")
|
|||||||
NONCE = re.compile(r"[0-9a-f]{32}")
|
NONCE = re.compile(r"[0-9a-f]{32}")
|
||||||
ID = re.compile(r"[0-9a-f]{16}")
|
ID = re.compile(r"[0-9a-f]{16}")
|
||||||
CHAINED = re.compile(r"[;&|`$<>()\n\r]")
|
CHAINED = re.compile(r"[;&|`$<>()\n\r]")
|
||||||
|
OFFICE = re.compile(r"chair|[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._-]{0,63}")
|
||||||
|
SESSION = re.compile(r"(?!-)[A-Za-z0-9_-]{1,128}")
|
||||||
|
WORK = re.compile(r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{1,300}")
|
||||||
|
HOLDER = re.compile(r"[^\x00-\x1f\x7f]{0,200}")
|
||||||
|
OFFICE_ACTIONS = ("appoint", "takeover", "recall")
|
||||||
|
OFFICE_KEYS = ("action", "office", "holder", "session", "work")
|
||||||
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
||||||
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
||||||
WARNINGS = (
|
WARNINGS = (
|
||||||
@@ -69,6 +75,38 @@ def cleanup_item(repository: str, branch: str, worktree: str, tip: str) -> dict:
|
|||||||
return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip}
|
return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip}
|
||||||
|
|
||||||
|
|
||||||
|
def office_item(action: str, office: str, holder: str, session: str, work: str) -> dict:
|
||||||
|
return {"kind": "office", "action": action, "office": office, "holder": holder, "session": session, "work": work}
|
||||||
|
|
||||||
|
|
||||||
|
def office_problem(order: object) -> str | None:
|
||||||
|
"""Args:
|
||||||
|
order: {"action", "office", "holder", "session", "work"}: appoint
|
||||||
|
gives a vacant office to the session, takeover hands the
|
||||||
|
holder's office to the session, recall takes it from the
|
||||||
|
holder; work is the claim's one-line --work.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Why the order is malformed, or None.
|
||||||
|
"""
|
||||||
|
if not isinstance(order, dict) or set(order) != set(OFFICE_KEYS) or not all(isinstance(order[name], str) for name in OFFICE_KEYS):
|
||||||
|
return "an office order holds exactly action, office, holder, session and work as text"
|
||||||
|
action, office, holder, session, work = (order[name] for name in OFFICE_KEYS)
|
||||||
|
if action not in OFFICE_ACTIONS:
|
||||||
|
return f"action must be one of {', '.join(OFFICE_ACTIONS)}"
|
||||||
|
if not OFFICE.fullmatch(office):
|
||||||
|
return "office must be chair, <dimension>/<line>, cluster/<slug> or work/<slug>"
|
||||||
|
if not HOLDER.fullmatch(holder) or (action == "appoint") != (holder == ""):
|
||||||
|
return "appoint names no holder, takeover and recall the current one"
|
||||||
|
if action == "recall":
|
||||||
|
return None if session == work == "" else "a recall names no successor and no work"
|
||||||
|
if not SESSION.fullmatch(session) or session == holder:
|
||||||
|
return "session must be the successor's session id, not the holder's"
|
||||||
|
if not WORK.fullmatch(work):
|
||||||
|
return "work must be one line of 1 to 300 characters"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ services:
|
|||||||
- /app/vendor
|
- /app/vendor
|
||||||
- --cleanup
|
- --cleanup
|
||||||
- /state/cleanup
|
- /state/cleanup
|
||||||
|
- --offices
|
||||||
|
- /state/offices
|
||||||
- --llm-url
|
- --llm-url
|
||||||
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
||||||
- --llm-model
|
- --llm-model
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .asking import ask_state
|
|||||||
from .cleanup import cleanup_request
|
from .cleanup import cleanup_request
|
||||||
from .errors import Conflict, Missing, ToolError
|
from .errors import Conflict, Missing, ToolError
|
||||||
from .fields import decode, expect, field
|
from .fields import decode, expect, field
|
||||||
|
from .offices import office_request
|
||||||
from .records import digest, shown
|
from .records import digest, shown
|
||||||
from .register import REGISTER, operator_rows, read_register, runnable
|
from .register import REGISTER, operator_rows, read_register, runnable
|
||||||
from .rules import rule_matches
|
from .rules import rule_matches
|
||||||
@@ -144,7 +145,12 @@ class Approving:
|
|||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("items",))
|
expect(payload, ("items",))
|
||||||
raw = payload["items"]
|
raw = payload["items"]
|
||||||
signed = self.deletable(raw) if cleanup_request(raw) else [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
|
if cleanup_request(raw):
|
||||||
|
signed = self.deletable(raw)
|
||||||
|
elif office_request(raw):
|
||||||
|
signed = self.appointable(raw)
|
||||||
|
else:
|
||||||
|
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
|
||||||
hashes = [signing.item_hash(item) for item in signed]
|
hashes = [signing.item_hash(item) for item in signed]
|
||||||
credentials = [key["id"] for key in self.keys()]
|
credentials = [key["id"] for key in self.keys()]
|
||||||
if not hashes:
|
if not hashes:
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ MODES = ("evening", "sunset")
|
|||||||
RADIO_LIMIT = 5000
|
RADIO_LIMIT = 5000
|
||||||
GRANT_LIMIT = 50
|
GRANT_LIMIT = 50
|
||||||
MAYDAY_ACTIVE = 7200
|
MAYDAY_ACTIVE = 7200
|
||||||
|
CHAIR_OFFICE = "chair"
|
||||||
|
GROUPS = ("work", "cluster")
|
||||||
|
CLAIM_FILES = ("*/claim.json", "*/*/claim.json")
|
||||||
|
OFFICE = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)?")
|
||||||
|
|
||||||
|
|
||||||
def agent(name: object) -> str:
|
def agent(name: object) -> str:
|
||||||
@@ -74,10 +78,33 @@ def radio_rows(rows: list[dict], begin: float, end: float) -> list[dict]:
|
|||||||
return sorted(found, key=lambda row: row["ts"])[-RADIO_LIMIT:]
|
return sorted(found, key=lambda row: row["ts"])[-RADIO_LIMIT:]
|
||||||
|
|
||||||
|
|
||||||
def claim_row(data: dict) -> dict:
|
def office_kind(office: str) -> tuple[str, str, str]:
|
||||||
|
"""Args:
|
||||||
|
office: an office of one or two segments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(kind, dimension, line): kind is chair, line for <dimension>/<line>,
|
||||||
|
work or cluster for their groups, other for any other single
|
||||||
|
segment; dimension and line are empty unless the kind is line.
|
||||||
|
"""
|
||||||
|
head, _, rest = office.partition("/")
|
||||||
|
if office == CHAIR_OFFICE:
|
||||||
|
return "chair", "", ""
|
||||||
|
if not rest:
|
||||||
|
return "other", "", ""
|
||||||
|
if head in GROUPS:
|
||||||
|
return head, "", ""
|
||||||
|
return "line", head, rest
|
||||||
|
|
||||||
|
|
||||||
|
def claim_row(office: str, data: dict) -> dict:
|
||||||
name = data.get("name")
|
name = data.get("name")
|
||||||
|
kind, dimension, line = office_kind(office)
|
||||||
return {
|
return {
|
||||||
"office": shown(data.get("office")),
|
"office": office,
|
||||||
|
"kind": kind,
|
||||||
|
"dimension": dimension,
|
||||||
|
"line": line,
|
||||||
"agent": agent(name),
|
"agent": agent(name),
|
||||||
"address": name.strip() if isinstance(name, str) else "",
|
"address": name.strip() if isinstance(name, str) else "",
|
||||||
"session": shown(data.get("session")),
|
"session": shown(data.get("session")),
|
||||||
@@ -89,8 +116,14 @@ def claim_row(data: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def read_claims(council: Path) -> list[dict]:
|
def read_claims(council: Path) -> list[dict]:
|
||||||
paths = sorted([*council.glob("cluster/*/claim.json"), *council.glob("work/*/claim.json")])
|
found = []
|
||||||
return [claim_row(data) for data in map(read_json, paths) if isinstance(data, dict)]
|
for pattern in CLAIM_FILES:
|
||||||
|
for path in council.glob(pattern):
|
||||||
|
office = path.parent.relative_to(council).as_posix()
|
||||||
|
data = read_json(path)
|
||||||
|
if OFFICE.fullmatch(office) and isinstance(data, dict):
|
||||||
|
found.append(claim_row(office, data))
|
||||||
|
return sorted(found, key=lambda row: row["office"])
|
||||||
|
|
||||||
|
|
||||||
def sessions(claims: list[dict]) -> dict[str, str]:
|
def sessions(claims: list[dict]) -> dict[str, str]:
|
||||||
|
|||||||
@@ -2,13 +2,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from .council import HUMAN, agent, ordered, subagent
|
from .council import CHAIR_OFFICE, HUMAN, agent, ordered, subagent
|
||||||
from .names import Directory
|
from .names import Directory
|
||||||
from .orbits import affinity
|
from .orbits import affinity
|
||||||
from .records import shown
|
from .records import shown
|
||||||
from .register import ENDED, parties, status_of
|
from .register import ENDED, parties, status_of
|
||||||
|
|
||||||
CHAIR = "Maintainer"
|
|
||||||
|
def chair_of(claims: list[dict], directory: Directory, end: float) -> str:
|
||||||
|
"""Returns:
|
||||||
|
The node key of the chair office's holder, empty while it is vacant.
|
||||||
|
"""
|
||||||
|
claim = next((claim for claim in claims if claim["office"] == CHAIR_OFFICE), None)
|
||||||
|
if claim is None:
|
||||||
|
return ""
|
||||||
|
if claim["session"] != "-":
|
||||||
|
return claim["session"]
|
||||||
|
return directory.key(claim["agent"], end)
|
||||||
|
|
||||||
|
|
||||||
def parent_of(roles: dict[str, str], known: set[str], fallback: str) -> str:
|
def parent_of(roles: dict[str, str], known: set[str], fallback: str) -> str:
|
||||||
@@ -36,13 +46,14 @@ def edges(radio: list[dict], grant_rows: list[dict], chair: str) -> list[dict]:
|
|||||||
|
|
||||||
def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], claims: list[dict], end: float, directory: Directory) -> dict:
|
def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], claims: list[dict], end: float, directory: Directory) -> dict:
|
||||||
current = {task: row for task, row in sorted(tasks.items()) if status_of(row) not in ENDED}
|
current = {task: row for task, row in sorted(tasks.items()) if status_of(row) not in ENDED}
|
||||||
chair = directory.key(CHAIR, end)
|
chair = chair_of(claims, directory, end)
|
||||||
|
top = chair or HUMAN
|
||||||
workers = {task: agent(row.get("worker")) for task, row in current.items()}
|
workers = {task: agent(row.get("worker")) for task, row in current.items()}
|
||||||
keys = {row["source"] for row in radio} | {key for row in radio for key in row["targets"]}
|
keys = {row["source"] for row in radio} | {key for row in radio for key in row["targets"]}
|
||||||
keys |= {claim["session"] for claim in claims if claim["agent"] and claim["session"] != "-"}
|
keys |= {claim["session"] for claim in claims if claim["agent"] and claim["session"] != "-"}
|
||||||
keys |= {directory.key(name, end) for name in workers.values() if not subagent(name)}
|
keys |= {directory.key(name, end) for name in workers.values() if not subagent(name)}
|
||||||
found = sorted(keys - {"", HUMAN, chair}, key=lambda key: (directory.label(key, end).lower(), key))
|
found = sorted(keys - {"", HUMAN, chair}, key=lambda key: (directory.label(key, end).lower(), key))
|
||||||
known = {chair, *found}
|
known = {chair, *found} - {""}
|
||||||
moons: dict[str, tuple[str, str]] = {}
|
moons: dict[str, tuple[str, str]] = {}
|
||||||
holders: dict[str, dict[str, dict]] = {}
|
holders: dict[str, dict[str, dict]] = {}
|
||||||
for task, row in current.items():
|
for task, row in current.items():
|
||||||
@@ -53,14 +64,15 @@ def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], cla
|
|||||||
worker = workers[task]
|
worker = workers[task]
|
||||||
holder = directory.key(worker, end)
|
holder = directory.key(worker, end)
|
||||||
if subagent(worker):
|
if subagent(worker):
|
||||||
parent = parent_of(roles, known, chair)
|
parent = parent_of(roles, known, top)
|
||||||
holder = f"{worker} @ {parent}"
|
holder = f"{worker} @ {parent}"
|
||||||
moons[holder] = (parent, worker)
|
moons[holder] = (parent, worker)
|
||||||
if holder:
|
if holder:
|
||||||
duties(holder, task, row, holders)["worker"] = True
|
duties(holder, task, row, holders)["worker"] = True
|
||||||
for key, letters in roles.items():
|
for key, letters in roles.items():
|
||||||
duties(key, task, row, holders)["letters"] += letters
|
duties(key, task, row, holders)["letters"] += letters
|
||||||
plan = [(HUMAN, "operator", None, HUMAN), (chair, "chair", None, directory.label(chair, end))]
|
plan = [(HUMAN, "operator", None, HUMAN)]
|
||||||
|
plan += [(chair, "chair", None, directory.label(chair, end))] if chair else []
|
||||||
plan += [(key, "session", None, directory.label(key, end)) for key in found]
|
plan += [(key, "session", None, directory.label(key, end)) for key in found]
|
||||||
plan += [(moon, "subagent", *moons[moon]) for moon in ordered(set(moons))]
|
plan += [(moon, "subagent", *moons[moon]) for moon in ordered(set(moons))]
|
||||||
nodes = [
|
nodes = [
|
||||||
@@ -77,4 +89,4 @@ def graph(radio: list[dict], grant_rows: list[dict], tasks: dict[str, dict], cla
|
|||||||
for key, node_kind, parent, label in plan
|
for key, node_kind, parent, label in plan
|
||||||
]
|
]
|
||||||
layout = affinity(nodes, radio, tasks, claims, end, lambda name: directory.key(name, end))
|
layout = affinity(nodes, radio, tasks, claims, end, lambda name: directory.key(name, end))
|
||||||
return {"nodes": nodes, "edges": edges(radio, grant_rows, chair), "layout": layout}
|
return {"nodes": nodes, "edges": edges(radio, grant_rows, top), "layout": layout}
|
||||||
@@ -36,10 +36,12 @@ SIGN_ROUTE = "/api/sign"
|
|||||||
RULE_SIGN_ROUTE = "/api/rules/sign"
|
RULE_SIGN_ROUTE = "/api/rules/sign"
|
||||||
CLEANUP_ROUTE = "/api/cleanup"
|
CLEANUP_ROUTE = "/api/cleanup"
|
||||||
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
|
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
|
||||||
|
OFFICES_ROUTE = "/api/offices"
|
||||||
|
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
|
||||||
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
||||||
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
|
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
|
||||||
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
||||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE)
|
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE)
|
||||||
|
|
||||||
|
|
||||||
class Handler(Exchange):
|
class Handler(Exchange):
|
||||||
@@ -59,6 +61,7 @@ class Handler(Exchange):
|
|||||||
proof = PROOF_ROUTE.fullmatch(path)
|
proof = PROOF_ROUTE.fullmatch(path)
|
||||||
vendored = VENDOR_ROUTE.fullmatch(path)
|
vendored = VENDOR_ROUTE.fullmatch(path)
|
||||||
cleanup = CLEANUP_STATE.fullmatch(path)
|
cleanup = CLEANUP_STATE.fullmatch(path)
|
||||||
|
office = OFFICE_STATE.fullmatch(path)
|
||||||
if self.host() is None:
|
if self.host() is None:
|
||||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
||||||
elif path == "/":
|
elif path == "/":
|
||||||
@@ -67,6 +70,8 @@ class Handler(Exchange):
|
|||||||
self.vendored(VENDOR[vendored.group(1)])
|
self.vendored(VENDOR[vendored.group(1)])
|
||||||
elif cleanup:
|
elif cleanup:
|
||||||
self.answer(lambda: hub.cleanup_state(cleanup.group(1)), HTTPStatus.OK)
|
self.answer(lambda: hub.cleanup_state(cleanup.group(1)), HTTPStatus.OK)
|
||||||
|
elif office:
|
||||||
|
self.answer(lambda: hub.office_state(office.group(1)), HTTPStatus.OK)
|
||||||
elif proof:
|
elif proof:
|
||||||
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
||||||
elif path == "/api/state":
|
elif path == "/api/state":
|
||||||
@@ -125,6 +130,8 @@ class Handler(Exchange):
|
|||||||
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
|
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
|
||||||
if path == CLEANUP_ROUTE:
|
if path == CLEANUP_ROUTE:
|
||||||
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
|
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
|
||||||
|
if path == OFFICES_ROUTE:
|
||||||
|
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
|
||||||
if inbox:
|
if inbox:
|
||||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||||
if undo:
|
if undo:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from .ledger import Ledger, Marks, Reviews
|
|||||||
from .mcp import Mcp
|
from .mcp import Mcp
|
||||||
from .messaging import Messaging
|
from .messaging import Messaging
|
||||||
from .names import Names
|
from .names import Names
|
||||||
|
from .offices import Offices
|
||||||
from .reviewing import Reviewing
|
from .reviewing import Reviewing
|
||||||
from .rules import Rules
|
from .rules import Rules
|
||||||
from .settings import Settings
|
from .settings import Settings
|
||||||
@@ -40,6 +41,7 @@ class Places:
|
|||||||
timers: Path
|
timers: Path
|
||||||
vendor: Path
|
vendor: Path
|
||||||
cleanup: Path
|
cleanup: Path
|
||||||
|
offices: Path
|
||||||
|
|
||||||
|
|
||||||
def environment(templates: Path) -> jinja2.Environment:
|
def environment(templates: Path) -> jinja2.Environment:
|
||||||
@@ -55,7 +57,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||||
council: Path
|
council: Path
|
||||||
pages: Path
|
pages: Path
|
||||||
public_port: int
|
public_port: int
|
||||||
@@ -84,6 +86,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules,
|
|||||||
vendor: Path
|
vendor: Path
|
||||||
icons: Markup
|
icons: Markup
|
||||||
spool: Spool
|
spool: Spool
|
||||||
|
office_spool: Spool
|
||||||
touched: Touched
|
touched: Touched
|
||||||
|
|
||||||
def hosts(self) -> tuple[str, str]:
|
def hosts(self) -> tuple[str, str]:
|
||||||
@@ -101,6 +104,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
|||||||
vendor=places.vendor,
|
vendor=places.vendor,
|
||||||
icons=sheet(places.vendor, templates),
|
icons=sheet(places.vendor, templates),
|
||||||
spool=Spool(places.cleanup).ready(),
|
spool=Spool(places.cleanup).ready(),
|
||||||
|
office_spool=Spool(places.offices).ready(),
|
||||||
touched=Touched(reviews.stored),
|
touched=Touched(reviews.stored),
|
||||||
approvers=approvers,
|
approvers=approvers,
|
||||||
speech=Speech(engines, state / "tts"),
|
speech=Speech(engines, state / "tts"),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .errors import ToolError
|
|||||||
from .events import VIEWS
|
from .events import VIEWS
|
||||||
from .fields import bounded, decode, expect, message_text
|
from .fields import bounded, decode, expect, message_text
|
||||||
from .records import read_jsonl
|
from .records import read_jsonl
|
||||||
from .register import REGISTER, priority, read_register
|
from .register import REGISTER, agent_order, read_register
|
||||||
|
|
||||||
NL_LIMIT = 2000
|
NL_LIMIT = 2000
|
||||||
NL_ACTION_LIMIT = 20
|
NL_ACTION_LIMIT = 20
|
||||||
@@ -183,6 +183,6 @@ class Language:
|
|||||||
directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
|
directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
|
||||||
self.watch.see(tasks, now)
|
self.watch.see(tasks, now)
|
||||||
items = self.inbox(now, tasks, directory)
|
items = self.inbox(now, tasks, directory)
|
||||||
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(priority(tasks)))
|
agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(agent_order(tasks, [], [])))
|
||||||
shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items]
|
shown_items = [{"id": item["id"], "kind": item["kind"], "title": item["title"][:200]} for item in items]
|
||||||
return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents)
|
return nl_actions(self.model.complete(nl_messages(text, shown_items, agents)), items, agents)
|
||||||
@@ -8,6 +8,7 @@ from .council import HUMAN, ordered, read_claims
|
|||||||
from .errors import Missing, ToolError
|
from .errors import Missing, ToolError
|
||||||
from .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single
|
from .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single
|
||||||
from .records import write_atomic
|
from .records import write_atomic
|
||||||
|
from .register import REGISTER, agent_order, read_register
|
||||||
from .skill_scripts import NIGHT, RADIO, RASCI
|
from .skill_scripts import NIGHT, RADIO, RASCI
|
||||||
|
|
||||||
NIGHTS = {
|
NIGHTS = {
|
||||||
@@ -111,7 +112,11 @@ class Messaging:
|
|||||||
raise ToolError("agents must be a non-empty list of agent names without commas")
|
raise ToolError("agents must be a non-empty list of agent names without commas")
|
||||||
if len(set(names)) != len(names):
|
if len(set(names)) != len(names):
|
||||||
raise ToolError("agents must be distinct")
|
raise ToolError("agents must be distinct")
|
||||||
runs = [self.scripts.run(RASCI, ["order", ",".join(names)])]
|
known = agent_order(read_register(self.council / REGISTER), read_claims(self.council), [])
|
||||||
|
unknown = [name for name in names if name not in known]
|
||||||
|
if unknown:
|
||||||
|
raise ToolError(f"unknown agents: {', '.join(unknown)[:300]}; the register and the council know {', '.join(known)[:600] or 'none'}")
|
||||||
|
runs = [self.scripts.run(RASCI, ["agents", "order", ",".join(names)])]
|
||||||
elif action in NIGHTS:
|
elif action in NIGHTS:
|
||||||
command, text = NIGHTS[action]
|
command, text = NIGHTS[action]
|
||||||
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
|
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .council import HUMAN, office_kind, read_claims
|
||||||
|
from .errors import Conflict, Missing, ToolError
|
||||||
|
from .fields import decode, expect
|
||||||
|
from .records import read_json, stamp
|
||||||
|
from .signed import signing
|
||||||
|
|
||||||
|
ITEM_LIMIT = 20
|
||||||
|
JOB_VERSION = 1
|
||||||
|
LABELS = {"appoint": "besetzt", "takeover": "übergeben", "recall": "abberufen"}
|
||||||
|
|
||||||
|
|
||||||
|
def office_request(raw: object) -> bool:
|
||||||
|
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == "office" for entry in raw)
|
||||||
|
|
||||||
|
|
||||||
|
class Offices:
|
||||||
|
def office_sessions(self, claims: list[dict]) -> set[str]:
|
||||||
|
named = {entry["session"] for entry in self.names.listed()} | {claim["session"] for claim in claims}
|
||||||
|
return {session for session in named if signing.SESSION.fullmatch(session) and session != HUMAN}
|
||||||
|
|
||||||
|
def appointable(self, raw: object) -> list[dict]:
|
||||||
|
"""Args:
|
||||||
|
raw: the orders the page chose, each {"kind": "office", "action",
|
||||||
|
"office", "holder", "session", "work"}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The signed items: each order well formed, its holder the office's
|
||||||
|
current one, its successor a known session that represents no
|
||||||
|
other line of the same dimension.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list) or not 1 <= len(raw) <= ITEM_LIMIT:
|
||||||
|
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
|
||||||
|
claims = read_claims(self.council)
|
||||||
|
holders = {claim["office"]: claim["session"] for claim in claims}
|
||||||
|
sessions = self.office_sessions(claims)
|
||||||
|
chosen = []
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ToolError("every item must be an object with kind, action, office, holder, session and work")
|
||||||
|
expect(entry, ("kind", *signing.OFFICE_KEYS))
|
||||||
|
if entry["kind"] != "office":
|
||||||
|
raise ToolError("kind must be office")
|
||||||
|
order = {name: entry[name] for name in signing.OFFICE_KEYS}
|
||||||
|
problem = signing.office_problem(order)
|
||||||
|
if problem:
|
||||||
|
raise ToolError(problem)
|
||||||
|
if order["holder"] != holders.get(order["office"], ""):
|
||||||
|
raise Conflict({"office": order["office"], "holder": holders.get(order["office"], "")})
|
||||||
|
if order["action"] != "recall":
|
||||||
|
self.successor(order, sessions, claims)
|
||||||
|
item = signing.office_item(**order)
|
||||||
|
if item in chosen:
|
||||||
|
raise ToolError("items must be distinct")
|
||||||
|
chosen.append(item)
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
def successor(self, order: dict, sessions: set[str], claims: list[dict]) -> None:
|
||||||
|
if order["session"] not in sessions:
|
||||||
|
raise ToolError("session must be a session the Agent Orchestrator knows")
|
||||||
|
kind, dimension, _ = office_kind(order["office"])
|
||||||
|
others = [claim["office"] for claim in claims if claim["session"] == order["session"] and claim["dimension"] == dimension and claim["office"] != order["office"]]
|
||||||
|
if kind == "line" and others:
|
||||||
|
raise ToolError(f"{order['session']} represents {others[0]} already: one line per dimension")
|
||||||
|
|
||||||
|
def appoint(self, body: bytes) -> dict:
|
||||||
|
payload = decode(body)
|
||||||
|
expect(payload, ("items", "proof"))
|
||||||
|
items = self.appointable(payload["items"])
|
||||||
|
proof = self.covered(payload["proof"], items, time.time())
|
||||||
|
job_id = secrets.token_hex(8)
|
||||||
|
job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{name: item[name] for name in signing.OFFICE_KEYS} for item in items], "proof": proof}
|
||||||
|
self.office_spool.put(job_id, job)
|
||||||
|
names = ", ".join(f"{item['office']} {LABELS[item['action']]}" for item in items)
|
||||||
|
lines = "\n".join(f"{item['action']} {item['office']}: {item['holder'] or '-'} -> {item['session'] or '-'}" for item in items)
|
||||||
|
self.log.add({"kind": "run", "title": f"Amt signiert: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}})
|
||||||
|
return {"id": job_id, "status": "queued", "items": job["items"]}
|
||||||
|
|
||||||
|
def office_state(self, job_id: str) -> dict:
|
||||||
|
result = read_json(self.office_spool.results / f"{job_id}.json")
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("items"), list):
|
||||||
|
return {**result, "id": job_id, "status": "done"}
|
||||||
|
if (self.office_spool.jobs / f"{job_id}.json").is_file():
|
||||||
|
return {"id": job_id, "status": "queued", "items": []}
|
||||||
|
raise Missing(job_id)
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import errno
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import stat
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC
|
||||||
|
JSON_LIMIT = 1 << 24
|
||||||
|
|
||||||
|
|
||||||
def stamp() -> str:
|
def stamp() -> str:
|
||||||
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
|
return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")
|
||||||
@@ -20,9 +25,27 @@ def write_atomic(path: Path, data: bytes) -> None:
|
|||||||
os.replace(handle.name, path)
|
os.replace(handle.name, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_regular(path: Path, limit: int | None) -> str:
|
||||||
|
"""Args:
|
||||||
|
path: the file; a link, FIFO or other non-regular file raises OSError
|
||||||
|
without blocking.
|
||||||
|
limit: the most bytes to accept, more raise OSError; None for any size.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The file's text, UTF-8 decoded.
|
||||||
|
"""
|
||||||
|
with open(os.open(path, READ_FLAGS), "rb") as handle:
|
||||||
|
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
||||||
|
raise OSError(errno.EINVAL, "not a regular file", str(path))
|
||||||
|
data = handle.read() if limit is None else handle.read(limit + 1)
|
||||||
|
if limit is not None and len(data) > limit:
|
||||||
|
raise OSError(errno.EFBIG, f"larger than {limit} bytes", str(path))
|
||||||
|
return data.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def read_json(path: Path) -> object:
|
def read_json(path: Path) -> object:
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(read_regular(path, JSON_LIMIT))
|
||||||
except (OSError, ValueError, RecursionError):
|
except (OSError, ValueError, RecursionError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -37,7 +60,7 @@ def parse_line(line: str) -> dict | None:
|
|||||||
|
|
||||||
def read_jsonl(path: Path) -> list[dict]:
|
def read_jsonl(path: Path) -> list[dict]:
|
||||||
try:
|
try:
|
||||||
lines = path.read_text(encoding="utf-8").splitlines()
|
lines = read_regular(path, None).splitlines()
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return []
|
return []
|
||||||
return [row for row in map(parse_line, lines) if row is not None]
|
return [row for row in map(parse_line, lines) if row is not None]
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .council import agent
|
from .council import agent, ordered
|
||||||
from .fields import ORDER_NAME
|
from .fields import ORDER_NAME
|
||||||
from .records import read_json, shown
|
from .records import read_json, shown
|
||||||
|
|
||||||
REGISTER = "rasci.json"
|
REGISTER = "rasci.json"
|
||||||
|
AGENT_ORDER = "agent-order.json"
|
||||||
OPERATOR = "operator/"
|
OPERATOR = "operator/"
|
||||||
ENDED = ("done", "stopped")
|
ENDED = ("done", "stopped")
|
||||||
|
|
||||||
@@ -98,10 +99,31 @@ def register_rows(tasks: dict[str, dict]) -> list[dict]:
|
|||||||
return [register_row(task, row) for task, row in sorted(tasks.items(), key=pinned) if is_open(task, row, False)]
|
return [register_row(task, row) for task, row in sorted(tasks.items(), key=pinned) if is_open(task, row, False)]
|
||||||
|
|
||||||
|
|
||||||
def priority(tasks: dict[str, dict]) -> list[str]:
|
def stored_order(path: Path) -> list[str]:
|
||||||
|
data = read_json(path)
|
||||||
|
names = data.get("agents") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
|
||||||
|
return []
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def agent_order(tasks: dict[str, dict], claims: list[dict], stored: list[str]) -> list[str]:
|
||||||
|
"""Args:
|
||||||
|
tasks: the register.
|
||||||
|
claims: every office claim; its holder is an agent too.
|
||||||
|
stored: the operator's agent order, as `rasci.py agents order` wrote it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The agents `rasci.py agents order` accepts: the stored ones it still
|
||||||
|
knows first, in their order, then the workers of open tasks by pin,
|
||||||
|
then the remaining office holders by name.
|
||||||
|
"""
|
||||||
names: list[str] = []
|
names: list[str] = []
|
||||||
for _, row in sorted(tasks.items(), key=pinned):
|
for _, row in sorted(tasks.items(), key=pinned):
|
||||||
worker = row.get("worker")
|
worker = row.get("worker")
|
||||||
if status_of(row) not in ENDED and isinstance(worker, str) and ORDER_NAME.fullmatch(worker) and worker not in names:
|
if status_of(row) not in ENDED and isinstance(worker, str) and worker not in names:
|
||||||
names.append(worker)
|
names.append(worker)
|
||||||
return names
|
names += [name for name in ordered({claim["address"] or claim["session"] for claim in claims}) if name not in names]
|
||||||
|
known = [name for name in names if ORDER_NAME.fullmatch(name)]
|
||||||
|
listed = [name for name in dict.fromkeys(stored) if name in known]
|
||||||
|
return [*listed, *(name for name in known if name not in listed)]
|
||||||
@@ -22,7 +22,15 @@ from .messaging import message_row
|
|||||||
from .orbits import TRACK_STEPS, track
|
from .orbits import TRACK_STEPS, track
|
||||||
from .phrasing import phrase
|
from .phrasing import phrase
|
||||||
from .records import epoch, iso_epoch, read_jsonl
|
from .records import epoch, iso_epoch, read_jsonl
|
||||||
from .register import REGISTER, operator_rows, priority, read_register, register_rows
|
from .register import (
|
||||||
|
AGENT_ORDER,
|
||||||
|
REGISTER,
|
||||||
|
agent_order,
|
||||||
|
operator_rows,
|
||||||
|
read_register,
|
||||||
|
register_rows,
|
||||||
|
stored_order,
|
||||||
|
)
|
||||||
from .reviewing import review_row
|
from .reviewing import review_row
|
||||||
from .settings import media_origins
|
from .settings import media_origins
|
||||||
from .skill_scripts import OUTPUT_LIMIT
|
from .skill_scripts import OUTPUT_LIMIT
|
||||||
@@ -117,7 +125,7 @@ class View:
|
|||||||
"messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end],
|
"messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end],
|
||||||
"operator": operator_rows(tasks),
|
"operator": operator_rows(tasks),
|
||||||
"register": register_rows(tasks),
|
"register": register_rows(tasks),
|
||||||
"priority": priority(tasks),
|
"agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)),
|
||||||
"radio": radio,
|
"radio": radio,
|
||||||
"claims": claims,
|
"claims": claims,
|
||||||
"grants": grant_rows,
|
"grants": grant_rows,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
--cleanup: host-mounted directory the hub writes signed branch deletions
|
--cleanup: host-mounted directory the hub writes signed branch deletions
|
||||||
into (jobs/) and reads their results from (results/); the host's
|
into (jobs/) and reads their results from (results/); the host's
|
||||||
kvb-orchestrator-cleanup path unit runs them.
|
kvb-orchestrator-cleanup path unit runs them.
|
||||||
|
--offices: host-mounted directory the hub writes signed office orders
|
||||||
|
(appoint, take over, recall) into (jobs/) and reads their results
|
||||||
|
from (results/); the host's kvb-orchestrator-offices path unit runs
|
||||||
|
council.py for them.
|
||||||
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
|
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
|
||||||
natural-language command line off.
|
natural-language command line off.
|
||||||
--llm-model: model name sent to that endpoint; empty turns it off.
|
--llm-model: model name sent to that endpoint; empty turns it off.
|
||||||
@@ -66,7 +70,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--bind", required=True)
|
parser.add_argument("--bind", required=True)
|
||||||
for name in ("--listen-port", "--public-port"):
|
for name in ("--listen-port", "--public-port"):
|
||||||
parser.add_argument(name, type=int, required=True)
|
parser.add_argument(name, type=int, required=True)
|
||||||
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--llm-key-file", "--approvers")
|
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--offices", "--llm-key-file", "--approvers")
|
||||||
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
|
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
|
||||||
parser.add_argument(name, type=Path, required=True)
|
parser.add_argument(name, type=Path, required=True)
|
||||||
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
||||||
@@ -82,7 +86,7 @@ def main() -> int:
|
|||||||
transcripts=args.transcripts,
|
transcripts=args.transcripts,
|
||||||
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
||||||
templates=args.templates,
|
templates=args.templates,
|
||||||
places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup),
|
places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup, offices=args.offices),
|
||||||
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
|
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
|
||||||
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
|
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
|
||||||
approvers=args.approvers,
|
approvers=args.approvers,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Agent Orchestrator offices: wake on a signed office order the Agent Orchestrator wrote
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
DirectoryNotEmpty=%h/.local/state/kvb-orchestrator/offices/jobs
|
||||||
|
MakeDirectory=true
|
||||||
|
DirectoryMode=0700
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Agent Orchestrator offices: verify the operator signature, then appoint, hand over or recall exactly the signed office through council.py
|
||||||
|
|
||||||
|
[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/offices.py --port ${KVB_ORCHESTRATOR_PORT} --jobs %h/.local/state/kvb-orchestrator/offices/jobs --results %h/.local/state/kvb-orchestrator/offices/results --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/offices.json --council ${KVB_ORCHESTRATOR_COUNCIL} --script ${KVB_ORCHESTRATOR_SKILLS}/kvb-org-organize/scripts/council.py
|
||||||
|
TimeoutStartSec=600
|
||||||
@@ -402,6 +402,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
|
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
|
||||||
.graph3d .scene-container { position: relative; }
|
.graph3d .scene-container { position: relative; }
|
||||||
.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); }
|
.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" %}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -485,6 +486,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
|
|
||||||
{% include "page-spawn.js" %}
|
{% include "page-spawn.js" %}
|
||||||
|
|
||||||
|
{% include "page-hierarchy.js" %}
|
||||||
|
|
||||||
|
{% include "page-agent-order.js" %}
|
||||||
|
|
||||||
{% include "page-graph3d.js" %}
|
{% include "page-graph3d.js" %}
|
||||||
|
|
||||||
{% include "page-artifacts.js" %}
|
{% include "page-artifacts.js" %}
|
||||||
@@ -493,6 +498,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
|
|
||||||
{% include "page-cleanup.js" %}
|
{% include "page-cleanup.js" %}
|
||||||
|
|
||||||
|
{% include "page-offices.js" %}
|
||||||
|
|
||||||
{% include "page-time.js" %}
|
{% include "page-time.js" %}
|
||||||
|
|
||||||
{% include "page-pulses.js" %}
|
{% include "page-pulses.js" %}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ const Nav = (() => {
|
|||||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||||
|
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
|
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
|
||||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d",
|
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||||
};
|
};
|
||||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||||
const known = (view) => VIEWS.some(([id]) => id === view);
|
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
|
||||||
|
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
|
||||||
|
|
||||||
function tabOf(screen) {
|
function tabOf(screen) {
|
||||||
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
|
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
|
||||||
@@ -22,19 +24,19 @@ const Nav = (() => {
|
|||||||
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
|
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
|
||||||
if (Object.hasOwn(OLD, head)) {
|
if (Object.hasOwn(OLD, head)) {
|
||||||
const [screen, fallback] = OLD[head];
|
const [screen, fallback] = OLD[head];
|
||||||
const view = known(sub) ? sub : fallback;
|
const view = known(screen, sub) ? sub : fallback;
|
||||||
return {screen, view, redirect: hashOf(screen, view)};
|
return {screen, view, redirect: hashOf(screen, view)};
|
||||||
}
|
}
|
||||||
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
|
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
|
||||||
return {screen: sub, view: null, redirect: null};
|
return {screen: sub, view: null, redirect: null};
|
||||||
}
|
}
|
||||||
if (TABS.some(([id]) => id === head) && head !== "stream") {
|
if (TABS.some(([id]) => id === head) && head !== "stream") {
|
||||||
return {screen: head, view: known(sub) ? sub : null, redirect: null};
|
return {screen: head, view: known(head, sub) ? sub : null, redirect: null};
|
||||||
}
|
}
|
||||||
return {screen: "stream", view: null, redirect: null};
|
return {screen: "stream", view: null, redirect: null};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
|
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
|
||||||
})();
|
})();
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports) {
|
||||||
module.exports = Nav;
|
module.exports = Nav;
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
const orderDom = {box: null, dragged: null, focus: null};
|
||||||
|
|
||||||
|
function openAgentOrder() {
|
||||||
|
go("agents", "hierarchy");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const title = document.getElementById("agent-order-title");
|
||||||
|
if (title) {
|
||||||
|
title.scrollIntoView({block: "nearest"});
|
||||||
|
title.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOrder() {
|
||||||
|
const stored = state.agent_order;
|
||||||
|
if (ui.order === null || ui.order.length !== stored.length || ui.order.some((name) => !stored.includes(name))) {
|
||||||
|
ui.order = [...stored];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeAgent(next, focus) {
|
||||||
|
ui.order = next;
|
||||||
|
orderDom.focus = focus;
|
||||||
|
drawAgentOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveAgent(name, step) {
|
||||||
|
const from = ui.order.indexOf(name);
|
||||||
|
const to = from + step;
|
||||||
|
if (from < 0 || to < 0 || to >= ui.order.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = ui.order.filter((entry) => entry !== name);
|
||||||
|
next.splice(to, 0, name);
|
||||||
|
placeAgent(next, `${step < 0 ? "up" : "down"}:${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropAgent(name) {
|
||||||
|
const dragged = orderDom.dragged;
|
||||||
|
orderDom.dragged = null;
|
||||||
|
if (dragged === null || dragged === name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = ui.order.filter((entry) => entry !== dragged);
|
||||||
|
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
|
||||||
|
placeAgent(next, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderItem(name, index) {
|
||||||
|
const up = officeBtn("bi-arrow-up", `${name} nach oben`, `${name} nach oben: höhere Priorität`, () => moveAgent(name, -1), `up:${name}`);
|
||||||
|
const down = officeBtn("bi-arrow-down", `${name} nach unten`, `${name} nach unten: niedrigere Priorität`, () => moveAgent(name, 1), `down:${name}`);
|
||||||
|
up.disabled = index === 0;
|
||||||
|
down.disabled = index === ui.order.length - 1;
|
||||||
|
const item = h("li", {draggable: "true", "data-name": name}, icon("bi-grip-vertical", "grip"), h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name), h("span", {class: "spacer"}), up, down);
|
||||||
|
item.addEventListener("dragstart", (event) => {
|
||||||
|
orderDom.dragged = name;
|
||||||
|
item.classList.add("dragging");
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", name);
|
||||||
|
});
|
||||||
|
item.addEventListener("dragend", () => item.classList.remove("dragging"));
|
||||||
|
item.addEventListener("dragover", (event) => event.preventDefault());
|
||||||
|
item.addEventListener("drop", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
dropAgent(name);
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAgentOrder() {
|
||||||
|
if (await act("order", "Priorität", {agents: ui.order})) {
|
||||||
|
ui.order = null;
|
||||||
|
drawAgentOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAgentOrder() {
|
||||||
|
const box = orderDom.box;
|
||||||
|
if (!box || !box.isConnected) {
|
||||||
|
orderDom.box = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncOrder();
|
||||||
|
const key = orderDom.focus || focusKey(box);
|
||||||
|
orderDom.focus = null;
|
||||||
|
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.agent_order);
|
||||||
|
box.replaceChildren(
|
||||||
|
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Priorität der Agenten", counter(ui.order.length))),
|
||||||
|
h("p", {class: "muted"}, "Ordnet die Freigaben des Chairs und entscheidet nur bei gleicher Aufgabenpriorität; die --prio-Pins der Aufgaben bleiben unverändert."),
|
||||||
|
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Reihenfolge der Agenten"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "Kein Agent mit Amt oder offener Aufgabe."),
|
||||||
|
h("div", {class: "actions"},
|
||||||
|
h("span", {class: "muted"}, "Ziehen oder Pfeile zum Umsortieren"),
|
||||||
|
h("span", {class: "spacer"}),
|
||||||
|
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Zurücksetzen"),
|
||||||
|
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Reihenfolge speichern")));
|
||||||
|
const target = key && [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
|
||||||
|
if (target && target.disabled) {
|
||||||
|
refocus(box, key.startsWith("up:") ? `down:${key.slice(3)}` : key.startsWith("down:") ? `up:${key.slice(5)}` : "order:title");
|
||||||
|
} else {
|
||||||
|
refocus(box, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function agentNames() {
|
function agentNames() {
|
||||||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]);
|
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
|
||||||
return [...names].sort((a, b) => a.localeCompare(b));
|
return [...names].sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||||
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
||||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
||||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", prioritySheet), night), clear, mode};
|
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", openAgentOrder, "Priorität: Reihenfolge der Agenten in der Hierarchie"), night), clear, mode};
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawToolbar() {
|
function drawToolbar() {
|
||||||
@@ -75,54 +75,3 @@
|
|||||||
graphDom.toolbar.clear.disabled = !state.silence;
|
graphDom.toolbar.clear.disabled = !state.silence;
|
||||||
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
|
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
|
||||||
}
|
}
|
||||||
|
|
||||||
function priorityContent() {
|
|
||||||
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
|
|
||||||
ui.order = [...state.priority];
|
|
||||||
}
|
|
||||||
const list = h("ol", {class: "order-list"});
|
|
||||||
let dragged = null;
|
|
||||||
ui.order.forEach((name, index) => {
|
|
||||||
const item = h("li", {draggable: "true", "data-name": name}, h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name));
|
|
||||||
item.addEventListener("dragstart", (event) => {
|
|
||||||
dragged = name;
|
|
||||||
item.classList.add("dragging");
|
|
||||||
event.dataTransfer.effectAllowed = "move";
|
|
||||||
event.dataTransfer.setData("text/plain", name);
|
|
||||||
});
|
|
||||||
item.addEventListener("dragend", () => item.classList.remove("dragging"));
|
|
||||||
item.addEventListener("dragover", (event) => event.preventDefault());
|
|
||||||
item.addEventListener("drop", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (dragged === null || dragged === name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = ui.order.filter((entry) => entry !== dragged);
|
|
||||||
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
|
|
||||||
ui.order = next;
|
|
||||||
prioritySheet();
|
|
||||||
});
|
|
||||||
list.append(item);
|
|
||||||
});
|
|
||||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
|
|
||||||
return [
|
|
||||||
ui.order.length ? list : h("p", {class: "muted"}, "Kein Agent mit offener Aufgabe."),
|
|
||||||
h("div", {class: "actions"},
|
|
||||||
h("span", {class: "muted"}, "Ziehen zum Umsortieren"),
|
|
||||||
h("span", {class: "spacer"}),
|
|
||||||
h("button", {type: "button", class: "btn ghost", disabled: !changed, on: {click: () => {
|
|
||||||
ui.order = [...state.priority];
|
|
||||||
prioritySheet();
|
|
||||||
}}}, "Zurücksetzen"),
|
|
||||||
h("button", {type: "button", class: "btn primary", disabled: !changed, on: {click: async () => {
|
|
||||||
if (await act("order", "Priorität", {agents: ui.order})) {
|
|
||||||
ui.order = null;
|
|
||||||
prioritySheet();
|
|
||||||
}
|
|
||||||
}}}, "Reihenfolge speichern")),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function prioritySheet() {
|
|
||||||
openSheet("Priorität der Agenten", priorityContent(), false);
|
|
||||||
}
|
|
||||||
@@ -47,30 +47,36 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
|
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
|
||||||
followCleanup(result.data.id, outcome, Date.now());
|
followJob(`/api/cleanup/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||||
|
const {good, lines} = showJob(data, outcome, "branch", RESULT_LABELS);
|
||||||
|
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, lines, good);
|
||||||
|
fetchTree();
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function followCleanup(id, outcome, began) {
|
async function followJob(url, outcome, began, finish) {
|
||||||
let data = null;
|
let data = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {cache: "no-store"});
|
const response = await fetch(url, {cache: "no-store"});
|
||||||
data = response.ok ? await response.json() : null;
|
data = response.ok ? await response.json() : null;
|
||||||
} catch {
|
} catch {
|
||||||
data = null;
|
data = null;
|
||||||
}
|
}
|
||||||
if (!data || data.status !== "done") {
|
if (!data || data.status !== "done") {
|
||||||
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
|
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
|
||||||
window.setTimeout(() => followCleanup(id, outcome, began), CLEANUP_POLL);
|
window.setTimeout(() => followJob(url, outcome, began, finish), CLEANUP_POLL);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_LABELS[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
finish(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showJob(data, outcome, key, labels) {
|
||||||
|
const lines = data.error ? [data.error] : data.items.map((item) => `${item[key]}: ${(labels[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
||||||
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||||
const [label, tone] = RESULT_LABELS[item.status] || [item.status, "neutral"];
|
const [label, tone] = labels[item.status] || [item.status, "neutral"];
|
||||||
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item.branch), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item[key]), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
||||||
})));
|
})));
|
||||||
const good = !data.error && data.items.every((item) => item.status === "done");
|
return {good: !data.error && data.items.every((item) => item.status === "done"), lines};
|
||||||
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
|
|
||||||
fetchTree();
|
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
const NONCE = document.currentScript ? document.currentScript.nonce : "";
|
const NONCE = document.currentScript ? document.currentScript.nonce : "";
|
||||||
const ACTIVE_BEAT = 900;
|
const ACTIVE_BEAT = 900;
|
||||||
const DAY = 86400;
|
const DAY = 86400;
|
||||||
const CHAIR = "Maintainer";
|
|
||||||
const HUMAN = "Operator";
|
const HUMAN = "Operator";
|
||||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||||||
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
|
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
|
||||||
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: CHAIR}).id;
|
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: HUMAN}).id;
|
||||||
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
function toast(title, lines, good) {
|
function toast(title, lines, good) {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewSwitch(screen) {
|
function viewSwitch(screen) {
|
||||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {
|
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
|
||||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
|
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
|
||||||
}, icon(Nav.ICONS[id]))));
|
}, icon(Nav.ICONS[id]))));
|
||||||
}
|
}
|
||||||
@@ -191,6 +191,6 @@
|
|||||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||||
const toolbar = agentsToolbar();
|
const toolbar = agentsToolbar();
|
||||||
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
||||||
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const hier = {chart: null, sig: "", closed: new Set(), ids: 0};
|
||||||
|
const OFFICE_ICONS = {chair: "bi-award", line: "bi-person-badge", cluster: "bi-collection", work: "bi-briefcase", other: "bi-signpost-split"};
|
||||||
|
const OFFICE_GROUPS = [["cluster", "Organisatoren", "Organisator"], ["work", "Präsidenten", "Präsident"], ["other", "Weitere Ämter", "Amt"]];
|
||||||
|
|
||||||
|
function hierarchyScreen() {
|
||||||
|
hier.chart = h("section", {class: "card org-chart", "aria-labelledby": "org-title"});
|
||||||
|
orderDom.box = h("section", {class: "card agent-order", id: "agent-order", "aria-labelledby": "agent-order-title"});
|
||||||
|
hier.sig = "";
|
||||||
|
return h("div", {class: "hierarchy-body"}, hier.chart, orderDom.box);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHierarchy(force) {
|
||||||
|
if (!hier.chart || !hier.chart.isConnected) {
|
||||||
|
hier.chart = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sig = hierarchySig();
|
||||||
|
if (!force && sig === hier.sig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hier.sig = sig;
|
||||||
|
drawChart();
|
||||||
|
drawAgentOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusKey(box) {
|
||||||
|
const at = document.activeElement;
|
||||||
|
return at && box.contains(at) ? at.dataset.focus || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refocus(box, key) {
|
||||||
|
const found = key === null ? null : [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
|
||||||
|
if (found) {
|
||||||
|
found.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function officeBtn(name, label, hint, run, focus) {
|
||||||
|
const button = iconBtn(name, label, "btn small ghost", run, hint);
|
||||||
|
button.dataset.focus = focus;
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function holderOf(claim) {
|
||||||
|
return claim.agent || (claim.session === "-" ? "" : claim.session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function officeRow(claim, label, title) {
|
||||||
|
const who = holderOf(claim);
|
||||||
|
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
|
||||||
|
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: `${who} öffnen`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||||
|
active ? chip("bi-play-circle", "aktiv", "ok") : chip("bi-pause-circle", "ohne Herzschlag", "neutral"),
|
||||||
|
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||||
|
h("span", {class: "org-actions"},
|
||||||
|
officeBtn("bi-arrow-left-right", "Übergeben", `Übergeben: ${claim.office} an eine andere Session, mit Passkey-Signatur`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
|
||||||
|
officeBtn("bi-person-x", "Abberufen", `Abberufen: ${claim.office} frei machen, mit Passkey-Signatur`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function vacantRow(office, label) {
|
||||||
|
return h("div", {class: "org-row vacant", "data-office": office},
|
||||||
|
h("span", {class: "org-office"}, icon(OFFICE_ICONS.chair), h("strong", {}, label)),
|
||||||
|
pill("unbesetzt", "warn"),
|
||||||
|
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Besetzen", `Besetzen: ${office} einer Session geben, mit Passkey-Signatur`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupRow(glyph, label, count, note) {
|
||||||
|
return h("div", {class: "org-row group"}, h("span", {class: "org-office"}, icon(glyph), h("strong", {}, label)), counter(count), h("span", {class: "muted"}, note));
|
||||||
|
}
|
||||||
|
|
||||||
|
function orgNode(id, label, row, children) {
|
||||||
|
if (!children.length) {
|
||||||
|
return h("li", {}, h("div", {class: "org-line"}, h("span", {class: "org-gap", "aria-hidden": "true"}), row));
|
||||||
|
}
|
||||||
|
const open = !hier.closed.has(id);
|
||||||
|
hier.ids += 1;
|
||||||
|
const list = h("ul", {class: "org-tree", id: `org-${hier.ids}`, hidden: !open}, children);
|
||||||
|
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Zuklappen" : "Aufklappen"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
const now = list.hidden;
|
||||||
|
list.hidden = !now;
|
||||||
|
toggle.setAttribute("aria-expanded", String(now));
|
||||||
|
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Zuklappen" : "Aufklappen"}: ${label}`);
|
||||||
|
if (now) {
|
||||||
|
hier.closed.delete(id);
|
||||||
|
} else {
|
||||||
|
hier.closed.add(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return h("li", {"data-node": id}, h("div", {class: "org-line"}, toggle, row), list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartTree() {
|
||||||
|
const claims = state.claims;
|
||||||
|
const dimensions = new Map();
|
||||||
|
for (const claim of claims.filter((entry) => entry.kind === "line")) {
|
||||||
|
dimensions.set(claim.dimension, [...(dimensions.get(claim.dimension) || []), claim]);
|
||||||
|
}
|
||||||
|
const lines = [...dimensions].sort(([a], [b]) => a.localeCompare(b)).map(([dimension, held]) => orgNode(`dimension:${dimension}`, dimension,
|
||||||
|
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "Linie" : "Linien"),
|
||||||
|
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Repräsentant"), []))));
|
||||||
|
const groups = OFFICE_GROUPS.map(([kind, label, title]) => [kind, label, title, claims.filter((entry) => entry.kind === kind)]).filter(([, , , held]) => held.length)
|
||||||
|
.map(([kind, label, title, held]) => orgNode(`group:${kind}`, label, groupRow(OFFICE_ICONS[kind], label, held.length, kind === "other" ? "" : `${kind}/…`),
|
||||||
|
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, kind === "other" ? claim.office : claim.office.slice(kind.length + 1), title), []))));
|
||||||
|
const chair = claims.find((entry) => entry.kind === "chair");
|
||||||
|
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Amt") : vacantRow("chair", "Chair"), [...lines, ...groups]);
|
||||||
|
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "vergibt jedes Amt mit Passkey-Signatur"));
|
||||||
|
return h("ul", {class: "org-tree root"}, orgNode("operator", HUMAN, root, [chairNode]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart() {
|
||||||
|
const key = focusKey(hier.chart);
|
||||||
|
hier.ids = 0;
|
||||||
|
const count = state.claims.length;
|
||||||
|
hier.chart.replaceChildren(
|
||||||
|
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchie", counter(count)),
|
||||||
|
h("span", {class: "muted"}, "Formale Ämter aus dem Council: Operator → Chair → Dimensionen → Linien, dazu Organisatoren und Präsidenten"),
|
||||||
|
h("span", {class: "spacer"}),
|
||||||
|
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("Neues Amt", "Neues Amt: ein freies Amt einer Session geben, mit Passkey-Signatur"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "Neues Amt")),
|
||||||
|
chartTree());
|
||||||
|
refocus(hier.chart, key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const OFFICE_RESULTS = {done: ["erledigt", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||||
|
const OFFICE_VERBS = {
|
||||||
|
appoint: ["Besetzen", "bi-person-plus", "council.py claim --operator auf dem Host: die gewählte Session erhält das freie Amt."],
|
||||||
|
takeover: ["Übergeben", "bi-arrow-left-right", "council.py takeover --operator auf dem Host: das Amt geht an die gewählte Session, auch wenn der bisherige Inhaber noch lebt."],
|
||||||
|
recall: ["Abberufen", "bi-person-x", "council.py release --operator auf dem Host: das Amt wird frei, auch wenn der Inhaber noch lebt."],
|
||||||
|
};
|
||||||
|
const OFFICE_HINT = "Der Host prüft deine Passkey-Signatur selbst und verweigert, wenn sich der Inhaber seit der Signatur geändert hat.";
|
||||||
|
|
||||||
|
function successors(office, holder) {
|
||||||
|
const [dimension, line] = office.split("/");
|
||||||
|
const lined = Boolean(line) && dimension !== "work" && dimension !== "cluster";
|
||||||
|
return state.graph.nodes
|
||||||
|
.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id) && node.id !== holder)
|
||||||
|
.filter((node) => !lined || !state.claims.some((claim) => claim.session === node.id && claim.kind === "line" && claim.dimension === dimension && claim.office !== office))
|
||||||
|
.map((node) => [node.id, `${labelOf(node.id)} · ${node.id.slice(0, 8)}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function officeSheet(action, claim) {
|
||||||
|
const office = claim ? claim.office : "";
|
||||||
|
const holder = claim && action !== "appoint" ? claim.session : "";
|
||||||
|
const [verb, glyph, text] = OFFICE_VERBS[action];
|
||||||
|
const name = h("input", {class: "field", "aria-label": "Amt", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/linie, cluster/slug oder work/slug"});
|
||||||
|
const picks = successors(office, holder);
|
||||||
|
const session = h("select", {class: "field", "aria-label": "Session"}, picks.map(([id, label]) => h("option", {value: id}, label)));
|
||||||
|
const work = h("input", {class: "field", "aria-label": "Arbeit", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "woran die Session in diesem Amt arbeitet"});
|
||||||
|
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||||
|
const recall = action === "recall";
|
||||||
|
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: mit deiner Passkey-Signatur an den Host übergeben`)}, icon(glyph), verb);
|
||||||
|
confirm.addEventListener("click", async () => {
|
||||||
|
const item = {kind: "office", action, office: name.value.trim(), holder, session: recall ? "" : session.value, work: recall ? "" : work.value.trim()};
|
||||||
|
confirm.disabled = true;
|
||||||
|
if (!await signOffice([item], outcome, verb)) {
|
||||||
|
confirm.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
openSheet(office ? `${office}: ${verb}` : "Neues Amt besetzen", [
|
||||||
|
h("p", {class: "muted"}, text, " ", OFFICE_HINT),
|
||||||
|
holder ? meta([["Inhaber", holderOf(claim) || holder, holder]]) : null,
|
||||||
|
h("div", {class: "office-form"},
|
||||||
|
h("label", {}, "Amt", name),
|
||||||
|
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "Keine passende Session bekannt.")),
|
||||||
|
recall ? null : h("label", {}, "Arbeit", work)),
|
||||||
|
outcome,
|
||||||
|
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||||
|
], false);
|
||||||
|
(office ? (recall ? confirm : session) : name).focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signOffice(items, outcome, verb) {
|
||||||
|
const signed = await proofFor(items, verb);
|
||||||
|
if (signed === null) {
|
||||||
|
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = await sendJson("/api/offices", {items, proof: signed.proof});
|
||||||
|
if (!result.ok || !result.data) {
|
||||||
|
toast(`${verb}: nicht möglich`, fail(result), false);
|
||||||
|
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
outcome.replaceChildren(h("p", {class: "muted"},`Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit ruft jetzt council.py auf.`));
|
||||||
|
followJob(`/api/offices/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||||
|
const {good, lines} = showJob(data, outcome, "office", OFFICE_RESULTS);
|
||||||
|
toast(`${verb}: ${good ? "erledigt" : "nicht ausgeführt"}`, lines, good);
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
function paletteItems() {
|
function paletteItems() {
|
||||||
return [
|
return [
|
||||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||||
...["agents", "artifacts"].flatMap((screen) => Nav.VIEWS.map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
||||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||||
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
||||||
{label: "Priorität …", hint: "Reihenfolge der Agenten", run: prioritySheet},
|
{label: "Priorität …", hint: "Reihenfolge der Agenten in der Hierarchie", run: openAgentOrder},
|
||||||
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
|
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
|
||||||
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
||||||
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
stage();
|
stage();
|
||||||
drawSide();
|
drawSide();
|
||||||
drawSpawn();
|
drawSpawn();
|
||||||
|
drawHierarchy(force);
|
||||||
drawToolbar();
|
drawToolbar();
|
||||||
drawInbox();
|
drawInbox();
|
||||||
drawStatus();
|
drawStatus();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
function stage() {
|
function stage() {
|
||||||
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree" || ui.views.agents === "hierarchy") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
graphDom.pulses = null;
|
graphDom.pulses = null;
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawSide() {
|
function drawSide() {
|
||||||
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
|
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
|
||||||
graphDom.aside.replaceChildren(...side());
|
graphDom.aside.replaceChildren(...side());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.hierarchy-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; align-items: start; }
|
||||||
|
@media (max-width: 960px) { .hierarchy-body { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.org-chart .inbox-head { flex-wrap: wrap; }
|
||||||
|
.org-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 16px; list-style: none; border-left: 1px solid var(--line); margin-left: 14px; }
|
||||||
|
.org-tree.root { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||||
|
.org-tree[hidden] { display: none; }
|
||||||
|
.org-line { display: flex; align-items: flex-start; gap: 4px; }
|
||||||
|
.org-toggle { margin-top: 3px; }
|
||||||
|
.org-gap { flex: none; width: 30px; }
|
||||||
|
.org-row { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; padding: 5px 8px; border-radius: 8px; }
|
||||||
|
.org-row:hover { background: var(--hover); }
|
||||||
|
.org-row.vacant { border: 1px dashed var(--line-strong); }
|
||||||
|
.org-row.group .org-office { color: var(--muted); }
|
||||||
|
.org-office, .org-holder { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.org-work { flex: 1 1 140px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.org-actions { display: inline-flex; gap: 2px; margin-left: auto; }
|
||||||
|
.agent-order .order-list li { gap: 8px; padding: 6px 8px; }
|
||||||
|
.agent-order .order-list .linkish { min-width: 0; }
|
||||||
|
.agent-order .grip { flex: none; color: var(--muted); }
|
||||||
|
.office-form { display: grid; gap: 10px; margin: 10px 0; }
|
||||||
|
.office-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||||
+35
-3
@@ -3,8 +3,8 @@
|
|||||||
# install migrate, then copy orchestrator/ to ~/.local/share/kvb-orchestrator,
|
# install migrate, then copy orchestrator/ to ~/.local/share/kvb-orchestrator,
|
||||||
# write its .env, build and start the container, register the MCP
|
# write its .env, build and start the container, register the MCP
|
||||||
# server for Claude Code, build pkgmgr from the newest signed tag of
|
# server for Claude Code, build pkgmgr from the newest signed tag of
|
||||||
# PKGMGR_REPO and enable the tree timer and the cleanup path unit;
|
# PKGMGR_REPO and enable the tree timer and the cleanup path unit,
|
||||||
# re-enable the runner and re-register the hook when the migration
|
# enable the offices path unit; re-enable the runner and re-register the hook when the migration
|
||||||
# took them over
|
# took them over
|
||||||
# migrate move every trace of the earlier name to the kvb-orchestrator names
|
# migrate move every trace of the earlier name to the kvb-orchestrator names
|
||||||
# once: container, image, code, state, approvals, pinned keys, MCP
|
# once: container, image, code, state, approvals, pinned keys, MCP
|
||||||
@@ -35,6 +35,9 @@ HOOK_TIMEOUT=110
|
|||||||
RUNNER="${NAME}-runner.service"
|
RUNNER="${NAME}-runner.service"
|
||||||
TREE_UNIT="${NAME}-tree"
|
TREE_UNIT="${NAME}-tree"
|
||||||
CLEANUP_UNIT="${NAME}-cleanup"
|
CLEANUP_UNIT="${NAME}-cleanup"
|
||||||
|
OFFICES_UNIT="${NAME}-offices"
|
||||||
|
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"
|
TREE_DIR="${HOME}/.local/state/pkgmgr/tree"
|
||||||
PKGMGR_REPO="${PKGMGR_REPO:-https://github.com/kevinveenbirkenbach/package-manager.git}"
|
PKGMGR_REPO="${PKGMGR_REPO:-https://github.com/kevinveenbirkenbach/package-manager.git}"
|
||||||
IMAGE="${NAME}:local"
|
IMAGE="${NAME}:local"
|
||||||
@@ -148,7 +151,7 @@ install_hub() {
|
|||||||
cp -a "${REPO_ROOT}/orchestrator/." "${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
|
# 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 {} +
|
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"
|
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py" "${HUB}/client/offices.py"
|
||||||
log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.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"
|
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' \
|
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' \
|
||||||
@@ -223,10 +226,34 @@ tree_timer() {
|
|||||||
log "skills: ${CLEANUP_UNIT}.path deletes the merged branches the operator signs in the Artifacts Tree, locally only"
|
log "skills: ${CLEANUP_UNIT}.path deletes the merged branches the operator signs in the Artifacts Tree, locally only"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skills_current() {
|
||||||
|
local script
|
||||||
|
for script in "${HOST_SCRIPTS[@]}"; do
|
||||||
|
cmp -s "${REPO_ROOT}/skills/${script}" "${INSTALLED_SKILLS}/${script}" || return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
offices_unit() {
|
||||||
|
if ! has_systemd; then
|
||||||
|
warn "skills: no systemd user session; the office changes signed in the Hierarchie view wait."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! skills_current; then
|
||||||
|
systemctl --user disable --now "${OFFICES_UNIT}.path" >/dev/null 2>&1 || true
|
||||||
|
warn "skills: ${INSTALLED_SKILLS} holds other council.py, nofollow.py or rasci.py than this tree; run 'make install' to enable ${OFFICES_UNIT}.path."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
cp -f "${HUB}/systemd/${OFFICES_UNIT}.service" "${HUB}/systemd/${OFFICES_UNIT}.path" "${UNITS}/"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now "${OFFICES_UNIT}.path"
|
||||||
|
log "skills: ${OFFICES_UNIT}.path runs council.py for the office changes the operator signs in the Hierarchie view"
|
||||||
|
}
|
||||||
|
|
||||||
install_all() {
|
install_all() {
|
||||||
migrate || return 0
|
migrate || return 0
|
||||||
install_hub
|
install_hub
|
||||||
tree_timer
|
tree_timer
|
||||||
|
offices_unit
|
||||||
if ((TOOK_RUNNER)); then runner || true; fi
|
if ((TOOK_RUNNER)); then runner || true; fi
|
||||||
if ((TOOK_HOOK)); then
|
if ((TOOK_HOOK)); then
|
||||||
if [[ -f "${HUB}/client/permission_hook.py" ]]; then hook; else warn "skills: run 'make orchestrator-hook' once the Agent Orchestrator is installed"; fi
|
if [[ -f "${HUB}/client/permission_hook.py" ]]; then hook; else warn "skills: run 'make orchestrator-hook' once the Agent Orchestrator is installed"; fi
|
||||||
@@ -295,6 +322,11 @@ check() {
|
|||||||
else
|
else
|
||||||
todo "${CLEANUP_UNIT}.path off: deleting merged branches from the Artifacts Tree waits for make orchestrator-install"
|
todo "${CLEANUP_UNIT}.path off: deleting merged branches from the Artifacts Tree waits for make orchestrator-install"
|
||||||
fi
|
fi
|
||||||
|
if [[ "$(systemctl --user is-active "${OFFICES_UNIT}.path" 2>/dev/null || true)" == "active" ]]; then
|
||||||
|
ok "${OFFICES_UNIT}.path active"
|
||||||
|
else
|
||||||
|
todo "${OFFICES_UNIT}.path off: office changes from the Hierarchie view wait for make orchestrator-install"
|
||||||
|
fi
|
||||||
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
|
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
|
||||||
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
|
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
|
||||||
return "${FAILED}"
|
return "${FAILED}"
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ Exit codes:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
import datetime
|
import datetime
|
||||||
|
import errno
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -43,6 +46,7 @@ ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
|
|||||||
HEARTBEAT_LIMIT = 3600
|
HEARTBEAT_LIMIT = 3600
|
||||||
OFFICE = re.compile(r"^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$")
|
OFFICE = re.compile(r"^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$")
|
||||||
CLAIM = "claim.json"
|
CLAIM = "claim.json"
|
||||||
|
CLAIM_MODE = 0o644
|
||||||
WORK_WIDTH = 40
|
WORK_WIDTH = 40
|
||||||
RASCI = Path(__file__).resolve().parents[2] / "kvb-org-rasci" / "scripts" / "rasci.py"
|
RASCI = Path(__file__).resolve().parents[2] / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||||
|
|
||||||
@@ -114,11 +118,50 @@ def write_claim(path: Path, office: str, sid: str, runtime: str, work: str) -> d
|
|||||||
"beat": time.time(),
|
"beat": time.time(),
|
||||||
"work": work,
|
"work": work,
|
||||||
}
|
}
|
||||||
(path / CLAIM).write_text(json.dumps(claim), encoding="utf-8")
|
nofollow.replace(ROOT, path / CLAIM, json.dumps(claim), CLAIM_MODE)
|
||||||
return claim
|
return claim
|
||||||
|
|
||||||
|
|
||||||
|
def make_office(path: Path) -> bool:
|
||||||
|
*dirs, name = nofollow.parts_below(ROOT, path)
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = nofollow.directory(ROOT, tuple(dirs), True)
|
||||||
|
try:
|
||||||
|
os.mkdir(name, nofollow.DIRECTORY_MODE, dir_fd=fd)
|
||||||
|
except FileExistsError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def remove_office(path: Path) -> None:
|
||||||
|
*dirs, name = nofollow.parts_below(ROOT, path)
|
||||||
|
try:
|
||||||
|
fd = nofollow.directory(ROOT, tuple(dirs), False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
found = os.lstat(name, dir_fd=fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
if not stat.S_ISDIR(found.st_mode):
|
||||||
|
raise OSError(errno.ENOTDIR, "the office is no directory", str(path))
|
||||||
|
tomb = f"{name}.stale-{os.getpid()}-{time.time_ns()}"
|
||||||
|
try:
|
||||||
|
os.rename(name, tomb, src_dir_fd=fd, dst_dir_fd=fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
shutil.rmtree(tomb, dir_fd=fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
def here() -> dict[str, str | None]:
|
def here() -> dict[str, str | None]:
|
||||||
|
# The Agent Orchestrator's office runner claims from / for the successor: / is no task's workdir.
|
||||||
|
if os.getcwd() == os.sep:
|
||||||
|
return {}
|
||||||
try:
|
try:
|
||||||
head = subprocess.run(
|
head = subprocess.run(
|
||||||
["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, check=False
|
["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, check=False
|
||||||
@@ -144,10 +187,12 @@ def register(office: str, holder: str, status: str, **extra: str | None) -> None
|
|||||||
def cmd_claim(args: argparse.Namespace) -> int:
|
def cmd_claim(args: argparse.Namespace) -> int:
|
||||||
sid, runtime = caller(args.session)
|
sid, runtime = caller(args.session)
|
||||||
path = office_dir(args.office)
|
path = office_dir(args.office)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
try:
|
||||||
path.mkdir()
|
created = make_office(path)
|
||||||
except FileExistsError:
|
except OSError as error:
|
||||||
|
print(f"council: cannot open {args.office}: {error.strerror}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not created:
|
||||||
claim = read_claim(path)
|
claim = read_claim(path)
|
||||||
if claim is not None and claim["session"] == sid:
|
if claim is not None and claim["session"] == sid:
|
||||||
claim = write_claim(path, args.office, sid, runtime, args.work)
|
claim = write_claim(path, args.office, sid, runtime, args.work)
|
||||||
@@ -179,14 +224,11 @@ def cmd_takeover(args: argparse.Namespace) -> int:
|
|||||||
args.previous = claim["name"] or claim["session"]
|
args.previous = claim["name"] or claim["session"]
|
||||||
# Two successors racing between this check and the rename can both win;
|
# Two successors racing between this check and the rename can both win;
|
||||||
# kvb-org-organize names exactly one successor per office to keep that window closed.
|
# kvb-org-organize names exactly one successor per office to keep that window closed.
|
||||||
if path.exists():
|
try:
|
||||||
tomb = path.with_name(f"{path.name}.stale-{os.getpid()}-{time.time_ns()}")
|
remove_office(path)
|
||||||
try:
|
except OSError as error:
|
||||||
path.rename(tomb)
|
print(f"council: cannot take over {args.office}: {error.strerror}", file=sys.stderr)
|
||||||
except FileNotFoundError:
|
return 2
|
||||||
pass
|
|
||||||
else:
|
|
||||||
shutil.rmtree(tomb)
|
|
||||||
return cmd_claim(args)
|
return cmd_claim(args)
|
||||||
|
|
||||||
|
|
||||||
@@ -200,7 +242,11 @@ def cmd_release(args: argparse.Namespace) -> int:
|
|||||||
if claim["session"] != sid and not is_stale(claim, live_sessions()):
|
if claim["session"] != sid and not is_stale(claim, live_sessions()):
|
||||||
print(json.dumps(claim))
|
print(json.dumps(claim))
|
||||||
return 3
|
return 3
|
||||||
shutil.rmtree(path)
|
try:
|
||||||
|
remove_office(path)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"council: cannot release {args.office}: {error.strerror}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
register(args.office, claim["name"] or claim["session"], "done")
|
register(args.office, claim["name"] or claim["session"], "done")
|
||||||
print(f"released {args.office}")
|
print(f"released {args.office}")
|
||||||
return 0
|
return 0
|
||||||
@@ -211,7 +257,8 @@ def cmd_beat(args: argparse.Namespace) -> int:
|
|||||||
for office, claim in claims():
|
for office, claim in claims():
|
||||||
if claim["session"] == sid:
|
if claim["session"] == sid:
|
||||||
claim["beat"] = time.time()
|
claim["beat"] = time.time()
|
||||||
(ROOT / office / CLAIM).write_text(json.dumps(claim), encoding="utf-8")
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
nofollow.rewrite(ROOT, ROOT / office / CLAIM, json.dumps(claim), CLAIM_MODE)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -142,8 +142,25 @@ def replace(root: Path, path: Path, text: str, mode: int) -> None:
|
|||||||
text: the new content, UTF-8 encoded.
|
text: the new content, UTF-8 encoded.
|
||||||
mode: permission bits of the new file.
|
mode: permission bits of the new file.
|
||||||
"""
|
"""
|
||||||
|
swap(root, path, text, mode, True)
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite(root: Path, path: Path, text: str, mode: int) -> None:
|
||||||
|
"""Replace a file below root like replace, inside directories that still exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: the trusted directory.
|
||||||
|
path: the file, below root; a missing directory on the way raises
|
||||||
|
FileNotFoundError and nothing is made.
|
||||||
|
text: the new content, UTF-8 encoded.
|
||||||
|
mode: permission bits of the new file.
|
||||||
|
"""
|
||||||
|
swap(root, path, text, mode, False)
|
||||||
|
|
||||||
|
|
||||||
|
def swap(root: Path, path: Path, text: str, mode: int, create: bool) -> None:
|
||||||
*dirs, name = parts_below(root, path)
|
*dirs, name = parts_below(root, path)
|
||||||
fd = directory(root, tuple(dirs), True)
|
fd = directory(root, tuple(dirs), create)
|
||||||
try:
|
try:
|
||||||
temp = f".{name}.{os.urandom(8).hex()}"
|
temp = f".{name}.{os.urandom(8).hex()}"
|
||||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||||
|
|||||||
@@ -22,3 +22,5 @@
|
|||||||
radio; everyone else leaves it alone.
|
radio; everyone else leaves it alone.
|
||||||
- After a restart or reboot, run `council.py list --mine` and claim again
|
- After a restart or reboot, run `council.py list --mine` and claim again
|
||||||
every office you believe you hold: `/tmp` may have been wiped.
|
every office you believe you hold: `/tmp` may have been wiped.
|
||||||
|
- Only the operator, through the Orchestrator's passkey-signed Hierarchie
|
||||||
|
view, removes a live holder; agents have no override.
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
---
|
---
|
||||||
name: kvb-org-priority
|
name: kvb-org-priority
|
||||||
description: >
|
description: >
|
||||||
Let the operator reorder the agents' priority from a selection; pins their
|
Let the operator reorder the agents; it breaks ties of grants and equal task
|
||||||
tasks in that order. Trigger on "prioritaet", "reihenfolge der agents".
|
priority. Trigger on "prioritaet", "reihenfolge der agents".
|
||||||
---
|
---
|
||||||
|
|
||||||
The operator's order of the agents outranks every WSJF score. It is written
|
The agent order is separate from task priority: it lies in
|
||||||
as `--prio` pins into the `kvb-org-rasci` register, so the grants of the
|
`agent-order.json` beside the `kvb-org-rasci` register, writes no `--prio`
|
||||||
`kvb-org-queue` skill follow it. The script is
|
pin and only breaks ties of equal task rank, also among `kvb-org-queue`
|
||||||
`${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py`.
|
grants. The script is `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rasci.py agents
|
rasci.py agents
|
||||||
rasci.py order "<agent>,<agent>,..."
|
rasci.py agents order "<agent>,<agent>,..."
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 1: current order
|
## Step 1: current order
|
||||||
|
|
||||||
`rasci.py agents` prints one line per agent with open tasks, in the
|
`rasci.py agents` prints one line per agent with open tasks or an office, in
|
||||||
register's rank: number, agent, task count, tasks. Show it as a table with
|
stored order, then register rank: number, agent, task count, tasks. Show it as a table with each agent's status and OKR from
|
||||||
each agent's status and OKR from `rasci.py show --wide`.
|
`rasci.py show --wide`.
|
||||||
|
|
||||||
## Step 2: selection
|
## Step 2: selection
|
||||||
|
|
||||||
@@ -33,15 +33,13 @@ One `AskUserQuestion`, `multiSelect: false`, the order of each option in its
|
|||||||
- The operator's own order arrives through `Other`, as agent names or the
|
- The operator's own order arrives through `Other`, as agent names or the
|
||||||
numbers of step 1 (map them to names), comma-separated.
|
numbers of step 1 (map them to names), comma-separated.
|
||||||
|
|
||||||
An agent left out of the chosen order loses its pins and ranks by WSJF after
|
An agent left out of the chosen order follows the listed ones in register
|
||||||
the listed ones; say so in the question.
|
rank; say so in the question.
|
||||||
|
|
||||||
## Step 3: apply
|
## Step 3: apply
|
||||||
|
|
||||||
`rasci.py order "<A>,<B>,..."` pins the listed agents' tasks 1..n in that
|
`rasci.py agents order "<A>,<B>,..."` stores the order and touches no task
|
||||||
order, keeping each agent's own task order. It writes other sessions' rows:
|
row. Show `rasci.py agents` again, then post the new order for the
|
||||||
the operator decides priority, so this is the one exception to "write only
|
|
||||||
your own rows". Show `rasci.py agents` again, then post the new ranks for the
|
|
||||||
resource chair of the `kvb-org-organize` skill and every reordered agent as
|
resource chair of the `kvb-org-organize` skill and every reordered agent as
|
||||||
one `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`,
|
one `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`,
|
||||||
not as wakes.
|
not as wakes.
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ Args:
|
|||||||
queue.
|
queue.
|
||||||
--kind: a key of slot_need_gb in the config.
|
--kind: a key of slot_need_gb in the config.
|
||||||
--task: an open task of the kvb-org-rasci register; its rank orders
|
--task: an open task of the kvb-org-rasci register; its rank orders
|
||||||
the queue.
|
the queue, and the operator's agent order (`rasci.py agents
|
||||||
|
order`) of the holding session breaks ties.
|
||||||
--need-gb: disk the job takes, in GB; when omitted the need learned
|
--need-gb: disk the job takes, in GB; when omitted the need learned
|
||||||
for the command's signature, else slot_need_gb[kind].
|
for the command's signature, else slot_need_gb[kind].
|
||||||
--hotfix: the slug of the register row hotfix/<slug> the job proves;
|
--hotfix: the slug of the register row hotfix/<slug> the job proves;
|
||||||
@@ -259,17 +260,19 @@ def store() -> Iterator[dict]:
|
|||||||
nofollow.replace(watch.ROOT, STORE, json.dumps(data, indent=1, sort_keys=True), 0o600)
|
nofollow.replace(watch.ROOT, STORE, json.dumps(data, indent=1, sort_keys=True), 0o600)
|
||||||
|
|
||||||
|
|
||||||
def ordered(waiting: list[dict], ranks: dict[str, int], now: float) -> list[dict]:
|
def ordered(waiting: list[dict], ranks: dict[str, int], agents: dict[str, int], now: float) -> list[dict]:
|
||||||
def key(entry: dict) -> tuple:
|
def key(entry: dict) -> tuple:
|
||||||
hours = int((now - entry["enqueued"]) // HOUR)
|
hours = int((now - entry["enqueued"]) // HOUR)
|
||||||
rank = ranks[entry["task"]] if entry["task"] in ranks else len(ranks)
|
rank = ranks[entry["task"]] if entry["task"] in ranks else len(ranks)
|
||||||
return (not entry["hotfix"], rank - hours, entry["seq"])
|
agent = agents[entry["name"]] if entry["name"] in agents else len(agents)
|
||||||
|
return (not entry["hotfix"], rank - hours, agent, entry["seq"])
|
||||||
|
|
||||||
return sorted(waiting, key=key)
|
return sorted(waiting, key=key)
|
||||||
|
|
||||||
|
|
||||||
def evaluate(entries: list[dict], free_gb: float, floor: float, stage: str, silenced: bool,
|
def evaluate(entries: list[dict], free_gb: float, floor: float, stage: str, silenced: bool,
|
||||||
slot_limits: dict[str, int], ranks: dict[str, int], now: float) -> dict[str, str | None]:
|
slot_limits: dict[str, int], ranks: dict[str, int], agents: dict[str, int],
|
||||||
|
now: float) -> dict[str, str | None]:
|
||||||
"""Decide every waiting entry in grant order, without disk or processes.
|
"""Decide every waiting entry in grant order, without disk or processes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -281,6 +284,9 @@ def evaluate(entries: list[dict], free_gb: float, floor: float, stage: str, sile
|
|||||||
slot_limits: {kind: max concurrent jobs}.
|
slot_limits: {kind: max concurrent jobs}.
|
||||||
ranks: {task: index in the register's ranking}; a task missing there
|
ranks: {task: index in the register's ranking}; a task missing there
|
||||||
ranks after all of them.
|
ranks after all of them.
|
||||||
|
agents: {agent name: index in the operator's agent order}; it breaks
|
||||||
|
ties of equal rank only, an entry whose holder is missing there after
|
||||||
|
all of them.
|
||||||
now: epoch seconds; each whole hour waited moves an entry one rank up.
|
now: epoch seconds; each whole hour waited moves an entry one rank up.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -293,7 +299,7 @@ def evaluate(entries: list[dict], free_gb: float, floor: float, stage: str, sile
|
|||||||
counts = Counter(e["kind"] for e in active)
|
counts = Counter(e["kind"] for e in active)
|
||||||
busy = {e["workdir"] for e in active}
|
busy = {e["workdir"] for e in active}
|
||||||
verdicts: dict[str, str | None] = {}
|
verdicts: dict[str, str | None] = {}
|
||||||
for entry in ordered([e for e in entries if e["status"] == "waiting"], ranks, now):
|
for entry in ordered([e for e in entries if e["status"] == "waiting"], ranks, agents, now):
|
||||||
kind, need, workdir = entry["kind"], entry["need_gb"], entry["workdir"]
|
kind, need, workdir = entry["kind"], entry["need_gb"], entry["workdir"]
|
||||||
reason = None
|
reason = None
|
||||||
if stage in ("stop", "critical"):
|
if stage in ("stop", "critical"):
|
||||||
@@ -324,6 +330,10 @@ def register_ranks() -> dict[str, int]:
|
|||||||
return {task: index for index, task in enumerate(rasci.ranked(rasci.merged(False)))}
|
return {task: index for index, task in enumerate(rasci.ranked(rasci.merged(False)))}
|
||||||
|
|
||||||
|
|
||||||
|
def agent_ranks() -> dict[str, int]:
|
||||||
|
return {name: index for index, name in enumerate(sibling("rasci").agent_order())}
|
||||||
|
|
||||||
|
|
||||||
def stage(cfg: dict, free_gb: float, now: float) -> str:
|
def stage(cfg: dict, free_gb: float, now: float) -> str:
|
||||||
state = watch.read_json(watch.STATE)
|
state = watch.read_json(watch.STATE)
|
||||||
return state["stage"] if "stage" in state else watch.stage_for(free_gb, [], cfg, now)
|
return state["stage"] if "stage" in state else watch.stage_for(free_gb, [], cfg, now)
|
||||||
@@ -338,7 +348,7 @@ def decide(cfg: dict, entries: list[dict], free_gb: float, now: float, kinds: di
|
|||||||
slots = {kind: limit for kind, (limit, _) in limits(cfg, kinds, entries).items()}
|
slots = {kind: limit for kind, (limit, _) in limits(cfg, kinds, entries).items()}
|
||||||
return evaluate(
|
return evaluate(
|
||||||
entries, free_gb, cfg["thresholds_gb"]["stop"], stage(cfg, free_gb, now), silenced(now),
|
entries, free_gb, cfg["thresholds_gb"]["stop"], stage(cfg, free_gb, now), silenced(now),
|
||||||
slots, register_ranks(), now,
|
slots, register_ranks(), agent_ranks(), now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ rasci.py estimate <task> "<party>" --value <fib> --complexity <fib>
|
|||||||
rasci.py wsjf [--all]
|
rasci.py wsjf [--all]
|
||||||
rasci.py tools
|
rasci.py tools
|
||||||
rasci.py agents
|
rasci.py agents
|
||||||
rasci.py order "<agent>,<agent>,..."
|
rasci.py agents order "<agent>,<agent>,..."
|
||||||
```
|
```
|
||||||
|
|
||||||
## Objective and key results
|
## Objective and key results
|
||||||
@@ -60,10 +60,9 @@ published). `done` is refused while one is open; a task ended short of them is
|
|||||||
tool the task ran (`--tool`): `tools` ranks them for `kvb-dream`.
|
tool the task ran (`--tool`): `tools` ranks them for `kvb-dream`.
|
||||||
- Priority is the rank of the `kvb-org-wsjf` skill: an operator's pin
|
- Priority is the rank of the `kvb-org-wsjf` skill: an operator's pin
|
||||||
(`--prio`) first, then WSJF from the estimates the chair collects; `show`
|
(`--prio`) first, then WSJF from the estimates the chair collects; `show`
|
||||||
prints the rank as Prio and the score as WSJF.
|
prints the rank as Prio and the score as WSJF; the agent order of
|
||||||
|
`kvb-org-priority` only breaks ties.
|
||||||
- Write only your own rows; another session's task belongs to that session.
|
- Write only your own rows; another session's task belongs to that session.
|
||||||
The one exception is `order`, the operator's pick through the
|
|
||||||
`kvb-org-priority` skill.
|
|
||||||
- Read `show` (Prio, WSJF, Task, Status, Worker, A; cells cut short) or
|
- Read `show` (Prio, WSJF, Task, Status, Worker, A; cells cut short) or
|
||||||
`get` instead of asking a peer what it works on. `show --wide` adds OKR,
|
`get` instead of asking a peer what it works on. `show --wide` adds OKR,
|
||||||
Artifacts, Start, End and every RASCI letter.
|
Artifacts, Start, End and every RASCI letter.
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
Environment:
|
Environment:
|
||||||
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
|
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
|
||||||
the register is <root>/rasci.json, and `show` also lists every work/
|
the register is <root>/rasci.json, and `show` also lists every work/
|
||||||
and cluster/ claim of the council there. /tmp/agents-council unless set.
|
and cluster/ claim of the council there. The operator's agent order
|
||||||
|
(`agents order`) lies beside it in <root>/agent-order.json, apart from
|
||||||
|
every task row: it breaks ties of equal task priority only.
|
||||||
|
/tmp/agents-council unless set.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 done, 2 usage error.
|
0 done, 2 usage error.
|
||||||
@@ -28,6 +31,7 @@ from types import ModuleType
|
|||||||
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
|
ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
|
||||||
DB = ROOT / "rasci.json"
|
DB = ROOT / "rasci.json"
|
||||||
LOCK = ROOT / "rasci.lock"
|
LOCK = ROOT / "rasci.lock"
|
||||||
|
AGENT_ORDER = ROOT / "agent-order.json"
|
||||||
FILE_MODE = 0o600
|
FILE_MODE = 0o600
|
||||||
ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts"
|
ORGANIZE = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts"
|
||||||
COUNCIL = ORGANIZE / "council.py"
|
COUNCIL = ORGANIZE / "council.py"
|
||||||
@@ -164,10 +168,21 @@ def track(task: str, holder: str, status: str, *, description: str | None = None
|
|||||||
roles[holder] = letters_of(roles.get(holder, ""), "R" if accountable else "RA")
|
roles[holder] = letters_of(roles.get(holder, ""), "R" if accountable else "RA")
|
||||||
|
|
||||||
|
|
||||||
|
def council_module() -> ModuleType | None:
|
||||||
|
return script("council", COUNCIL) if COUNCIL.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def office_holders() -> list[str]:
|
||||||
|
council = council_module()
|
||||||
|
if council is None:
|
||||||
|
return []
|
||||||
|
return [claim["name"] or claim["session"] for _, claim in council.claims()]
|
||||||
|
|
||||||
|
|
||||||
def claimed() -> dict[str, dict]:
|
def claimed() -> dict[str, dict]:
|
||||||
if not COUNCIL.is_file():
|
council = council_module()
|
||||||
|
if council is None:
|
||||||
return {}
|
return {}
|
||||||
council = script("council", COUNCIL)
|
|
||||||
live = council.live_sessions()
|
live = council.live_sessions()
|
||||||
rows = {}
|
rows = {}
|
||||||
for office, claim in council.claims():
|
for office, claim in council.claims():
|
||||||
@@ -219,12 +234,26 @@ def wsjf(row: dict) -> tuple[float, float, float] | None:
|
|||||||
return value, complexity, round(value / complexity, 2)
|
return value, complexity, round(value / complexity, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_order() -> list[str]:
|
||||||
|
try:
|
||||||
|
data = json.loads(nofollow.read_text(ROOT, AGENT_ORDER))
|
||||||
|
except (OSError, ValueError, RecursionError):
|
||||||
|
return []
|
||||||
|
names = data.get("agents") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
|
||||||
|
return []
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def ranked(rows: dict[str, dict]) -> list[str]:
|
def ranked(rows: dict[str, dict]) -> list[str]:
|
||||||
|
order = {name: index for index, name in enumerate(agent_order())}
|
||||||
|
|
||||||
def key(task: str) -> tuple:
|
def key(task: str) -> tuple:
|
||||||
row = rows[task]
|
row = rows[task]
|
||||||
score = wsjf(row)
|
score = wsjf(row)
|
||||||
pinned = row.get("prio")
|
pinned = row.get("prio")
|
||||||
return (pinned is None, pinned or 0, score is None, -(score[2] if score else 0), task)
|
agent = order.get(row.get("worker"), len(order))
|
||||||
|
return (pinned is None, pinned or 0, score is None, -(score[2] if score else 0), agent, task)
|
||||||
|
|
||||||
return sorted(rows, key=key)
|
return sorted(rows, key=key)
|
||||||
|
|
||||||
@@ -236,29 +265,45 @@ def by_agent(rows: dict[str, dict]) -> dict[str, list[str]]:
|
|||||||
return agents
|
return agents
|
||||||
|
|
||||||
|
|
||||||
def cmd_agents(_: argparse.Namespace) -> int:
|
def agents_ranked(rows: dict[str, dict]) -> dict[str, list[str]]:
|
||||||
for rank, (agent, tasks) in enumerate(by_agent(merged(include_done=False)).items(), 1):
|
tasks = by_agent(rows)
|
||||||
print(f"{rank}\t{agent}\t{len(tasks)}\t{', '.join(tasks)}")
|
for holder in office_holders():
|
||||||
|
tasks.setdefault(holder, [])
|
||||||
|
listed = [name for name in agent_order() if name in tasks]
|
||||||
|
return {name: tasks[name] for name in [*listed, *(name for name in tasks if name not in listed)]}
|
||||||
|
|
||||||
|
|
||||||
|
def store_order(text: str) -> int:
|
||||||
|
names = [name.strip() for name in text.split(",") if name.strip()]
|
||||||
|
known = agents_ranked(merged(include_done=False))
|
||||||
|
unknown = [name for name in names if name not in known]
|
||||||
|
if not names or unknown or len(set(names)) != len(names):
|
||||||
|
fail(f"agents order needs distinct agents from `agents`; unknown: {', '.join(unknown) or '-'}")
|
||||||
|
try:
|
||||||
|
ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
with nofollow.locked(ROOT, LOCK, FILE_MODE, None):
|
||||||
|
nofollow.replace(ROOT, AGENT_ORDER, json.dumps({"agents": names, "set": stamp()}, indent=1), FILE_MODE)
|
||||||
|
except OSError as error:
|
||||||
|
fail(f"agents order cannot write {AGENT_ORDER}: {error.strerror or error}")
|
||||||
|
print(f"agent order: {', '.join(names)}; the others follow by register rank; task --prio pins unchanged")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_agents(args: argparse.Namespace) -> int:
|
||||||
|
if args.action == "order":
|
||||||
|
return store_order(args.agents)
|
||||||
|
for rank, (agent, tasks) in enumerate(agents_ranked(merged(include_done=False)).items(), 1):
|
||||||
|
print(f"{rank}\t{agent}\t{len(tasks)}\t{', '.join(tasks) or '-'}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_order(args: argparse.Namespace) -> int:
|
def cmd_order(args: argparse.Namespace) -> int:
|
||||||
names = [name.strip() for name in args.agents.split(",") if name.strip()]
|
print(
|
||||||
agents = by_agent(merged(include_done=False))
|
"rasci: `order` is now `agents order`: it stores the agent order beside the register "
|
||||||
unknown = [name for name in names if name not in agents]
|
"and no longer writes --prio pins",
|
||||||
if not names or unknown or len(set(names)) != len(names):
|
file=sys.stderr,
|
||||||
fail(f"order needs distinct agents from `agents`; unknown: {', '.join(unknown) or '-'}")
|
)
|
||||||
with database() as tasks:
|
return store_order(args.agents)
|
||||||
prio = 0
|
|
||||||
for name in names:
|
|
||||||
for task in agents[name]:
|
|
||||||
prio += 1
|
|
||||||
tasks.setdefault(task, {})["prio"] = prio
|
|
||||||
for name in agents.keys() - set(names):
|
|
||||||
for task in agents[name]:
|
|
||||||
tasks.get(task, {}).pop("prio", None)
|
|
||||||
print(f"pinned {prio} tasks of {len(names)} agents; the others rank by WSJF after them")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def okr(row: dict) -> str:
|
def okr(row: dict) -> str:
|
||||||
@@ -549,7 +594,10 @@ def main() -> int:
|
|||||||
p.add_argument("--task")
|
p.add_argument("--task")
|
||||||
p.set_defaults(func=cmd_artifacts)
|
p.set_defaults(func=cmd_artifacts)
|
||||||
sub.add_parser("tools").set_defaults(func=cmd_tools)
|
sub.add_parser("tools").set_defaults(func=cmd_tools)
|
||||||
sub.add_parser("agents").set_defaults(func=cmd_agents)
|
p = sub.add_parser("agents")
|
||||||
|
p.set_defaults(func=cmd_agents)
|
||||||
|
order = p.add_subparsers(dest="action").add_parser("order")
|
||||||
|
order.add_argument("agents")
|
||||||
p = sub.add_parser("order")
|
p = sub.add_parser("order")
|
||||||
p.add_argument("agents")
|
p.add_argument("agents")
|
||||||
p.set_defaults(func=cmd_order)
|
p.set_defaults(func=cmd_order)
|
||||||
|
|||||||
+9
-1
@@ -3,7 +3,8 @@ import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
|||||||
import {tmpdir} from "node:os";
|
import {tmpdir} from "node:os";
|
||||||
import {join} from "node:path";
|
import {join} from "node:path";
|
||||||
|
|
||||||
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot, media} and an optional
|
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, keys, probe, shot, media}, keys
|
||||||
|
// being real key presses (Enter, Tab) on the focused element before the probe, and an optional
|
||||||
// authenticator {credentialId, privateKey, rpId}: base64 id and PKCS#8 key of a passkey the page may sign with.
|
// authenticator {credentialId, privateKey, rpId}: base64 id and PKCS#8 key of a passkey the page may sign with.
|
||||||
// Prints {results, errors}: one probe value per step and every uncaught page exception.
|
// Prints {results, errors}: one probe value per step and every uncaught page exception.
|
||||||
const [binary, planFile] = process.argv.slice(2);
|
const [binary, planFile] = process.argv.slice(2);
|
||||||
@@ -14,6 +15,7 @@ const profile = mkdtempSync(join(tmpdir(), "cdp-"));
|
|||||||
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
|
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
|
||||||
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
|
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const KEYS = {Enter: [13, "\r"], Tab: [9, ""]};
|
||||||
|
|
||||||
const address = await new Promise((resolve, reject) => {
|
const address = await new Promise((resolve, reject) => {
|
||||||
let text = "";
|
let text = "";
|
||||||
@@ -79,6 +81,12 @@ try {
|
|||||||
await page("Page.navigate", {url: step.url});
|
await page("Page.navigate", {url: step.url});
|
||||||
}
|
}
|
||||||
await sleep(step.wait);
|
await sleep(step.wait);
|
||||||
|
for (const key of step.keys || []) {
|
||||||
|
const [code, text] = KEYS[key];
|
||||||
|
await page("Input.dispatchKeyEvent", {type: "keyDown", key, code: key, windowsVirtualKeyCode: code, text});
|
||||||
|
await page("Input.dispatchKeyEvent", {type: "keyUp", key, code: key, windowsVirtualKeyCode: code});
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
let value = null;
|
let value = null;
|
||||||
if (step.probe) {
|
if (step.probe) {
|
||||||
const {result, exceptionDetails} = await page("Runtime.evaluate", {expression: step.probe, awaitPromise: true, returnByValue: true});
|
const {result, exceptionDetails} = await page("Runtime.evaluate", {expression: step.probe, awaitPromise: true, returnByValue: true});
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ sys.path.insert(0, str(HERE.parent))
|
|||||||
from hub_case import HubCase
|
from hub_case import HubCase
|
||||||
from hub_support import ASK, iso, verifier
|
from hub_support import ASK, iso, verifier
|
||||||
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
||||||
|
from test_hub_offices import COUNCIL_SCRIPT
|
||||||
|
from test_hub_offices import host as office_host
|
||||||
|
|
||||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
||||||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||||
SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS)]
|
SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy"]
|
||||||
HELPERS = """
|
HELPERS = """
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const until = async (check, ms) => {
|
const until = async (check, ms) => {
|
||||||
@@ -78,9 +80,57 @@ PROBE = """(async () => {@HELPERS@
|
|||||||
marks: all("#main svg.chart .node").length,
|
marks: all("#main svg.chart .node").length,
|
||||||
note: text(document.querySelector("#main .space-note")),
|
note: text(document.querySelector("#main .space-note")),
|
||||||
canvas: Boolean(document.querySelector("#main .graph3d canvas")),
|
canvas: Boolean(document.querySelector("#main .graph3d canvas")),
|
||||||
|
org: all("#main .org-chart .org-row[data-office]").map((row) => [row.dataset.office, text(row.querySelector(".org-holder"))]),
|
||||||
|
toggles: all("#main .org-toggle").map((button) => [name(button), button.getAttribute("aria-expanded")]),
|
||||||
|
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
||||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||||
};
|
};
|
||||||
})()""".replace("@HELPERS@", HELPERS)
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
ORG_OPEN = """(async () => {@HELPERS@
|
||||||
|
const row = await until(() => document.querySelector('#main .org-row[data-office="identity/oidc"]'), 8000);
|
||||||
|
if (!row) {
|
||||||
|
return {error: text(document.getElementById("main"))};
|
||||||
|
}
|
||||||
|
const toggle = all("#main .org-toggle").find((button) => name(button) === "Zuklappen: identity");
|
||||||
|
toggle.focus();
|
||||||
|
return {focused: document.activeElement.dataset.focus, offices: all("#main .org-row[data-office]").map((entry) => entry.dataset.office)};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
ORG_FOLDED = """(async () => {@HELPERS@
|
||||||
|
const toggle = document.activeElement;
|
||||||
|
const folded = {focused: toggle.dataset.focus, expanded: toggle.getAttribute("aria-expanded"), label: name(toggle), hidden: document.getElementById(toggle.getAttribute("aria-controls")).hidden, seen: all("#main .org-row[data-office]").filter((entry) => entry.offsetParent !== null).map((entry) => entry.dataset.office)};
|
||||||
|
toggle.click();
|
||||||
|
await sleep(200);
|
||||||
|
const before = all("#main .agent-order li").map((item) => item.dataset.name);
|
||||||
|
all("#main .agent-order li")[1].querySelector('[data-focus^="up:"]').focus();
|
||||||
|
return {...folded, again: toggle.getAttribute("aria-expanded"), before};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
ORDER_MOVED = """(async () => {@HELPERS@
|
||||||
|
const after = all("#main .agent-order li").map((item) => item.dataset.name);
|
||||||
|
const focused = document.activeElement.dataset.focus || null;
|
||||||
|
const save = all("#main .agent-order button").find((button) => text(button) === "Reihenfolge speichern");
|
||||||
|
const disabled = save.disabled;
|
||||||
|
save.click();
|
||||||
|
const toast = await until(() => all("#toasts .toast").find((box) => text(box).startsWith("Priorität")), 8000);
|
||||||
|
return {after, focused, disabled, toast: text(toast)};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
OPEN_TAKEOVER = """(async () => {@HELPERS@
|
||||||
|
document.querySelector('#main [data-focus="takeover:identity/oidc"]').click();
|
||||||
|
await sleep(400);
|
||||||
|
return {title: text(document.querySelector(".sheet-head .panel-title")), options: all(".sheet-box select option").map((option) => option.value), work: document.querySelector('.sheet-box input[aria-label="Arbeit"]').value, focused: document.activeElement.tagName};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
CONFIRM_TAKEOVER = """(async () => {@HELPERS@
|
||||||
|
all(".sheet-box button").find((button) => name(button) === "Übergeben").click();
|
||||||
|
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||||||
|
return {result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
AFTER_TAKEOVER = """(async () => {@HELPERS@
|
||||||
|
document.querySelector(".sheet-head button").click();
|
||||||
|
const row = await until(() => {
|
||||||
|
const found = document.querySelector('#main .org-row[data-office="identity/oidc"] .org-holder');
|
||||||
|
return found && !text(found).endsWith("Stage") ? found : null;
|
||||||
|
}, 15000);
|
||||||
|
return {holder: text(row)};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
ACTIONS = """(async () => {@HELPERS@
|
ACTIONS = """(async () => {@HELPERS@
|
||||||
const press = async (key) => {
|
const press = async (key) => {
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true}));
|
document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true}));
|
||||||
@@ -174,6 +224,8 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.claim("s1", "Skills session")
|
self.claim("s1", "Skills session")
|
||||||
self.write_council({
|
self.write_council({
|
||||||
"work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "x"},
|
"work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "x"},
|
||||||
|
"chair/claim.json": {"office": "chair", "session": "s1", "runtime": "claude", "name": "Skills session", "since": iso(now - 7200), "beat": now, "work": "allocate"},
|
||||||
|
"identity/oidc/claim.json": {"office": "identity/oidc", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "oidc line"},
|
||||||
"rasci.json": {"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}},
|
"rasci.json": {"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}},
|
||||||
"radio-silence.json": {"since": now - 60, "until": now + 3600, "by": "Docs", "text": "disk full"},
|
"radio-silence.json": {"since": now - 60, "until": now + 3600, "by": "Docs", "text": "disk full"},
|
||||||
"news.jsonl": self.jsonl([
|
"news.jsonl": self.jsonl([
|
||||||
@@ -253,15 +305,20 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
||||||
self.assertEqual((stream["menus"], stream["custom"]), (["Filter"], True))
|
self.assertEqual((stream["menus"], stream["custom"]), (["Filter"], True))
|
||||||
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
||||||
for tab in ("agents", "artifacts"):
|
for tab, own in (("agents", ["Hierarchie"]), ("artifacts", [])):
|
||||||
with self.subTest(tab=tab):
|
with self.subTest(tab=tab):
|
||||||
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D"])
|
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D", *own])
|
||||||
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Kraft", "Zeit", "Spawn-Tiefe"])
|
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Kraft", "Zeit", "Spawn-Tiefe"])
|
||||||
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ Knoten · \d+ Kanten · Kraft$")
|
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ Knoten · \d+ Kanten · Kraft$")
|
||||||
agents = by_hash["#agents/orbit"]
|
agents = by_hash["#agents/orbit"]
|
||||||
self.assertEqual(agents["toolbar"], ["Funk an alle", "Mayday aufheben", "Priorität", "Tag/Nacht"])
|
self.assertEqual(agents["toolbar"], ["Funk an alle", "Mayday aufheben", "Priorität", "Tag/Nacht"])
|
||||||
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
||||||
|
hierarchy = by_hash["#agents/hierarchy"]
|
||||||
|
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
||||||
|
self.assertEqual(hierarchy["toggles"], [["Zuklappen: Operator", "true"], ["Zuklappen: Chair", "true"], ["Zuklappen: identity", "true"], ["Zuklappen: Präsidenten", "true"]])
|
||||||
|
self.assertEqual(hierarchy["toolbar"], agents["toolbar"])
|
||||||
|
self.assertGreaterEqual(len(hierarchy["order"]), 2)
|
||||||
self.assertEqual(by_hash["#agents/tree"]["spawn"][0], ["operator", None])
|
self.assertEqual(by_hash["#agents/tree"]["spawn"][0], ["operator", None])
|
||||||
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
||||||
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
|
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
|
||||||
@@ -363,6 +420,45 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
["branch", "tree", "--format", "json", "--output", str(self.tree_file)],
|
["branch", "tree", "--format", "json", "--output", str(self.tree_file)],
|
||||||
])
|
])
|
||||||
|
|
||||||
|
def test_chromium_folds_the_hierarchy_by_keyboard_reorders_agents_and_hands_an_office_over_with_a_signature(self):
|
||||||
|
signer = self.pin()
|
||||||
|
trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "offices.json", origin=self.origin)
|
||||||
|
runner = office_host.Offices(trust, self.council, COUNCIL_SCRIPT, self.outside / "home")
|
||||||
|
jobs, results = self.offices / "jobs", self.offices / "results"
|
||||||
|
self.beside(lambda: any(jobs.iterdir()) and office_host.cycle(jobs, results, runner))
|
||||||
|
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||||||
|
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
|
||||||
|
local = f"http://localhost:{self.port}/"
|
||||||
|
steps = [
|
||||||
|
{"width": 1366, "height": 768, "url": f"{local}#agents/hierarchy", "wait": 1200, "probe": ORG_OPEN, "shot": self.shot("1366-hierarchy-open")},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "keys": ["Enter"], "probe": ORG_FOLDED, "shot": None},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "keys": ["Enter"], "probe": ORDER_MOVED, "shot": self.shot("1366-hierarchy-order")},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": OPEN_TAKEOVER, "shot": self.shot("1366-hierarchy-takeover")},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": CONFIRM_TAKEOVER, "shot": self.shot("1366-hierarchy-takeover-result")},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": AFTER_TAKEOVER, "shot": self.shot("1366-hierarchy-after")},
|
||||||
|
]
|
||||||
|
data = self.drive(steps, authenticator)
|
||||||
|
(OUT / "chromium-hierarchy.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
self.assertEqual(data["errors"], [])
|
||||||
|
opened, folded, moved, sheet, confirmed, after = data["results"]
|
||||||
|
self.assertNotIn("error", opened)
|
||||||
|
self.assertEqual(opened["focused"], "toggle:dimension:identity")
|
||||||
|
self.assertEqual((folded["focused"], folded["expanded"], folded["label"], folded["hidden"], folded["again"]), ("toggle:dimension:identity", "false", "Aufklappen: identity", True, "true"))
|
||||||
|
self.assertNotIn("identity/oidc", folded["seen"])
|
||||||
|
before = folded["before"]
|
||||||
|
self.assertEqual(moved["after"], [before[1], before[0], *before[2:]])
|
||||||
|
self.assertEqual((moved["focused"], moved["disabled"]), (f"down:{before[1]}", False))
|
||||||
|
self.assertTrue(moved["toast"].startswith("Priorität"), moved)
|
||||||
|
self.assertIn(("rasci.py", ["agents", "order", ",".join(moved["after"])]), [(Path(script).name, args) for script, args in self.scripts.calls])
|
||||||
|
self.assertEqual((sheet["title"], sheet["options"], sheet["work"], sheet["focused"]), ("identity/oidc: Übergeben", ["s1"], "oidc line", "SELECT"))
|
||||||
|
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "identity/oidcerledigt"), confirmed)
|
||||||
|
self.assertIn("Übergeben: erledigtidentity/oidc: erledigt", confirmed["toasts"])
|
||||||
|
self.assertNotIn("error", after)
|
||||||
|
claim = json.loads((self.council / "identity" / "oidc" / "claim.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((claim["session"], claim["work"]), ("s1", "oidc line"))
|
||||||
|
written = [json.loads(path.read_text(encoding="utf-8")) for path in results.glob("*.json")]
|
||||||
|
self.assertEqual([[(item["office"], item["action"], item["status"]) for item in result["items"]] for result in written], [[("identity/oidc", "takeover", "done")]])
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||||
def test_firefox_walks_every_screen_at_three_widths(self):
|
def test_firefox_walks_every_screen_at_three_widths(self):
|
||||||
shots = [(width, height, screen, OUT / f"firefox-{width}-{screen.replace('/', '-')}.png") for width, height in WIDTHS for screen in SCREENS]
|
shots = [(width, height, screen, OUT / f"firefox-{width}-{screen.replace('/', '-')}.png") for width, height in WIDTHS for screen in SCREENS]
|
||||||
|
|||||||
+2
-1
@@ -43,6 +43,7 @@ class HubCase(unittest.TestCase):
|
|||||||
self.tree_file = base / "tree" / "branch-tree.json"
|
self.tree_file = base / "tree" / "branch-tree.json"
|
||||||
self.timers = base / "timers"
|
self.timers = base / "timers"
|
||||||
self.cleanup = base / "state" / "cleanup"
|
self.cleanup = base / "state" / "cleanup"
|
||||||
|
self.offices = base / "state" / "offices"
|
||||||
self.approvers = base / "config" / "approvers.json"
|
self.approvers = base / "config" / "approvers.json"
|
||||||
self.replay = base / "replay"
|
self.replay = base / "replay"
|
||||||
for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers):
|
for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers):
|
||||||
@@ -64,7 +65,7 @@ class HubCase(unittest.TestCase):
|
|||||||
transcripts=self.projects,
|
transcripts=self.projects,
|
||||||
scripts=self.scripts,
|
scripts=self.scripts,
|
||||||
templates=HUB / "templates",
|
templates=HUB / "templates",
|
||||||
places=server.Places(tree_file=self.tree_file, timers=self.timers, vendor=HUB / "vendor", cleanup=self.cleanup),
|
places=server.Places(tree_file=self.tree_file, timers=self.timers, vendor=HUB / "vendor", cleanup=self.cleanup, offices=self.offices),
|
||||||
model=server.Model(url="", model="", key=""),
|
model=server.Model(url="", model="", key=""),
|
||||||
engines=self.engines,
|
engines=self.engines,
|
||||||
approvers=self.approvers,
|
approvers=self.approvers,
|
||||||
|
|||||||
+57
-1
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -9,6 +11,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
COUNCIL = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "council.py"
|
COUNCIL = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "council.py"
|
||||||
@@ -30,7 +33,7 @@ class TestCouncil(unittest.TestCase):
|
|||||||
json.dumps({"sessionId": sid, "name": name}), encoding="utf-8"
|
json.dumps({"sessionId": sid, "name": name}), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _run(self, sid: str, *args: str, stdin: str = "") -> subprocess.CompletedProcess:
|
def _run(self, sid: str, *args: str, stdin: str = "", cwd: str | None = None) -> subprocess.CompletedProcess:
|
||||||
env = {
|
env = {
|
||||||
"PATH": "/usr/bin:/bin",
|
"PATH": "/usr/bin:/bin",
|
||||||
"HOME": str(self.home),
|
"HOME": str(self.home),
|
||||||
@@ -43,6 +46,7 @@ class TestCouncil(unittest.TestCase):
|
|||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
input=stdin,
|
input=stdin,
|
||||||
|
cwd=cwd,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +78,58 @@ class TestCouncil(unittest.TestCase):
|
|||||||
self.assertEqual(self._run("s1", "release", "work/x").returncode, 0)
|
self.assertEqual(self._run("s1", "release", "work/x").returncode, 0)
|
||||||
self.assertFalse((self.root / "work/x").exists())
|
self.assertFalse((self.root / "work/x").exists())
|
||||||
|
|
||||||
|
def test_no_flag_lets_an_agent_take_over_or_release_a_live_holder(self):
|
||||||
|
self._run("s1", "claim", "security/gui", "--work", "w")
|
||||||
|
before = (self.root / "security/gui/claim.json").read_text(encoding="utf-8")
|
||||||
|
for args in (("takeover", "security/gui", "--work", "w2"), ("takeover", "security/gui", "--work", "w2", "--operator"), ("release", "security/gui"), ("release", "security/gui", "--operator")):
|
||||||
|
with self.subTest(args=args):
|
||||||
|
self.assertIn(self._run("s2", *args).returncode, (2, 3))
|
||||||
|
self.assertEqual((self.root / "security/gui/claim.json").read_text(encoding="utf-8"), before)
|
||||||
|
|
||||||
|
def test_no_office_operation_follows_a_planted_link(self):
|
||||||
|
outside = Path(self.tmp.name) / "outside"
|
||||||
|
(outside / "gui").mkdir(parents=True)
|
||||||
|
(outside / "gui" / "keep.txt").write_text("x", encoding="utf-8")
|
||||||
|
(outside / "gui" / "claim.json").write_text(json.dumps({"session": "s1", "runtime": "claude-code", "beat": 1.0}), encoding="utf-8")
|
||||||
|
self.root.mkdir()
|
||||||
|
(self.root / "security").symlink_to(outside)
|
||||||
|
for args in (
|
||||||
|
("takeover", "security/gui", "--work", "w"),
|
||||||
|
("release", "security/gui"),
|
||||||
|
("claim", "security/new", "--work", "w"),
|
||||||
|
("claim", "security/gui", "--work", "w"),
|
||||||
|
):
|
||||||
|
with self.subTest(args=args):
|
||||||
|
self.assertNotEqual(self._run("s2", *args).stdout.split(" ")[0], "elected")
|
||||||
|
self.assertEqual(sorted(path.name for path in outside.rglob("*")), ["claim.json", "gui", "keep.txt"])
|
||||||
|
(self.root / "work").mkdir()
|
||||||
|
(self.root / "work" / "x").symlink_to(outside / "gui")
|
||||||
|
self.assertEqual(self._run("s2", "takeover", "work/x", "--work", "w").returncode, 3)
|
||||||
|
self.assertTrue((outside / "gui" / "keep.txt").exists())
|
||||||
|
|
||||||
|
def test_a_claim_from_the_root_directory_records_no_workdir_or_branch(self):
|
||||||
|
self.assertEqual(self._run("s2", "claim", "work/y", "--work", "appointed", cwd="/").returncode, 0)
|
||||||
|
row = json.loads((self.root / "rasci.json").read_text(encoding="utf-8"))["work/y"]
|
||||||
|
self.assertEqual((row["worker"], row["description"], row["status"]), ("Two", "appointed", "running"))
|
||||||
|
self.assertNotIn("workdir", row)
|
||||||
|
self.assertNotIn("branch", row)
|
||||||
|
|
||||||
|
def test_a_vacant_office_is_taken_over_without_a_council_root_or_group(self):
|
||||||
|
self.assertEqual(self._run("s1", "takeover", "work/x", "--work", "w").stdout, "elected work/x\n")
|
||||||
|
self.assertEqual(self._run("s1", "takeover", "security/gui", "--work", "w").stdout, "elected security/gui\n")
|
||||||
|
|
||||||
|
def test_a_beat_racing_a_release_never_brings_the_office_back(self):
|
||||||
|
spec = importlib.util.spec_from_file_location("council_beat_race", COUNCIL)
|
||||||
|
council = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(council)
|
||||||
|
self.root.mkdir()
|
||||||
|
council.ROOT = self.root
|
||||||
|
released = {"session": "s1", "runtime": "claude-code", "beat": 1.0}
|
||||||
|
council.claims = lambda: [("work/gone", dict(released))]
|
||||||
|
with mock.patch.dict(os.environ, {"CLAUDE_CODE_SESSION_ID": "s1"}):
|
||||||
|
self.assertEqual(council.cmd_beat(argparse.Namespace(session=None)), 0)
|
||||||
|
self.assertEqual(sorted(self.root.rglob("*")), [])
|
||||||
|
|
||||||
def test_gate_blocks_unclaimed_edits_in_a_git_worktree_only(self):
|
def test_gate_blocks_unclaimed_edits_in_a_git_worktree_only(self):
|
||||||
repo = Path(self.tmp.name) / "repo"
|
repo = Path(self.tmp.name) / "repo"
|
||||||
(repo / ".git").mkdir(parents=True)
|
(repo / ".git").mkdir(parents=True)
|
||||||
|
|||||||
+11
-4
@@ -69,7 +69,11 @@ class TestHubBoard(HubCase):
|
|||||||
"rasci.json": {"work/a": {"worker": "A", "status": "running", "prio": 2}, "work/b": {"worker": "B session", "status": "queued", "prio": 1}, "work/c": {"worker": "C", "status": "done"}},
|
"rasci.json": {"work/a": {"worker": "A", "status": "running", "prio": 2}, "work/b": {"worker": "B session", "status": "queued", "prio": 1}, "work/c": {"worker": "C", "status": "done"}},
|
||||||
})
|
})
|
||||||
state = self.snapshot()
|
state = self.snapshot()
|
||||||
self.assertEqual((state["silence"]["by"], state["night"], state["priority"]), ("Docs", "evening", ["B session", "A"]))
|
self.assertEqual((state["silence"]["by"], state["night"]), ("Docs", "evening"))
|
||||||
|
self.assertEqual(state["agent_order"], ["B session", "A", "Gatekeeper", "Skills session"])
|
||||||
|
self.write_council({"agent-order.json": {"agents": ["Gatekeeper", "Nobody", "A", "A"], "set": "2026-11-07T10:00:00+00:00"}})
|
||||||
|
self.assertEqual(self.snapshot()["agent_order"], ["Gatekeeper", "A", "B session", "Skills session"])
|
||||||
|
register = (self.council / "rasci.json").read_text(encoding="utf-8")
|
||||||
|
|
||||||
def board(action: str, payload: object) -> tuple[int, dict | None]:
|
def board(action: str, payload: object) -> tuple[int, dict | None]:
|
||||||
status, _, body = self.post_json(f"/api/board/{action}", payload, self.guard(self.host))
|
status, _, body = self.post_json(f"/api/board/{action}", payload, self.guard(self.host))
|
||||||
@@ -81,11 +85,14 @@ class TestHubBoard(HubCase):
|
|||||||
status, result = board("broadcast", {"text": "main is green", "class": "newsletter"})
|
status, result = board("broadcast", {"text": "main is green", "class": "newsletter"})
|
||||||
message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"]})["structuredContent"]["id"]
|
message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"]})["structuredContent"]["id"]
|
||||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-orchestrator msg {message_id}] main is green"]))
|
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-orchestrator msg {message_id}] main is green"]))
|
||||||
self.assertEqual(board("order", {"agents": ["A", "B session"]})[0], 200)
|
self.assertEqual(board("order", {"agents": ["Gatekeeper", "A", "B session"]})[0], 200)
|
||||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RASCI, ["order", "A,B session"]))
|
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RASCI, ["agents", "order", "Gatekeeper,A,B session"]))
|
||||||
for payload in ({"agents": ["A,B"]}, {"agents": ["A", "A"]}, {"agents": []}, {"agents": ["-x"]}, {"agents": "A"}, {"text": "x"}):
|
calls = len(self.scripts.calls)
|
||||||
|
for payload in ({"agents": ["A,B"]}, {"agents": ["A", "A"]}, {"agents": []}, {"agents": ["-x"]}, {"agents": "A"}, {"text": "x"}, {"agents": ["A", "Nobody"]}, {"agents": ["C"]}):
|
||||||
with self.subTest(payload=payload):
|
with self.subTest(payload=payload):
|
||||||
self.assertEqual(board("order", payload)[0], 400)
|
self.assertEqual(board("order", payload)[0], 400)
|
||||||
|
self.assertEqual(len(self.scripts.calls), calls)
|
||||||
|
self.assertEqual((self.council / "rasci.json").read_text(encoding="utf-8"), register)
|
||||||
calls = len(self.scripts.calls)
|
calls = len(self.scripts.calls)
|
||||||
self.assertEqual(board("evening", {})[0], 200)
|
self.assertEqual(board("evening", {})[0], 200)
|
||||||
self.assertEqual(self.scripts.calls[calls:], [
|
self.assertEqual(self.scripts.calls[calls:], [
|
||||||
|
|||||||
+15
-2
@@ -9,10 +9,14 @@ from hub_support import NAV
|
|||||||
NAV_PROBE = """
|
NAV_PROBE = """
|
||||||
const N = require(process.argv[1]);
|
const N = require(process.argv[1]);
|
||||||
const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope",
|
const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope",
|
||||||
"#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane"];
|
"#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane",
|
||||||
|
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy"];
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]),
|
tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]),
|
||||||
views: N.VIEWS.map(([id, label]) => [id, label]),
|
views: N.VIEWS.map(([id, label]) => [id, label]),
|
||||||
|
agents: N.viewsOf("agents").map(([id, label]) => [id, label]),
|
||||||
|
artifacts: N.viewsOf("artifacts").map(([id, label]) => [id, label]),
|
||||||
|
icon: N.ICONS.hierarchy,
|
||||||
routes: hashes.map((hash) => N.route(hash)),
|
routes: hashes.map((hash) => N.route(hash)),
|
||||||
hashes: [["stream", null], ["inbox", null], ["rules", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)),
|
hashes: [["stream", null], ["inbox", null], ["rules", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)),
|
||||||
tabOf: ["stream", "inbox", "rules", "agents", "artifacts"].map((screen) => N.tabOf(screen)),
|
tabOf: ["stream", "inbox", "rules", "agents", "artifacts"].map((screen) => N.tabOf(screen)),
|
||||||
@@ -30,7 +34,12 @@ class TestNavModule(unittest.TestCase):
|
|||||||
self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]])
|
self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]])
|
||||||
|
|
||||||
def test_agents_and_artifacts_offer_five_views_in_order(self):
|
def test_agents_and_artifacts_offer_five_views_in_order(self):
|
||||||
self.assertEqual(self.data["views"], [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]])
|
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
|
||||||
|
self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared))
|
||||||
|
|
||||||
|
def test_only_agents_add_the_hierarchy_after_3d(self):
|
||||||
|
self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchie"]])
|
||||||
|
self.assertEqual(self.data["icon"], "bi-diagram-2")
|
||||||
|
|
||||||
def test_hashes_route_and_the_old_ones_redirect(self):
|
def test_hashes_route_and_the_old_ones_redirect(self):
|
||||||
stream = {"screen": "stream", "view": None, "redirect": None}
|
stream = {"screen": "stream", "view": None, "redirect": None}
|
||||||
@@ -52,6 +61,10 @@ class TestNavModule(unittest.TestCase):
|
|||||||
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
|
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
|
||||||
stream,
|
stream,
|
||||||
{"screen": "agents", "view": "swimlane", "redirect": None},
|
{"screen": "agents", "view": "swimlane", "redirect": None},
|
||||||
|
{"screen": "agents", "view": "hierarchy", "redirect": None},
|
||||||
|
{"screen": "artifacts", "view": None, "redirect": None},
|
||||||
|
{"screen": "agents", "view": "hierarchy", "redirect": "#agents/hierarchy"},
|
||||||
|
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
|
||||||
])
|
])
|
||||||
|
|
||||||
def test_every_screen_has_its_own_hash_and_tab(self):
|
def test_every_screen_has_its_own_hash_and_tab(self):
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from hub_case import HubCase
|
||||||
|
from hub_support import HUB, ORIGIN, REPO_ROOT, key, signing, verifier
|
||||||
|
|
||||||
|
OFFICES = HUB / "client" / "offices.py"
|
||||||
|
OFFICES_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_offices_runner", OFFICES)
|
||||||
|
host = importlib.util.module_from_spec(OFFICES_SPEC)
|
||||||
|
OFFICES_SPEC.loader.exec_module(host)
|
||||||
|
COUNCIL_SCRIPT = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "council.py"
|
||||||
|
SINCE = "2026-11-07T08:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def office_claim(office: str, session: str, name: str, runtime: str) -> dict:
|
||||||
|
return {"office": office, "session": session, "runtime": runtime, "name": name, "since": SINCE, "beat": time.time(), "work": f"{office} work"}
|
||||||
|
|
||||||
|
|
||||||
|
def unblock(fifo: Path) -> None:
|
||||||
|
try:
|
||||||
|
os.close(os.open(fifo, os.O_WRONLY | os.O_NONBLOCK))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubOffices(HubCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.write_council({
|
||||||
|
"chair/claim.json": office_claim("chair", "s1", "Maintainer", "claude-code"),
|
||||||
|
"identity/oidc/claim.json": office_claim("identity/oidc", "s2", "Skills", "claude-code"),
|
||||||
|
"work/hub/claim.json": office_claim("work/hub", "s3", "Stage", "claude-code"),
|
||||||
|
"cluster/gate/claim.json": office_claim("cluster/gate", "s2", "Skills", "claude-code"),
|
||||||
|
})
|
||||||
|
self.snapshot()
|
||||||
|
|
||||||
|
def sign(self, items: list[dict]) -> tuple[int, bytes]:
|
||||||
|
status, _, body = self.post_json("/api/sign", {"items": items}, self.guard(self.host))
|
||||||
|
return status, body
|
||||||
|
|
||||||
|
def test_the_chair_is_the_holder_of_the_chair_office(self):
|
||||||
|
state = self.snapshot()
|
||||||
|
self.assertEqual([(node["id"], node["kind"]) for node in state["graph"]["nodes"][:2]], [("Operator", "operator"), ("s1", "chair")])
|
||||||
|
self.assertEqual({claim["office"]: claim["kind"] for claim in state["claims"]}, {"chair": "chair", "cluster/gate": "cluster", "identity/oidc": "line", "work/hub": "work"})
|
||||||
|
code = re.sub(r'<script nonce="[^"]+" id="state" type="application/json">.*?</script>', "", self.index(), flags=re.DOTALL)
|
||||||
|
self.assertNotIn("Maintainer", code)
|
||||||
|
|
||||||
|
def test_a_fifo_or_link_at_a_council_file_never_blocks_or_feeds_the_state(self):
|
||||||
|
fifos = [self.council / "work" / "fifo" / "claim.json", self.council / "agent-order.json"]
|
||||||
|
fifos[0].parent.mkdir(parents=True)
|
||||||
|
for fifo in fifos:
|
||||||
|
os.mkfifo(fifo, 0o600)
|
||||||
|
self.addCleanup(unblock, fifo)
|
||||||
|
planted = self.outside / "claim.json"
|
||||||
|
planted.write_text(json.dumps(office_claim("security/gui", "s9", "Linked", "claude-code")), encoding="utf-8")
|
||||||
|
(self.council / "security" / "gui").mkdir(parents=True)
|
||||||
|
(self.council / "security" / "gui" / "claim.json").symlink_to(planted)
|
||||||
|
offices = [claim["office"] for claim in self.snapshot()["claims"]]
|
||||||
|
self.assertEqual(offices, ["chair", "cluster/gate", "identity/oidc", "work/hub"])
|
||||||
|
|
||||||
|
def test_a_vacant_chair_draws_no_chair_node_and_the_operator_grants(self):
|
||||||
|
shutil.rmtree(self.council / "chair")
|
||||||
|
now = time.time()
|
||||||
|
self.write_council({
|
||||||
|
"rasci.json": {"work/hub": {"title": "Hub", "status": "running", "worker": "Stage", "workdir": "/repo/hub", "roles": {"Stage": "RA"}}},
|
||||||
|
"queue-history.jsonl": self.jsonl([{"id": "q1", "kind": "gate", "workdir": "/repo/hub", "start": now - 10, "end": now - 5, "exit": 0}]),
|
||||||
|
})
|
||||||
|
state = self.snapshot()
|
||||||
|
self.assertEqual([node["kind"] for node in state["graph"]["nodes"]].count("chair"), 0)
|
||||||
|
self.assertIn({"source": "Operator", "target": "s3", "class": "grant", "count": 1}, state["graph"]["edges"])
|
||||||
|
|
||||||
|
def test_signing_an_office_needs_a_known_session_the_current_holder_and_one_line_per_dimension(self):
|
||||||
|
for name, item in (
|
||||||
|
("appoint a vacant line", signing.office_item("appoint", "security/gui", "", "s3", "GUI line")),
|
||||||
|
("take over the chair", signing.office_item("takeover", "chair", "s1", "s2", "allocate")),
|
||||||
|
("recall a president", signing.office_item("recall", "work/hub", "s3", "", "")),
|
||||||
|
("take over a line in the own dimension", signing.office_item("takeover", "identity/oidc", "s2", "s3", "oidc")),
|
||||||
|
):
|
||||||
|
with self.subTest(name=name):
|
||||||
|
status, body = self.sign([item])
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(item)])
|
||||||
|
bad = (
|
||||||
|
("capital office", signing.office_item("appoint", "Chair", "", "s3", "w"), 400),
|
||||||
|
("three levels", signing.office_item("appoint", "a/b/c", "", "s3", "w"), 400),
|
||||||
|
("climbing office", signing.office_item("appoint", "../x", "", "s3", "w"), 400),
|
||||||
|
("bare group", signing.office_item("appoint", "work", "", "s3", "w"), 400),
|
||||||
|
("unknown session", signing.office_item("appoint", "security/gui", "", "s9", "w"), 400),
|
||||||
|
("pseudo session", signing.office_item("appoint", "security/gui", "", "name:Docs", "w"), 400),
|
||||||
|
("the operator", signing.office_item("appoint", "security/gui", "", "Operator", "w"), 400),
|
||||||
|
("unknown action", {**signing.office_item("appoint", "security/gui", "", "s3", "w"), "action": "fire"}, 400),
|
||||||
|
("empty work", signing.office_item("appoint", "security/gui", "", "s3", " "), 400),
|
||||||
|
("two lines of work", signing.office_item("appoint", "security/gui", "", "s3", "a\nb"), 400),
|
||||||
|
("long work", signing.office_item("appoint", "security/gui", "", "s3", "x" * 301), 400),
|
||||||
|
("recall with a successor", signing.office_item("recall", "work/hub", "s3", "s2", ""), 400),
|
||||||
|
("extra key", {**signing.office_item("recall", "work/hub", "s3", "", ""), "command": "rm -rf ~"}, 400),
|
||||||
|
("missing key", {key_name: value for key_name, value in signing.office_item("recall", "work/hub", "s3", "", "").items() if key_name != "holder"}, 400),
|
||||||
|
("number", {**signing.office_item("recall", "work/hub", "s3", "", ""), "holder": 3}, 400),
|
||||||
|
("held office", signing.office_item("appoint", "identity/oidc", "", "s3", "w"), 409),
|
||||||
|
("holder changed", signing.office_item("takeover", "chair", "s2", "s3", "w"), 409),
|
||||||
|
("vacant recall", signing.office_item("recall", "security/gui", "", "", ""), 400),
|
||||||
|
("option as session", signing.office_item("appoint", "security/gui", "", "--all", "w"), 400),
|
||||||
|
("successor is the holder", signing.office_item("takeover", "chair", "s1", "s1", "w"), 400),
|
||||||
|
("second line of a dimension", signing.office_item("appoint", "identity/saml", "", "s2", "w"), 400),
|
||||||
|
)
|
||||||
|
for name, item, expected in bad:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertEqual(self.sign([item])[0], expected)
|
||||||
|
item = signing.office_item("appoint", "security/gui", "", "s3", "GUI line")
|
||||||
|
self.assertEqual(self.sign([item, item])[0], 400)
|
||||||
|
|
||||||
|
def test_a_signed_office_becomes_a_job_without_any_command(self):
|
||||||
|
item = signing.office_item("takeover", "chair", "s1", "s2", "allocate")
|
||||||
|
signer = self.pin()
|
||||||
|
proof = signer.proof(self.prepare([item])["payload"], self.origin)
|
||||||
|
self.assertEqual(self.post_json("/api/offices", {"items": [item], "proof": None}, self.guard(self.host))[0], 400)
|
||||||
|
self.assertEqual(self.post_json("/api/offices", {"items": [item], "proof": proof}, {"Host": self.host})[0], 403)
|
||||||
|
self.assertEqual(self.post_json("/api/offices", {"items": [{**item, "holder": "s3"}], "proof": proof}, self.guard(self.host))[0], 409)
|
||||||
|
status, _, body = self.post_json("/api/offices", {"items": [item], "proof": proof}, self.guard(self.host))
|
||||||
|
self.assertEqual(status, 202, body)
|
||||||
|
job_id = json.loads(body)["id"]
|
||||||
|
written = self.offices / "jobs" / f"{job_id}.json"
|
||||||
|
job = json.loads(written.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(sorted(job), ["created", "id", "items", "proof", "v"])
|
||||||
|
self.assertEqual(job["items"], [{key_name: item[key_name] for key_name in ("action", "office", "holder", "session", "work")}])
|
||||||
|
self.assertEqual(job["proof"], proof)
|
||||||
|
self.assertNotIn("command", written.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((written.stat().st_mode & 0o777, (self.offices / "jobs").stat().st_mode & 0o777), (0o600, 0o700))
|
||||||
|
self.assertEqual(json.loads(self.get(f"/api/offices/{job_id}")[2])["status"], "queued")
|
||||||
|
self.assertEqual(self.get("/api/offices/0000000000000000")[0], 404)
|
||||||
|
self.assertEqual(self.get("/api/offices")[0], 405)
|
||||||
|
result = {"id": job_id, "finished": time.time(), "items": [{**job["items"][0], "status": "done", "reason": ""}]}
|
||||||
|
(self.offices / "results" / f"{job_id}.json").write_text(json.dumps(result), encoding="utf-8")
|
||||||
|
self.assertEqual(json.loads(self.get(f"/api/offices/{job_id}")[2])["status"], "done")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostOffices(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
base = Path(tmp.name)
|
||||||
|
self.council = base / "council"
|
||||||
|
self.council.mkdir(mode=0o700)
|
||||||
|
self.home = base / "home"
|
||||||
|
sessions = self.home / ".claude" / "sessions"
|
||||||
|
sessions.mkdir(parents=True)
|
||||||
|
for sid, name in (("s1", "One"), ("s2", "Two")):
|
||||||
|
(sessions / f"{sid}.json").write_text(json.dumps({"sessionId": sid, "name": name}), encoding="utf-8")
|
||||||
|
self.jobs, self.results = base / "jobs", base / "results"
|
||||||
|
self.jobs.mkdir()
|
||||||
|
self.results.mkdir()
|
||||||
|
self.approvers = base / "config" / "approvers.json"
|
||||||
|
self.trust = verifier.Trust(approvers=self.approvers, replay=base / "replay" / "offices.json", origin=ORIGIN)
|
||||||
|
self.signer = key(-7, "main")
|
||||||
|
self.signer.pin(self.approvers, ORIGIN)
|
||||||
|
self.runner = host.Offices(self.trust, self.council, COUNCIL_SCRIPT, self.home)
|
||||||
|
|
||||||
|
def proof(self, items: list[dict], signer) -> dict:
|
||||||
|
hashes = [signing.item_hash(signing.office_item(**item)) for item in items]
|
||||||
|
return signer.proof(signing.approval_payload(hashes, secrets.token_hex(16), int(time.time()) + 600), ORIGIN)
|
||||||
|
|
||||||
|
def run_job(self, items: list[dict], proof: object) -> dict:
|
||||||
|
job_id = secrets.token_hex(8)
|
||||||
|
(self.jobs / f"{job_id}.json").write_text(json.dumps({"v": 1, "id": job_id, "created": "x", "items": items, "proof": proof}), encoding="utf-8")
|
||||||
|
self.assertEqual(host.cycle(self.jobs, self.results, self.runner), 1)
|
||||||
|
self.assertEqual(list(self.jobs.iterdir()), [])
|
||||||
|
return json.loads((self.results / f"{job_id}.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def signed(self, item: dict) -> dict:
|
||||||
|
return self.run_job([item], self.proof([item], self.signer))
|
||||||
|
|
||||||
|
def holder(self, office: str) -> dict | None:
|
||||||
|
path = self.council / office / "claim.json"
|
||||||
|
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else None
|
||||||
|
|
||||||
|
def files(self) -> list[str]:
|
||||||
|
return sorted(str(path.relative_to(self.council)) for path in self.council.rglob("*"))
|
||||||
|
|
||||||
|
def test_appoint_take_over_and_recall_run_the_council_script_for_the_operator(self):
|
||||||
|
appoint = {"action": "appoint", "office": "chair", "holder": "", "session": "s1", "work": "allocate"}
|
||||||
|
self.assertEqual([(entry["status"], entry["code"]) for entry in self.signed(appoint)["items"]], [("done", 0)])
|
||||||
|
self.assertEqual({key_name: self.holder("chair")[key_name] for key_name in ("session", "runtime", "name", "work")}, {"session": "s1", "runtime": "claude-code", "name": "One", "work": "allocate"})
|
||||||
|
takeover = {"action": "takeover", "office": "chair", "holder": "s1", "session": "s2", "work": "allocate more"}
|
||||||
|
self.assertEqual(self.signed(takeover)["items"][0]["status"], "done")
|
||||||
|
self.assertEqual((self.holder("chair")["session"], self.holder("chair")["name"]), ("s2", "Two"))
|
||||||
|
asleep = {"action": "appoint", "office": "security/gui", "holder": "", "session": "s9", "work": "gui line"}
|
||||||
|
self.assertEqual(self.signed(asleep)["items"][0]["status"], "done")
|
||||||
|
self.assertEqual((self.holder("security/gui")["session"], self.holder("security/gui")["runtime"]), ("s9", "other"))
|
||||||
|
recall = {"action": "recall", "office": "chair", "holder": "s2", "session": "", "work": ""}
|
||||||
|
self.assertEqual(self.signed(recall)["items"][0]["status"], "done")
|
||||||
|
self.assertIsNone(self.holder("chair"))
|
||||||
|
president = {"action": "appoint", "office": "work/new", "holder": "", "session": "s1", "work": "build it"}
|
||||||
|
self.assertEqual(self.signed(president)["items"][0]["status"], "done")
|
||||||
|
row = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/new"]
|
||||||
|
self.assertEqual((row["worker"], row["description"], "workdir" in row, "branch" in row), ("One", "build it", False, False))
|
||||||
|
|
||||||
|
def test_every_doubtful_item_is_refused_and_changes_nothing(self):
|
||||||
|
appoint = {"action": "appoint", "office": "chair", "holder": "", "session": "s1", "work": "allocate"}
|
||||||
|
self.signed({"action": "appoint", "office": "identity/oidc", "holder": "", "session": "s2", "work": "oidc"})
|
||||||
|
rule = self.signer.proof(signing.rule_payload(secrets.token_hex(8), ["office"], "", "*", "*", int(time.time()) + 600), ORIGIN)
|
||||||
|
outside = self.council.parent / "outside"
|
||||||
|
(outside / "gui").mkdir(parents=True)
|
||||||
|
cases = (
|
||||||
|
("bad signature", appoint, self.proof([appoint], key(-7, "intruder")), "no valid operator signature"),
|
||||||
|
("missing signature", appoint, None, "no signature"),
|
||||||
|
("rule instead of approval", appoint, rule, "no valid operator signature"),
|
||||||
|
("other item signed", appoint, self.proof([{**appoint, "session": "s2"}], self.signer), "does not list this item"),
|
||||||
|
("holder changed", {**appoint, "office": "identity/oidc"}, self.proof([{**appoint, "office": "identity/oidc"}], self.signer), "holder changed"),
|
||||||
|
)
|
||||||
|
for name, item, proof, reason in cases:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
before = self.files()
|
||||||
|
result = self.run_job([item], proof)
|
||||||
|
self.assertEqual([entry["status"] for entry in result["items"]], ["refused"])
|
||||||
|
self.assertIn(reason, result["items"][0]["reason"])
|
||||||
|
self.assertEqual(self.files(), before)
|
||||||
|
(self.council / "security").symlink_to(outside)
|
||||||
|
linked = {**appoint, "office": "security/gui"}
|
||||||
|
result = self.signed(linked)
|
||||||
|
self.assertEqual(result["items"][0]["status"], "refused")
|
||||||
|
self.assertIn("link", result["items"][0]["reason"])
|
||||||
|
self.assertEqual(list(outside.rglob("*")), [outside / "gui"])
|
||||||
|
(self.council / "security").unlink()
|
||||||
|
self.council.chmod(0o777)
|
||||||
|
result = self.signed(appoint)
|
||||||
|
self.assertEqual(result["items"][0]["status"], "refused")
|
||||||
|
self.assertIn("others can write", result["items"][0]["reason"])
|
||||||
|
self.council.chmod(0o700)
|
||||||
|
self.assertIsNone(self.holder("chair"))
|
||||||
|
|
||||||
|
def test_the_runner_passes_no_flag_and_checks_the_holder_in_the_removal_itself(self):
|
||||||
|
self.signed({"action": "appoint", "office": "chair", "holder": "", "session": "s1", "work": "allocate"})
|
||||||
|
takeover = {"action": "takeover", "office": "chair", "holder": "s1", "session": "s2", "work": "allocate"}
|
||||||
|
recall = {"action": "recall", "office": "chair", "holder": "s1", "session": "", "work": ""}
|
||||||
|
self.assertNotIn("--operator", self.runner.command(takeover)[0])
|
||||||
|
swapped = {**self.holder("chair"), "session": "s9"}
|
||||||
|
(self.council / "chair" / "claim.json").write_text(json.dumps(swapped), encoding="utf-8")
|
||||||
|
with mock.patch.object(host, "holder_of", return_value="s1"):
|
||||||
|
for item in (takeover, recall):
|
||||||
|
with self.subTest(action=item["action"]):
|
||||||
|
result = self.signed(item)["items"][0]
|
||||||
|
self.assertEqual(result["status"], "refused", result)
|
||||||
|
self.assertIn("holder changed", result["reason"])
|
||||||
|
self.assertEqual(self.holder("chair"), swapped)
|
||||||
|
self.assertEqual(self.files(), ["chair", "chair/claim.json"])
|
||||||
|
|
||||||
|
def test_a_claim_council_would_not_read_is_never_reported_recalled(self):
|
||||||
|
(self.council / "chair").mkdir()
|
||||||
|
(self.council / "chair" / "claim.json").write_text(json.dumps({"session": "s1"}), encoding="utf-8")
|
||||||
|
result = self.signed({"action": "recall", "office": "chair", "holder": "s1", "session": "", "work": ""})["items"][0]
|
||||||
|
self.assertNotEqual(result["status"], "done", result)
|
||||||
|
self.assertEqual(self.files(), ["chair", "chair/claim.json"])
|
||||||
|
|
||||||
|
def test_a_job_that_is_a_link_a_fifo_or_too_large_runs_nothing(self):
|
||||||
|
appoint = {"action": "appoint", "office": "chair", "holder": "", "session": "s1", "work": "allocate"}
|
||||||
|
linked, fifo, large = (secrets.token_hex(8) for _ in range(3))
|
||||||
|
planted = self.council.parent / "planted.json"
|
||||||
|
planted.write_text(json.dumps({"v": 1, "id": linked, "created": "x", "items": [appoint], "proof": self.proof([appoint], self.signer)}), encoding="utf-8")
|
||||||
|
(self.jobs / f"{linked}.json").symlink_to(planted)
|
||||||
|
os.mkfifo(self.jobs / f"{fifo}.json", 0o600)
|
||||||
|
(self.jobs / f"{large}.json").write_text(" " * (host.READ_LIMIT + 1), encoding="utf-8")
|
||||||
|
self.assertEqual(host.cycle(self.jobs, self.results, self.runner), 3)
|
||||||
|
for job_id in (linked, fifo, large):
|
||||||
|
with self.subTest(job=job_id):
|
||||||
|
result = json.loads((self.results / f"{job_id}.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(result["items"], [])
|
||||||
|
self.assertIn("unreadable job", result["error"])
|
||||||
|
self.assertEqual((list(self.jobs.iterdir()), self.files()), ([], []))
|
||||||
|
self.assertTrue(planted.is_file())
|
||||||
|
|
||||||
|
def test_a_signature_runs_once_and_a_malformed_job_nothing(self):
|
||||||
|
appoint = {"action": "appoint", "office": "chair", "holder": "", "session": "s1", "work": "allocate"}
|
||||||
|
proof = self.proof([appoint], self.signer)
|
||||||
|
self.assertEqual(self.run_job([appoint], proof)["items"][0]["status"], "done")
|
||||||
|
self.signed({"action": "recall", "office": "chair", "holder": "s1", "session": "", "work": ""})
|
||||||
|
again = self.run_job([appoint], proof)
|
||||||
|
self.assertEqual(again["items"][0]["status"], "refused")
|
||||||
|
self.assertIn("already used", again["items"][0]["reason"])
|
||||||
|
for name, items in (
|
||||||
|
("command smuggled in", [{**appoint, "command": "rm -rf ~"}]),
|
||||||
|
("climbing office", [{**appoint, "office": "../x"}]),
|
||||||
|
("unknown action", [{**appoint, "action": "fire"}]),
|
||||||
|
("option as session", [{**appoint, "session": "--all"}]),
|
||||||
|
("two lines of work", [{**appoint, "work": "a\nb"}]),
|
||||||
|
("recall with work", [{"action": "recall", "office": "chair", "holder": "s1", "session": "", "work": "x"}]),
|
||||||
|
("no items", []),
|
||||||
|
):
|
||||||
|
with self.subTest(name=name):
|
||||||
|
result = self.run_job(items, None)
|
||||||
|
self.assertEqual(result["items"], [])
|
||||||
|
self.assertIn("malformed job", result["error"])
|
||||||
|
self.assertIsNone(self.holder("chair"))
|
||||||
|
|
||||||
|
def test_the_unit_runs_the_script_once_over_the_job_directory(self):
|
||||||
|
appoint = {"action": "appoint", "office": "chair", "holder": "", "session": "s1", "work": "allocate"}
|
||||||
|
job_id = secrets.token_hex(8)
|
||||||
|
(self.jobs / f"{job_id}.json").write_text(json.dumps({"v": 1, "id": job_id, "created": "x", "items": [appoint], "proof": self.proof([appoint], self.signer)}), encoding="utf-8")
|
||||||
|
(self.jobs / "stray.tmp").write_text("x", encoding="utf-8")
|
||||||
|
trust = ["--approvers", str(self.approvers), "--replay", str(self.trust.replay)]
|
||||||
|
command = [sys.executable, str(OFFICES), "--port", ORIGIN.rsplit(":", 1)[1], "--jobs", str(self.jobs), "--results", str(self.results), *trust, "--council", str(self.council), "--script", str(COUNCIL_SCRIPT)]
|
||||||
|
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False, env={"PATH": "/usr/bin:/bin", "HOME": str(self.home)})
|
||||||
|
self.assertEqual(done.returncode, 0, done.stderr)
|
||||||
|
self.assertEqual(list(self.jobs.iterdir()), [])
|
||||||
|
result = json.loads((self.results / f"{job_id}.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(result["items"][0]["status"], "done", result)
|
||||||
|
self.assertEqual((self.results / f"{job_id}.json").stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertEqual(self.holder("chair")["session"], "s1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -175,7 +175,7 @@ class TestHubSpeech(HubCase):
|
|||||||
args = {
|
args = {
|
||||||
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
|
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
|
||||||
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"),
|
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"),
|
||||||
"--tree-file": str(self.tree_file), "--timers": str(self.timers), "--vendor": str(HUB / "vendor"), "--cleanup": str(self.cleanup),
|
"--tree-file": str(self.tree_file), "--timers": str(self.timers), "--vendor": str(HUB / "vendor"), "--cleanup": str(self.cleanup), "--offices": str(self.offices),
|
||||||
"--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
|
"--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
|
||||||
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
|
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
|
||||||
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
|
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
|
||||||
@@ -185,7 +185,7 @@ class TestHubSpeech(HubCase):
|
|||||||
argv = [part for pair in given.items() for part in pair]
|
argv = [part for pair in given.items() for part in pair]
|
||||||
return subprocess.run([sys.executable, str(HUB / "server.py"), *argv], capture_output=True, text=True, timeout=60, check=False)
|
return subprocess.run([sys.executable, str(HUB / "server.py"), *argv], capture_output=True, text=True, timeout=60, check=False)
|
||||||
|
|
||||||
for name in ("--piper", "--voices", "--operator-voice", "--whisper", "--whisper-model", "--scratch", "--approvers", "--vendor", "--cleanup"):
|
for name in ("--piper", "--voices", "--operator-voice", "--whisper", "--whisper-model", "--scratch", "--approvers", "--vendor", "--cleanup", "--offices"):
|
||||||
with self.subTest(name=name):
|
with self.subTest(name=name):
|
||||||
done = run({key: value for key, value in args.items() if key != name})
|
done = run({key: value for key, value in args.items() if key != name})
|
||||||
self.assertEqual(done.returncode, 2, done.stderr)
|
self.assertEqual(done.returncode, 2, done.stderr)
|
||||||
|
|||||||
+23
-12
@@ -68,10 +68,10 @@ class TestHubState(HubCase):
|
|||||||
self.assertEqual(self.status(review_id), "pending")
|
self.assertEqual(self.status(review_id), "pending")
|
||||||
|
|
||||||
def test_state_is_empty_without_council_files(self):
|
def test_state_is_empty_without_council_files(self):
|
||||||
empty = {"reviews": [], "operator": [], "register": [], "radio": [], "claims": [], "grants": []}
|
empty = {"reviews": [], "operator": [], "register": [], "radio": [], "claims": [], "grants": [], "agent_order": []}
|
||||||
state = self.snapshot()
|
state = self.snapshot()
|
||||||
self.assertEqual({key: state[key] for key in empty}, empty)
|
self.assertEqual({key: state[key] for key in empty}, empty)
|
||||||
self.assertEqual([(node["id"], node["kind"]) for node in state["graph"]["nodes"]], [("Operator", "operator"), ("name:Maintainer", "chair")])
|
self.assertEqual([(node["id"], node["kind"]) for node in state["graph"]["nodes"]], [("Operator", "operator")])
|
||||||
self.assertEqual(state["graph"]["edges"], [])
|
self.assertEqual(state["graph"]["edges"], [])
|
||||||
self.index()
|
self.index()
|
||||||
self.write_council({"rasci.json": "{not json", "queue-history.jsonl": "[" * 100000 + "\n", "work/x/claim.json": "[1]"})
|
self.write_council({"rasci.json": "{not json", "queue-history.jsonl": "[" * 100000 + "\n", "work/x/claim.json": "[1]"})
|
||||||
@@ -129,6 +129,8 @@ class TestHubState(HubCase):
|
|||||||
]
|
]
|
||||||
claim = {"office": "work/hub", "session": "s1", "runtime": "claude", "name": "Skills", "since": "2026-11-05T10:00:00+00:00", "beat": now, "work": "build the hub"}
|
claim = {"office": "work/hub", "session": "s1", "runtime": "claude", "name": "Skills", "since": "2026-11-05T10:00:00+00:00", "beat": now, "work": "build the hub"}
|
||||||
gate = {"office": "cluster/gate", "session": "s2", "runtime": "codex", "name": "Gatekeeper", "since": "2026-11-05T09:00:00+00:00", "beat": now, "work": "gate"}
|
gate = {"office": "cluster/gate", "session": "s2", "runtime": "codex", "name": "Gatekeeper", "since": "2026-11-05T09:00:00+00:00", "beat": now, "work": "gate"}
|
||||||
|
chair = {"office": "chair", "session": "s3", "runtime": "claude-code", "name": "Maintainer", "since": "2026-11-05T08:00:00+00:00", "beat": now, "work": "allocate"}
|
||||||
|
line = {"office": "identity/oidc", "session": "s1", "runtime": "claude", "name": "Skills", "since": "2026-11-05T10:00:00+00:00", "beat": now, "work": "oidc line"}
|
||||||
history = [
|
history = [
|
||||||
{"id": "q1", "kind": "gate", "signature": "make test", "workdir": "/repo/.worktrees/hub/sub", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2},
|
{"id": "q1", "kind": "gate", "signature": "make test", "workdir": "/repo/.worktrees/hub/sub", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2},
|
||||||
{"id": "q2", "kind": "deploy", "signature": "make deploy", "workdir": "/elsewhere", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2},
|
{"id": "q2", "kind": "deploy", "signature": "make deploy", "workdir": "/elsewhere", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2},
|
||||||
@@ -139,6 +141,8 @@ class TestHubState(HubCase):
|
|||||||
"news.jsonl": self.jsonl(news),
|
"news.jsonl": self.jsonl(news),
|
||||||
"work/hub/claim.json": claim,
|
"work/hub/claim.json": claim,
|
||||||
"cluster/gate/claim.json": gate,
|
"cluster/gate/claim.json": gate,
|
||||||
|
"chair/claim.json": chair,
|
||||||
|
"identity/oidc/claim.json": line,
|
||||||
"work/broken/claim.json": "not json",
|
"work/broken/claim.json": "not json",
|
||||||
"queue-history.jsonl": self.jsonl(history),
|
"queue-history.jsonl": self.jsonl(history),
|
||||||
})
|
})
|
||||||
@@ -147,7 +151,7 @@ class TestHubState(HubCase):
|
|||||||
set(state),
|
set(state),
|
||||||
{
|
{
|
||||||
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
||||||
"register", "priority", "radio", "claims", "grants", "night", "silence", "graph",
|
"register", "agent_order", "radio", "claims", "grants", "night", "silence", "graph",
|
||||||
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
||||||
"badges", "kinds",
|
"badges", "kinds",
|
||||||
},
|
},
|
||||||
@@ -168,7 +172,8 @@ class TestHubState(HubCase):
|
|||||||
"limit": phrasing.SPEECH_LIMIT, "rate": engines.STT_RATE, "seconds": engines.STT_SECONDS,
|
"limit": phrasing.SPEECH_LIMIT, "rate": engines.STT_RATE, "seconds": engines.STT_SECONDS,
|
||||||
})
|
})
|
||||||
self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW)
|
self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW)
|
||||||
self.assertEqual((state["night"], state["silence"], state["priority"]), ("day", None, ["Skills session", "subagent general-purpose (default)"]))
|
self.assertEqual((state["night"], state["silence"]), ("day", None))
|
||||||
|
self.assertEqual(state["agent_order"], ["Skills session", "subagent general-purpose (default)", "Gatekeeper", "Maintainer", "Skills"])
|
||||||
radio = [(row["from"], row["to"], row["class"], row["text"]) for row in state["radio"]]
|
radio = [(row["from"], row["to"], row["class"], row["text"]) for row in state["radio"]]
|
||||||
self.assertEqual(radio, [
|
self.assertEqual(radio, [
|
||||||
("Skills", ["Maintainer"], "post", "ready"),
|
("Skills", ["Maintainer"], "post", "ready"),
|
||||||
@@ -178,12 +183,17 @@ class TestHubState(HubCase):
|
|||||||
("Docs", ["Skills"], "pan-pan", "PAN-PAN slow"),
|
("Docs", ["Skills"], "pan-pan", "PAN-PAN slow"),
|
||||||
("Maintainer", ["Skills", "Docs"], "security", "rotate"),
|
("Maintainer", ["Skills", "Docs"], "security", "rotate"),
|
||||||
])
|
])
|
||||||
self.assertEqual([(row["office"], row["agent"], row["runtime"]) for row in state["claims"]], [("cluster/gate", "Gatekeeper", "codex"), ("work/hub", "Skills", "claude")])
|
self.assertEqual([(row["office"], row["agent"], row["runtime"], row["kind"], row["dimension"], row["line"]) for row in state["claims"]], [
|
||||||
|
("chair", "Maintainer", "claude-code", "chair", "", ""),
|
||||||
|
("cluster/gate", "Gatekeeper", "codex", "cluster", "", ""),
|
||||||
|
("identity/oidc", "Skills", "claude", "line", "identity", "oidc"),
|
||||||
|
("work/hub", "Skills", "claude", "work", "", ""),
|
||||||
|
])
|
||||||
self.assertEqual([(row["to"], row["kind"], row["id"]) for row in state["grants"]], [("Skills", "gate", "q1")])
|
self.assertEqual([(row["to"], row["kind"], row["id"]) for row in state["grants"]], [("Skills", "gate", "q1")])
|
||||||
self.assertEqual([row["task"] for row in state["operator"]], ["operator/push"])
|
self.assertEqual([row["task"] for row in state["operator"]], ["operator/push"])
|
||||||
nodes = {node["id"]: node for node in state["graph"]["nodes"]}
|
nodes = {node["id"]: node for node in state["graph"]["nodes"]}
|
||||||
moon = "subagent general-purpose (default) @ s1"
|
moon = "subagent general-purpose (default) @ s1"
|
||||||
self.assertEqual(list(nodes), ["Operator", "name:Maintainer", "name:Docs", "s2", "s1", moon])
|
self.assertEqual(list(nodes), ["Operator", "s3", "name:Docs", "s2", "s1", moon])
|
||||||
self.assertEqual([nodes[key]["kind"] for key in nodes], ["operator", "chair", "session", "session", "session", "subagent"])
|
self.assertEqual([nodes[key]["kind"] for key in nodes], ["operator", "chair", "session", "session", "session", "subagent"])
|
||||||
self.assertEqual([nodes[key]["label"] for key in nodes][:5], ["Operator", "Maintainer", "Docs", "Gatekeeper", "Skills"])
|
self.assertEqual([nodes[key]["label"] for key in nodes][:5], ["Operator", "Maintainer", "Docs", "Gatekeeper", "Skills"])
|
||||||
self.assertEqual((nodes[moon]["parent"], nodes[moon]["label"]), ("s1", "subagent general-purpose (default)"))
|
self.assertEqual((nodes[moon]["parent"], nodes[moon]["label"]), ("s1", "subagent general-purpose (default)"))
|
||||||
@@ -191,17 +201,18 @@ class TestHubState(HubCase):
|
|||||||
self.assertEqual(skills, [("operator/push", False, "A"), ("work/hub", True, "R"), ("work/sub", False, "A")])
|
self.assertEqual(skills, [("operator/push", False, "A"), ("work/hub", True, "R"), ("work/sub", False, "A")])
|
||||||
self.assertEqual([(task["task"], task["letters"]) for task in nodes["Operator"]["tasks"]], [("operator/push", "R")])
|
self.assertEqual([(task["task"], task["letters"]) for task in nodes["Operator"]["tasks"]], [("operator/push", "R")])
|
||||||
self.assertEqual([task["task"] for task in nodes[moon]["tasks"]], ["work/sub"])
|
self.assertEqual([task["task"] for task in nodes[moon]["tasks"]], ["work/sub"])
|
||||||
self.assertEqual([claim["office"] for claim in nodes["s1"]["claims"]], ["work/hub"])
|
self.assertEqual([claim["office"] for claim in nodes["s1"]["claims"]], ["identity/oidc", "work/hub"])
|
||||||
|
self.assertEqual([claim["office"] for claim in nodes["s3"]["claims"]], ["chair"])
|
||||||
self.assertEqual((state["people"]["s1"]["now"], state["radio"][0]["source"], state["grants"][0]["target"]), ("Skills", "s1", "s1"))
|
self.assertEqual((state["people"]["s1"]["now"], state["radio"][0]["source"], state["grants"][0]["target"]), ("Skills", "s1", "s1"))
|
||||||
edges = {(edge["source"], edge["target"], edge["class"]): edge["count"] for edge in state["graph"]["edges"]}
|
edges = {(edge["source"], edge["target"], edge["class"]): edge["count"] for edge in state["graph"]["edges"]}
|
||||||
self.assertEqual(edges, {
|
self.assertEqual(edges, {
|
||||||
("name:Docs", "*", "mayday"): 1,
|
("name:Docs", "*", "mayday"): 1,
|
||||||
("name:Docs", "s1", "pan-pan"): 1,
|
("name:Docs", "s1", "pan-pan"): 1,
|
||||||
("name:Maintainer", "*", "newsletter"): 1,
|
("s3", "*", "newsletter"): 1,
|
||||||
("name:Maintainer", "name:Docs", "security"): 1,
|
("s3", "name:Docs", "security"): 1,
|
||||||
("name:Maintainer", "s1", "grant"): 1,
|
("s3", "s1", "grant"): 1,
|
||||||
("name:Maintainer", "s1", "security"): 1,
|
("s3", "s1", "security"): 1,
|
||||||
("s1", "name:Maintainer", "post"): 2,
|
("s1", "s3", "post"): 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_state_needs_the_host(self):
|
def test_state_needs_the_host(self):
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
SCRIPT = REPO_ROOT / "scripts" / "orchestrator.sh"
|
SCRIPT = REPO_ROOT / "scripts" / "orchestrator.sh"
|
||||||
|
HOST_SCRIPTS = ("kvb-org-organize/scripts/council.py", "kvb-org-organize/scripts/nofollow.py", "kvb-org-rasci/scripts/rasci.py")
|
||||||
OLD = "kvb-hub"
|
OLD = "kvb-hub"
|
||||||
NEW = "kvb-orchestrator"
|
NEW = "kvb-orchestrator"
|
||||||
STUB = r'''#!/usr/bin/env python3
|
STUB = r'''#!/usr/bin/env python3
|
||||||
@@ -97,6 +98,8 @@ class TestOrchestratorMigration(unittest.TestCase):
|
|||||||
self.other = {"matcher": "Bash", "hooks": [{"type": "command", "command": "other-hook"}]}
|
self.other = {"matcher": "Bash", "hooks": [{"type": "command", "command": "other-hook"}]}
|
||||||
legacy = {"matcher": "Bash", "hooks": [{"type": "command", "command": f"python3 {share}/{OLD}/client/permission_hook.py --port 8737"}]}
|
legacy = {"matcher": "Bash", "hooks": [{"type": "command", "command": f"python3 {share}/{OLD}/client/permission_hook.py --port 8737"}]}
|
||||||
self.write(self.home / ".claude" / "settings.json", json.dumps({"model": "x", "hooks": {"PermissionRequest": [self.other, legacy], "Stop": [self.other]}}), 0o640)
|
self.write(self.home / ".claude" / "settings.json", json.dumps({"model": "x", "hooks": {"PermissionRequest": [self.other, legacy], "Stop": [self.other]}}), 0o640)
|
||||||
|
for script in HOST_SCRIPTS:
|
||||||
|
self.write(self.home / ".claude" / "skills" / script, (REPO_ROOT / "skills" / script).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
def write(self, path: Path, text: str, mode: int = 0o644) -> None:
|
def write(self, path: Path, text: str, mode: int = 0o644) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -127,7 +130,7 @@ class TestOrchestratorMigration(unittest.TestCase):
|
|||||||
self.assertIn(f"{share / NEW}/client/permission_hook.py", hooks[1]["hooks"][0]["command"])
|
self.assertIn(f"{share / NEW}/client/permission_hook.py", hooks[1]["hooks"][0]["command"])
|
||||||
self.assertEqual(settings.stat().st_mode & 0o777, 0o640)
|
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.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}-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"]))
|
||||||
self.assertIn(["docker", "compose", "--file", str(share / OLD / "compose.yml"), "down", "--remove-orphans"], first["calls"])
|
self.assertIn(["docker", "compose", "--file", str(share / OLD / "compose.yml"), "down", "--remove-orphans"], first["calls"])
|
||||||
|
|
||||||
before = snapshot(self.home)
|
before = snapshot(self.home)
|
||||||
@@ -137,6 +140,15 @@ class TestOrchestratorMigration(unittest.TestCase):
|
|||||||
legacy_calls = [call for call in second["calls"][len(first["calls"]) :] if any(OLD in arg for arg in call)]
|
legacy_calls = [call for call in second["calls"][len(first["calls"]) :] if any(OLD in arg for arg in call)]
|
||||||
self.assertEqual(legacy_calls, [["docker", "image", "inspect", f"{OLD}:local"], ["claude", "mcp", "get", OLD]])
|
self.assertEqual(legacy_calls, [["docker", "image", "inspect", f"{OLD}:local"], ["claude", "mcp", "get", OLD]])
|
||||||
|
|
||||||
|
def test_the_offices_unit_waits_for_the_installed_skill_scripts_of_this_tree(self):
|
||||||
|
self.install()
|
||||||
|
stale = self.home / ".claude" / "skills" / HOST_SCRIPTS[2]
|
||||||
|
stale.write_text(stale.read_text(encoding="utf-8") + "\n", encoding="utf-8")
|
||||||
|
done = subprocess.run(["bash", str(SCRIPT), "install"], env=self.env, capture_output=True, text=True, timeout=120, check=False)
|
||||||
|
self.assertEqual(done.returncode, 0, done.stderr)
|
||||||
|
self.assertIn("run 'make install' to enable", done.stderr)
|
||||||
|
self.assertNotIn(f"{NEW}-offices.path", json.loads(self.state.read_text(encoding="utf-8"))["enabled"])
|
||||||
|
|
||||||
def test_install_ships_no_bytecode_from_the_tree(self):
|
def test_install_ships_no_bytecode_from_the_tree(self):
|
||||||
planted = REPO_ROOT / "orchestrator" / "client" / "__pycache__" / "planted.cpython-0.pyc"
|
planted = REPO_ROOT / "orchestrator" / "client" / "__pycache__" / "planted.cpython-0.pyc"
|
||||||
planted.parent.mkdir(exist_ok=True)
|
planted.parent.mkdir(exist_ok=True)
|
||||||
|
|||||||
+10
-1
@@ -27,6 +27,7 @@ BASE = {
|
|||||||
"silenced": False,
|
"silenced": False,
|
||||||
"slot_limits": {"test": 5, "deploy": 1},
|
"slot_limits": {"test": 5, "deploy": 1},
|
||||||
"ranks": {"work/a": 0, "work/b": 1, "work/c": 2},
|
"ranks": {"work/a": 0, "work/b": 1, "work/c": 2},
|
||||||
|
"agents": {},
|
||||||
"now": NOW,
|
"now": NOW,
|
||||||
}
|
}
|
||||||
CHILD = (
|
CHILD = (
|
||||||
@@ -62,7 +63,7 @@ def history_row(seq: int, **fields: object) -> dict:
|
|||||||
def job(seq: int, **fields: object) -> dict:
|
def job(seq: int, **fields: object) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": f"j{seq}", "seq": seq, "kind": "test", "task": "work/a", "need_gb": 1, "hotfix": None,
|
"id": f"j{seq}", "seq": seq, "kind": "test", "task": "work/a", "need_gb": 1, "hotfix": None,
|
||||||
"workdir": f"/w{seq}", "status": "waiting", "enqueued": NOW, **fields,
|
"workdir": f"/w{seq}", "name": "s1", "status": "waiting", "enqueued": NOW, **fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +99,14 @@ class TestEvaluate(unittest.TestCase):
|
|||||||
tied = job(4, task="work/c", enqueued=NOW - 2 * HOUR)
|
tied = job(4, task="work/c", enqueued=NOW - 2 * HOUR)
|
||||||
self.assertEqual(list(self.decide([tied, fresh])), ["j1", "j4"])
|
self.assertEqual(list(self.decide([tied, fresh])), ["j1", "j4"])
|
||||||
|
|
||||||
|
def test_the_agent_order_breaks_ties_of_equal_rank_only(self):
|
||||||
|
entries = [job(1, name="Beta"), job(2, name="Alpha"), job(3, task="work/b", name="Alpha"), job(4, name="Stranger")]
|
||||||
|
self.assertEqual(list(self.decide(entries)), ["j1", "j2", "j4", "j3"])
|
||||||
|
self.assertEqual(list(self.decide(entries, agents={"Alpha": 0, "Beta": 1})), ["j2", "j1", "j4", "j3"])
|
||||||
|
tied = [job(5, task="work/b", name="Beta", enqueued=NOW - HOUR), job(6, name="Alpha")]
|
||||||
|
self.assertEqual(list(self.decide(tied)), ["j5", "j6"])
|
||||||
|
self.assertEqual(list(self.decide(tied, agents={"Alpha": 0, "Beta": 1})), ["j6", "j5"])
|
||||||
|
|
||||||
def test_the_slot_limit_counts_active_jobs_per_kind(self):
|
def test_the_slot_limit_counts_active_jobs_per_kind(self):
|
||||||
verdicts = self.decide([job(1, kind="deploy", status="active"), job(2, kind="deploy"), job(3)])
|
verdicts = self.decide([job(1, kind="deploy", status="active"), job(2, kind="deploy"), job(3)])
|
||||||
self.assertEqual(verdicts, {"j2": "all 1 deploy slots taken", "j3": None})
|
self.assertEqual(verdicts, {"j2": "all 1 deploy slots taken", "j3": None})
|
||||||
|
|||||||
+65
-11
@@ -36,7 +36,7 @@ class TestRasci(unittest.TestCase):
|
|||||||
json.dumps({"sessionId": sid, "name": name}), encoding="utf-8"
|
json.dumps({"sessionId": sid, "name": name}), encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _run(self, *args: str, script: Path = RASCI, sid: str = "s1", timeout: float | None = None) -> subprocess.CompletedProcess:
|
def _run(self, *args: str, script: Path = RASCI, sid: str = "s1", timeout: float = 120) -> subprocess.CompletedProcess:
|
||||||
env = {
|
env = {
|
||||||
"PATH": "/usr/bin:/bin",
|
"PATH": "/usr/bin:/bin",
|
||||||
"HOME": str(self.home),
|
"HOME": str(self.home),
|
||||||
@@ -312,28 +312,82 @@ class TestRasci(unittest.TestCase):
|
|||||||
self.assertEqual([line.split("\t")[1] for line in self._out(*args)],
|
self.assertEqual([line.split("\t")[1] for line in self._out(*args)],
|
||||||
["work/a"] if args == ("artifacts",) else [])
|
["work/a"] if args == ("artifacts",) else [])
|
||||||
|
|
||||||
def test_order_pins_the_agents_in_the_operators_order(self):
|
def _ranked(self) -> list[str]:
|
||||||
|
return [line.split(" | ")[2].split(":")[0] for line in self._out("show")[2:] if line.startswith("| ")]
|
||||||
|
|
||||||
|
def test_agents_order_breaks_ties_only_and_never_touches_the_task_pins(self):
|
||||||
self._run("set", "work/a1", "--worker", "Alpha", "--prio", "1")
|
self._run("set", "work/a1", "--worker", "Alpha", "--prio", "1")
|
||||||
self._run("set", "work/a2", "--worker", "Alpha", "--prio", "2")
|
self._run("set", "work/b1", "--worker", "Beta", "--prio", "1")
|
||||||
self._run("set", "work/b1", "--worker", "Beta", "--prio", "3")
|
self._run("set", "work/b2", "--worker", "Beta", "--prio", "2")
|
||||||
self._run("set", "work/c1", "--worker", "Gamma")
|
self._run("set", "work/c1", "--worker", "Gamma")
|
||||||
self._run("estimate", "work/c1", "One", "--value", "8", "--complexity", "2")
|
self._run("set", "work/d1", "--worker", "Delta")
|
||||||
|
self._claim("security/gui", "s9", "Epsilon", "line")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._out("agents"),
|
self._out("agents"),
|
||||||
["1\tAlpha\t2\twork/a1, work/a2", "2\tBeta\t1\twork/b1", "3\tGamma\t1\twork/c1"],
|
["1\tAlpha\t1\twork/a1", "2\tBeta\t2\twork/b1, work/b2", "3\tGamma\t1\twork/c1", "4\tDelta\t1\twork/d1", "5\tEpsilon\t0\t-"],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(self._ranked(), ["work/a1", "work/b1", "work/b2", "work/c1", "work/d1"])
|
||||||
|
register = (self.root / "rasci.json").read_text(encoding="utf-8")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._out("order", "Gamma, Beta"), ["pinned 2 tasks of 2 agents; the others rank by WSJF after them"]
|
self._out("agents", "order", "Gamma, Beta,Epsilon"),
|
||||||
|
["agent order: Gamma, Beta, Epsilon; the others follow by register rank; task --prio pins unchanged"],
|
||||||
)
|
)
|
||||||
|
self.assertEqual((self.root / "rasci.json").read_text(encoding="utf-8"), register)
|
||||||
|
stored = json.loads((self.root / "agent-order.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(stored["agents"], ["Gamma", "Beta", "Epsilon"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self._out("agents"),
|
self._out("agents"),
|
||||||
["1\tGamma\t1\twork/c1", "2\tBeta\t1\twork/b1", "3\tAlpha\t2\twork/a1, work/a2"],
|
["1\tGamma\t1\twork/c1", "2\tBeta\t2\twork/b1, work/b2", "3\tEpsilon\t0\t-", "4\tAlpha\t1\twork/a1", "5\tDelta\t1\twork/d1"],
|
||||||
)
|
)
|
||||||
self.assertTrue(self._out("show")[2].startswith("| 1 | 4 | work/c1 |"))
|
self.assertEqual(self._ranked(), ["work/b1", "work/a1", "work/b2", "work/c1", "work/d1"])
|
||||||
self.assertNotIn("prio: 1", self._out("get", "work/a1"))
|
self.assertIn("prio: 1", self._out("get", "work/a1"))
|
||||||
for args in (("order", "Gamma,Nobody"), ("order", "Beta,Beta"), ("order", " , ")):
|
for args in (("agents", "order", "Gamma,Nobody"), ("agents", "order", "Beta,Beta"), ("agents", "order", " , "), ("agents", "sort")):
|
||||||
with self.subTest(args=args):
|
with self.subTest(args=args):
|
||||||
self.assertEqual(self._run(*args).returncode, 2)
|
self.assertEqual(self._run(*args).returncode, 2)
|
||||||
|
self.assertEqual(json.loads((self.root / "agent-order.json").read_text(encoding="utf-8")), stored)
|
||||||
|
|
||||||
|
def test_the_old_order_command_stores_the_agent_order_and_says_so(self):
|
||||||
|
self._run("set", "work/a1", "--worker", "Alpha", "--prio", "3")
|
||||||
|
self._run("set", "work/b1", "--worker", "Beta")
|
||||||
|
done = self._run("order", "Beta,Alpha")
|
||||||
|
self.assertEqual(done.returncode, 0, done.stderr)
|
||||||
|
self.assertIn("rasci: `order` is now `agents order`", done.stderr)
|
||||||
|
self.assertIn("no longer writes --prio pins", done.stderr)
|
||||||
|
self.assertEqual(json.loads((self.root / "agent-order.json").read_text(encoding="utf-8"))["agents"], ["Beta", "Alpha"])
|
||||||
|
self.assertIn("prio: 3", self._out("get", "work/a1"))
|
||||||
|
self.assertNotIn("prio: 1", self._out("get", "work/b1"))
|
||||||
|
|
||||||
|
def test_a_broken_agent_order_file_ranks_like_none(self):
|
||||||
|
self._run("set", "work/a1", "--worker", "Alpha")
|
||||||
|
self._run("set", "work/b1", "--worker", "Beta")
|
||||||
|
for text in ("{not json", json.dumps({"agents": "Beta"}), json.dumps({"agents": [1, "Beta"]}), json.dumps([1])):
|
||||||
|
with self.subTest(text=text):
|
||||||
|
(self.root / "agent-order.json").write_text(text, encoding="utf-8")
|
||||||
|
self.assertEqual(self._ranked(), ["work/a1", "work/b1"])
|
||||||
|
|
||||||
|
def test_the_agent_order_never_reads_or_writes_through_a_link_fifo_or_deep_nesting(self):
|
||||||
|
self._run("set", "work/a1", "--worker", "Alpha")
|
||||||
|
self._run("set", "work/b1", "--worker", "Beta")
|
||||||
|
outside = Path(self.tmp.name) / "outside.json"
|
||||||
|
outside.write_text(json.dumps({"agents": ["Beta"]}), encoding="utf-8")
|
||||||
|
order = self.root / "agent-order.json"
|
||||||
|
order.symlink_to(outside)
|
||||||
|
self.assertEqual(self._ranked(), ["work/a1", "work/b1"])
|
||||||
|
order.unlink()
|
||||||
|
order.write_text("[" * 100000, encoding="utf-8")
|
||||||
|
self.assertEqual(self._ranked(), ["work/a1", "work/b1"])
|
||||||
|
order.unlink()
|
||||||
|
os.mkfifo(order, 0o600)
|
||||||
|
self.assertEqual(self._run("agents", timeout=20).stdout.splitlines(), ["1\tAlpha\t1\twork/a1", "2\tBeta\t1\twork/b1"])
|
||||||
|
order.unlink()
|
||||||
|
lock = self.root / "rasci.lock"
|
||||||
|
lock.unlink(missing_ok=True)
|
||||||
|
planted = Path(self.tmp.name) / "planted.lock"
|
||||||
|
lock.symlink_to(planted)
|
||||||
|
refused = self._run("agents", "order", "Beta,Alpha")
|
||||||
|
self.assertEqual(refused.returncode, 2, refused.stderr)
|
||||||
|
self.assertIn("agents order", refused.stderr)
|
||||||
|
self.assertFalse(planted.exists() or order.exists())
|
||||||
|
|
||||||
def test_a_symlinked_register_is_refused_and_never_copied_into_the_council(self):
|
def test_a_symlinked_register_is_refused_and_never_copied_into_the_council(self):
|
||||||
outside = Path(self.tmp.name) / "outside.json"
|
outside = Path(self.tmp.name) / "outside.json"
|
||||||
|
|||||||
Reference in new issue
Block a user