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
+1821
-211
No files matched your search
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}")
|
||||
ID = re.compile(r"[0-9a-f]{16}")
|
||||
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"}
|
||||
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ services:
|
||||
- /app/vendor
|
||||
- --cleanup
|
||||
- /state/cleanup
|
||||
- --offices
|
||||
- /state/offices
|
||||
- --llm-url
|
||||
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
||||
- --llm-model
|
||||
|
||||
@@ -7,6 +7,7 @@ from .asking import ask_state
|
||||
from .cleanup import cleanup_request
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import decode, expect, field
|
||||
from .offices import office_request
|
||||
from .records import digest, shown
|
||||
from .register import REGISTER, operator_rows, read_register, runnable
|
||||
from .rules import rule_matches
|
||||
@@ -144,7 +145,12 @@ class Approving:
|
||||
payload = decode(body)
|
||||
expect(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]
|
||||
credentials = [key["id"] for key in self.keys()]
|
||||
if not hashes:
|
||||
|
||||
@@ -18,6 +18,10 @@ MODES = ("evening", "sunset")
|
||||
RADIO_LIMIT = 5000
|
||||
GRANT_LIMIT = 50
|
||||
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:
|
||||
@@ -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:]
|
||||
|
||||
|
||||
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")
|
||||
kind, dimension, line = office_kind(office)
|
||||
return {
|
||||
"office": shown(data.get("office")),
|
||||
"office": office,
|
||||
"kind": kind,
|
||||
"dimension": dimension,
|
||||
"line": line,
|
||||
"agent": agent(name),
|
||||
"address": name.strip() if isinstance(name, str) else "",
|
||||
"session": shown(data.get("session")),
|
||||
@@ -89,8 +116,14 @@ def claim_row(data: dict) -> dict:
|
||||
|
||||
|
||||
def read_claims(council: Path) -> list[dict]:
|
||||
paths = sorted([*council.glob("cluster/*/claim.json"), *council.glob("work/*/claim.json")])
|
||||
return [claim_row(data) for data in map(read_json, paths) if isinstance(data, dict)]
|
||||
found = []
|
||||
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]:
|
||||
|
||||
@@ -2,13 +2,23 @@ from __future__ import annotations
|
||||
|
||||
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 .orbits import affinity
|
||||
from .records import shown
|
||||
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:
|
||||
@@ -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:
|
||||
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()}
|
||||
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 |= {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))
|
||||
known = {chair, *found}
|
||||
known = {chair, *found} - {""}
|
||||
moons: dict[str, tuple[str, str]] = {}
|
||||
holders: dict[str, dict[str, dict]] = {}
|
||||
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]
|
||||
holder = directory.key(worker, end)
|
||||
if subagent(worker):
|
||||
parent = parent_of(roles, known, chair)
|
||||
parent = parent_of(roles, known, top)
|
||||
holder = f"{worker} @ {parent}"
|
||||
moons[holder] = (parent, worker)
|
||||
if holder:
|
||||
duties(holder, task, row, holders)["worker"] = True
|
||||
for key, letters in roles.items():
|
||||
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 += [(moon, "subagent", *moons[moon]) for moon in ordered(set(moons))]
|
||||
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
|
||||
]
|
||||
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"
|
||||
CLEANUP_ROUTE = "/api/cleanup"
|
||||
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 = {"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"
|
||||
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):
|
||||
@@ -59,6 +61,7 @@ class Handler(Exchange):
|
||||
proof = PROOF_ROUTE.fullmatch(path)
|
||||
vendored = VENDOR_ROUTE.fullmatch(path)
|
||||
cleanup = CLEANUP_STATE.fullmatch(path)
|
||||
office = OFFICE_STATE.fullmatch(path)
|
||||
if self.host() is None:
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
||||
elif path == "/":
|
||||
@@ -67,6 +70,8 @@ class Handler(Exchange):
|
||||
self.vendored(VENDOR[vendored.group(1)])
|
||||
elif cleanup:
|
||||
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:
|
||||
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
||||
elif path == "/api/state":
|
||||
@@ -125,6 +130,8 @@ class Handler(Exchange):
|
||||
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
|
||||
if path == CLEANUP_ROUTE:
|
||||
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:
|
||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||
if undo:
|
||||
|
||||
@@ -22,6 +22,7 @@ from .ledger import Ledger, Marks, Reviews
|
||||
from .mcp import Mcp
|
||||
from .messaging import Messaging
|
||||
from .names import Names
|
||||
from .offices import Offices
|
||||
from .reviewing import Reviewing
|
||||
from .rules import Rules
|
||||
from .settings import Settings
|
||||
@@ -40,6 +41,7 @@ class Places:
|
||||
timers: Path
|
||||
vendor: Path
|
||||
cleanup: Path
|
||||
offices: Path
|
||||
|
||||
|
||||
def environment(templates: Path) -> jinja2.Environment:
|
||||
@@ -55,7 +57,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
||||
|
||||
|
||||
@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
|
||||
pages: Path
|
||||
public_port: int
|
||||
@@ -84,6 +86,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules,
|
||||
vendor: Path
|
||||
icons: Markup
|
||||
spool: Spool
|
||||
office_spool: Spool
|
||||
touched: Touched
|
||||
|
||||
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,
|
||||
icons=sheet(places.vendor, templates),
|
||||
spool=Spool(places.cleanup).ready(),
|
||||
office_spool=Spool(places.offices).ready(),
|
||||
touched=Touched(reviews.stored),
|
||||
approvers=approvers,
|
||||
speech=Speech(engines, state / "tts"),
|
||||
|
||||
@@ -11,7 +11,7 @@ from .errors import ToolError
|
||||
from .events import VIEWS
|
||||
from .fields import bounded, decode, expect, message_text
|
||||
from .records import read_jsonl
|
||||
from .register import REGISTER, priority, read_register
|
||||
from .register import REGISTER, agent_order, read_register
|
||||
|
||||
NL_LIMIT = 2000
|
||||
NL_ACTION_LIMIT = 20
|
||||
@@ -183,6 +183,6 @@ class Language:
|
||||
directory = self.directory(claims, read_jsonl(self.council / NEWS), now)
|
||||
self.watch.see(tasks, now)
|
||||
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]
|
||||
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 .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single
|
||||
from .records import write_atomic
|
||||
from .register import REGISTER, agent_order, read_register
|
||||
from .skill_scripts import NIGHT, RADIO, RASCI
|
||||
|
||||
NIGHTS = {
|
||||
@@ -111,7 +112,11 @@ class Messaging:
|
||||
raise ToolError("agents must be a non-empty list of agent names without commas")
|
||||
if len(set(names)) != len(names):
|
||||
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:
|
||||
command, text = NIGHTS[action]
|
||||
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
|
||||
|
||||
import datetime
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return json.loads(read_regular(path, JSON_LIMIT))
|
||||
except (OSError, ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
@@ -37,7 +60,7 @@ def parse_line(line: str) -> dict | None:
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict]:
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
lines = read_regular(path, None).splitlines()
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
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 .council import agent
|
||||
from .council import agent, ordered
|
||||
from .fields import ORDER_NAME
|
||||
from .records import read_json, shown
|
||||
|
||||
REGISTER = "rasci.json"
|
||||
AGENT_ORDER = "agent-order.json"
|
||||
OPERATOR = "operator/"
|
||||
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)]
|
||||
|
||||
|
||||
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] = []
|
||||
for _, row in sorted(tasks.items(), key=pinned):
|
||||
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)
|
||||
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 .phrasing import phrase
|
||||
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 .settings import media_origins
|
||||
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],
|
||||
"operator": operator_rows(tasks),
|
||||
"register": register_rows(tasks),
|
||||
"priority": priority(tasks),
|
||||
"agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)),
|
||||
"radio": radio,
|
||||
"claims": claims,
|
||||
"grants": grant_rows,
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
--cleanup: host-mounted directory the hub writes signed branch deletions
|
||||
into (jobs/) and reads their results from (results/); the host's
|
||||
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
|
||||
natural-language command line 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)
|
||||
for name in ("--listen-port", "--public-port"):
|
||||
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"):
|
||||
parser.add_argument(name, type=Path, required=True)
|
||||
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
||||
@@ -82,7 +86,7 @@ def main() -> int:
|
||||
transcripts=args.transcripts,
|
||||
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
||||
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)),
|
||||
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,
|
||||
|
||||
@@ -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-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); }
|
||||
{% include "style-hierarchy.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -485,6 +486,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-spawn.js" %}
|
||||
|
||||
{% include "page-hierarchy.js" %}
|
||||
|
||||
{% include "page-agent-order.js" %}
|
||||
|
||||
{% include "page-graph3d.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-offices.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
{% include "page-pulses.js" %}
|
||||
|
||||
@@ -3,12 +3,14 @@ const Nav = (() => {
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||
const ICONS = {
|
||||
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 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) {
|
||||
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("/");
|
||||
if (Object.hasOwn(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)};
|
||||
}
|
||||
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
|
||||
return {screen: sub, view: null, redirect: null};
|
||||
}
|
||||
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 {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
|
||||
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
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() {
|
||||
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));
|
||||
}
|
||||
@@ -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,
|
||||
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))))));
|
||||
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() {
|
||||
@@ -75,54 +75,3 @@
|
||||
graphDom.toolbar.clear.disabled = !state.silence;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
async function followCleanup(id, outcome, began) {
|
||||
async function followJob(url, outcome, began, finish) {
|
||||
let data = null;
|
||||
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;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!data || data.status !== "done") {
|
||||
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;
|
||||
}
|
||||
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) => {
|
||||
const [label, tone] = RESULT_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);
|
||||
const [label, tone] = labels[item.status] || [item.status, "neutral"];
|
||||
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");
|
||||
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
|
||||
fetchTree();
|
||||
return {good: !data.error && data.items.every((item) => item.status === "done"), lines};
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
const NONCE = document.currentScript ? document.currentScript.nonce : "";
|
||||
const ACTIVE_BEAT = 900;
|
||||
const DAY = 86400;
|
||||
const CHAIR = "Maintainer";
|
||||
const HUMAN = "Operator";
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
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 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(" · ");
|
||||
|
||||
function toast(title, lines, good) {
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
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)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
@@ -191,6 +191,6 @@
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const toolbar = agentsToolbar();
|
||||
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);
|
||||
}
|
||||
@@ -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() {
|
||||
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)}))]),
|
||||
...["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)})),
|
||||
...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)})),
|
||||
{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)})),
|
||||
{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: "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},
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
stage();
|
||||
drawSide();
|
||||
drawSpawn();
|
||||
drawHierarchy(force);
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawStatus();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
}
|
||||
graphDom.pulses = null;
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
Reference in new issue
Block a user