feat(orchestrator): inbox console, deny by channel and agent rule requests
The inbox shows the console output of a command under its "Ausführen" row, kept to the same tail the job list shows. A deny picks its radio channel (post by default, call, newsletter, broadcast, pan-pan, security) and an optional reason; the passkey signs denial, channel and reason, the hub verifies the proof before it stores or sends anything, and the PermissionRequest hook quotes a reason only when it verifies. Agents ask for an auto-approval rule through the MCP tool rule_request; the request shows in the inbox with a dry run over the last seven days and is accepted only with a passkey assertion the hub verifies against the pinned keys and the hub origin, for every rule kind. Requests lapse with the ask lifetime and are capped per agent and in total. The image runs as nobody by default and carries the health check command; compose keeps the timing. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 98 % ⏱️ Time: 75 % 🛡️ 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
1d311a3382
commit
58187e8323
37 files changed
+1387
-100
No files matched your search
@@ -12,7 +12,7 @@ FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install --yes --no-install-recommends git openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install --no-cache-dir --root-user-action=ignore --requirement /app/requirements.txt
|
||||
@@ -36,8 +36,10 @@ RUN chmod 0755 /opt/voices /opt/whisper
|
||||
|
||||
COPY server.py /app/server.py
|
||||
COPY kvb_orchestrator /app/kvb_orchestrator
|
||||
COPY client/signing.py /app/client/signing.py
|
||||
COPY client/signing.py client/passkey.py client/verifier.py /app/client/
|
||||
COPY templates /app/templates
|
||||
COPY vendor /app/vendor
|
||||
|
||||
USER 65534:65534
|
||||
HEALTHCHECK CMD ["python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 2).close()"]
|
||||
ENTRYPOINT ["python3", "/app/server.py"]
|
||||
@@ -12,7 +12,8 @@
|
||||
Returns:
|
||||
stdout: the PermissionRequest decision JSON: allow only when a pinned
|
||||
passkey signed exactly this tool, command, cwd, session and ask id;
|
||||
deny when the operator denied in the Agent Orchestrator. Nothing on timeout,
|
||||
deny when the operator denied in the Agent Orchestrator, with the
|
||||
reason only when a pinned passkey signed it. Nothing on timeout,
|
||||
error, a missing or invalid signature, a value longer than the hub
|
||||
takes or a hub that is down, so Claude Code shows its own dialog.
|
||||
The exit code is 0.
|
||||
@@ -22,6 +23,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -37,6 +39,8 @@ COMMAND_LIMIT = 4000
|
||||
PATH_LIMIT = 4096
|
||||
LIMITS = {"tool": NAME_LIMIT, "command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": NAME_LIMIT, "agent": NAME_LIMIT}
|
||||
DENIED = "The operator denied this in the Agent Orchestrator."
|
||||
REASON_LIMIT = 2000
|
||||
CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def summary(tool_input: object) -> str:
|
||||
@@ -74,9 +78,21 @@ def status(base: str, ask_id: str, wait: float) -> dict:
|
||||
return answer
|
||||
|
||||
|
||||
def denied(answer: dict, item: dict, trust: verifier.Trust) -> str:
|
||||
reason, channel = answer.get("reason"), answer.get("channel")
|
||||
if not isinstance(reason, str) or not isinstance(channel, str) or not reason.strip():
|
||||
return DENIED
|
||||
try:
|
||||
verifier.check(answer.get("proof"), signing.denial_item(item, channel, reason), "", trust, time.time())
|
||||
except (signing.Refused, OSError) as error:
|
||||
print(f"kvb-orchestrator: the reason is not signed, the agent gets the denial without it: {error}", file=sys.stderr)
|
||||
return DENIED
|
||||
return f"{DENIED} Reason: {CONTROL.sub('?', reason.strip())[:REASON_LIMIT]}"
|
||||
|
||||
|
||||
def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None:
|
||||
if answer["status"] == "denied":
|
||||
return {"behavior": "deny", "message": DENIED}
|
||||
return {"behavior": "deny", "message": denied(answer, item, trust)}
|
||||
if answer["status"] != "allowed":
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -107,6 +107,23 @@ def office_problem(order: object) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def rule_request_item(request_id: str, kind: str, tool: str, pattern: str, scope: str, expires: int) -> dict:
|
||||
return {"kind": "rule-request", "id": request_id, "rule": kind, "tool": tool, "pattern": pattern, "scope": scope, "expires": expires}
|
||||
|
||||
|
||||
def denial_item(subject: dict, channel: str, reason: str) -> dict:
|
||||
"""Args:
|
||||
subject: the item an approval of it would sign.
|
||||
channel: the radio class that tells the requesting agent.
|
||||
reason: the operator's reason, possibly empty.
|
||||
|
||||
Returns:
|
||||
The item a denial signs: its hash never equals the subject's, so no
|
||||
denial signature approves anything.
|
||||
"""
|
||||
return {"kind": "deny", "subject": item_hash(subject), "channel": channel, "reason": reason}
|
||||
|
||||
|
||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ services:
|
||||
mem_limit: 2g
|
||||
pids_limit: 256
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import socket; socket.create_connection(('127.0.0.1', 8080), 2).close()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -5,17 +5,44 @@ import time
|
||||
|
||||
from .asking import ask_state
|
||||
from .cleanup import cleanup_request
|
||||
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
|
||||
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
|
||||
from .signed import ask_signed, operator_signed, review_signed, signing
|
||||
from .rules import rule_matches, subject_of
|
||||
from .signed import (
|
||||
ask_signed,
|
||||
operator_signed,
|
||||
request_signed,
|
||||
review_signed,
|
||||
signing,
|
||||
verifier,
|
||||
)
|
||||
|
||||
BATCH_LIMIT = 100
|
||||
INBOX_KINDS = ("review", "ask", "decision", "operator")
|
||||
INBOX_KINDS = ("review", "ask", "decision", "operator", "rule")
|
||||
VERBS = ("approve", "reject")
|
||||
ITEM_KEYS = ("kind", "id", "verb", "label", "sha256")
|
||||
|
||||
|
||||
def inbox_item(raw: dict, label: str, title: str, gate: str, subject: dict | None) -> dict:
|
||||
"""Args:
|
||||
raw: the page's entry, already checked for its keys and types.
|
||||
label: the chosen option of a decision, else empty.
|
||||
title: what the stream logs for the item.
|
||||
gate: the review gate, else empty.
|
||||
subject: what an approval signs; None for a decision.
|
||||
|
||||
Returns:
|
||||
The batch item. A denial of a deniable kind signs its own item and
|
||||
carries the note that tells the requesting agent.
|
||||
"""
|
||||
note = denial_note(raw) if raw["verb"] == "reject" and raw["kind"] in DENIABLE else None
|
||||
signed = subject if raw["verb"] == "approve" else None if note is None else signing.denial_item(subject, note["channel"], note["reason"])
|
||||
sha256 = raw["sha256"] if raw["kind"] == "operator" else ""
|
||||
return {"kind": raw["kind"], "id": raw["id"], "verb": raw["verb"], "label": label, "sha256": sha256, "title": shown(title)[:200], "gate": gate, "signed": signed, "note": note}
|
||||
|
||||
|
||||
class Approving:
|
||||
@@ -34,7 +61,8 @@ class Approving:
|
||||
|
||||
Returns:
|
||||
The proof to relay, None when nothing needs a signature. The hub
|
||||
checks the shape only; the executors verify the signature.
|
||||
verifies the signature of a denial itself, since it acts on it;
|
||||
of an approval it checks the shape, and the executors verify.
|
||||
"""
|
||||
if not signed:
|
||||
if proof is not None:
|
||||
@@ -49,16 +77,28 @@ class Approving:
|
||||
raise ToolError(f"proof: {error}") from None
|
||||
if not isinstance(proof["assertion"], dict) or any(signing.item_hash(item) not in payload["items"] for item in signed):
|
||||
raise ToolError("proof must carry the assertion and list every approved item")
|
||||
for item in signed:
|
||||
if item["kind"] == "deny":
|
||||
self.proven(proof, item)
|
||||
return {"payload": payload, "assertion": proof["assertion"]}
|
||||
|
||||
def automate(self, kind_name: str, record: dict, subject: dict) -> None:
|
||||
def proven(self, proof: object, item: dict) -> dict:
|
||||
trust = verifier.Trust(approvers=self.approvers, replay=self.replay, origin=signing.origin_of(self.public_port))
|
||||
try:
|
||||
verifier.check(proof, item, "", trust, time.time())
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"nicht signiert: {error}") from None
|
||||
return proof
|
||||
|
||||
def automate(self, kind_name: str, record: dict) -> None:
|
||||
now = time.time()
|
||||
subject = subject_of(kind_name, record)
|
||||
rule = next((rule for rule in self.rules.listed() if rule_matches(rule, kind_name, subject, now)), None)
|
||||
if rule is None:
|
||||
return
|
||||
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
|
||||
signed = None if kind_name == "decision" else subject["item"]
|
||||
item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed}
|
||||
item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed, "note": None}
|
||||
batch = self.batches.add([item], f"rule:{rule['id']}", rule.get("proof"), self.apply)
|
||||
words = f"{rule['kind']} {rule['tool']} {rule['pattern']}".replace(" ", " ")
|
||||
self.log.add({"kind": "rule", "title": f"Regel gibt frei: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}})
|
||||
@@ -76,6 +116,8 @@ class Approving:
|
||||
|
||||
def settle_item(self, item: dict, proof: dict | None) -> str:
|
||||
approve = item["verb"] == "approve"
|
||||
if item["note"] is not None:
|
||||
return self.deny(item, proof)
|
||||
if item["kind"] == "operator":
|
||||
return self.queue_job(item, proof)
|
||||
if item["kind"] == "ask":
|
||||
@@ -93,27 +135,29 @@ class Approving:
|
||||
|
||||
def item_of(self, raw: object, tasks: dict[str, dict]) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
raise ToolError("every item must be an object with kind, id, verb, label and sha256")
|
||||
expect(raw, ("kind", "id", "verb", "label", "sha256"))
|
||||
kind_name, verb = raw["kind"], raw["verb"]
|
||||
raise ToolError("every item must be an object with kind, id, verb, label and sha256, and a denial also channel and reason")
|
||||
kind_name, verb = raw.get("kind"), raw.get("verb")
|
||||
expect(raw, (*ITEM_KEYS, *DENY_KEYS) if verb == "reject" and kind_name in DENIABLE else ITEM_KEYS)
|
||||
if kind_name not in INBOX_KINDS or verb not in VERBS or not isinstance(raw["label"], str) or not isinstance(raw["sha256"], str):
|
||||
raise ToolError(f"kind must be one of {', '.join(INBOX_KINDS)}, verb approve or reject, label and sha256 text")
|
||||
if kind_name == "operator":
|
||||
row = next((row for row in operator_rows(tasks) if row["task"] == raw["id"]), None)
|
||||
if row is None:
|
||||
raise Missing(str(raw["id"]))
|
||||
if verb != "approve" or not runnable(row):
|
||||
raise ToolError("an operator row is approved only, and only when it holds a directory and a command")
|
||||
if verb == "approve" and not runnable(row):
|
||||
raise ToolError("an operator row is approved only when it holds a directory and a command")
|
||||
if digest(row["command"]) != raw["sha256"]:
|
||||
raise Conflict({"id": row["task"], "status": "changed"})
|
||||
if row["task"] in self.open_jobs():
|
||||
raise Conflict({"id": row["task"], "status": "queued"})
|
||||
return {"kind": kind_name, "id": row["task"], "verb": verb, "label": "", "sha256": raw["sha256"], "title": row["command"][:200], "gate": "", "signed": operator_signed(row)}
|
||||
return inbox_item(raw, "", row["command"], "", operator_signed(row))
|
||||
if kind_name == "rule" and verb == "approve":
|
||||
raise ToolError("a rule request is accepted in its row, with the passkey signature over the rule itself")
|
||||
item_id = field(raw, "id")
|
||||
record = {"review": self.reviews, "ask": self.asks, "decision": self.decisions}[kind_name].get(item_id)
|
||||
record = {"review": self.reviews, "ask": self.asks, "decision": self.decisions, "rule": self.rule_requests}[kind_name].get(item_id)
|
||||
if record is None:
|
||||
raise Missing(item_id)
|
||||
status = ask_state(record, time.time())["status"] if kind_name == "ask" else record["status"]
|
||||
status = ask_state(record, time.time())["status"] if kind_name == "ask" else request_status(record, time.time()) if kind_name == "rule" else record["status"]
|
||||
if status != "pending":
|
||||
raise Conflict({"id": item_id, "status": status})
|
||||
label = ""
|
||||
@@ -121,11 +165,11 @@ class Approving:
|
||||
if verb != "approve" or raw["label"] not in [option["label"] for option in record["options"]]:
|
||||
raise ToolError("a decision is approved with one of its option labels")
|
||||
label = raw["label"]
|
||||
if kind_name == "rule":
|
||||
return inbox_item(raw, "", f"Regel {request_words(record)}", "", request_signed(record))
|
||||
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
|
||||
signed = None
|
||||
if verb == "approve" and kind_name != "decision":
|
||||
signed = ask_signed(record) if kind_name == "ask" else review_signed(record)
|
||||
return {"kind": kind_name, "id": item_id, "verb": verb, "label": label, "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed}
|
||||
subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else None
|
||||
return inbox_item(raw, label, title, record.get("gate", ""), subject)
|
||||
|
||||
def checked(self, raw: object) -> list[dict]:
|
||||
if not isinstance(raw, list) or not 1 <= len(raw) <= BATCH_LIMIT:
|
||||
|
||||
@@ -26,7 +26,7 @@ ANSWERS = {"allow": "allowed", "deny": "denied"}
|
||||
|
||||
def ask_state(ask: dict, now: float) -> dict:
|
||||
status = "expired" if ask["status"] == "pending" and ask["expires"] <= now else ask["status"]
|
||||
return {"id": ask["id"], "status": status, "decided": ask["decided"], "proof": ask.get("proof")}
|
||||
return {"id": ask["id"], "status": status, "decided": ask["decided"], "proof": ask.get("proof"), "channel": ask.get("channel", ""), "reason": ask.get("reason", "")}
|
||||
|
||||
|
||||
def ask_row(ask: dict, names: dict[str, str]) -> dict:
|
||||
@@ -59,7 +59,7 @@ class Asking:
|
||||
created = time.time()
|
||||
fields = {**values, "tool": field(payload, "tool").strip(), "status": "pending", "decided": None, "proof": None}
|
||||
record = self.asks.add({**fields, "created_ts": created, "expires": created + lifetime})
|
||||
self.automate("ask", record, {"item": ask_signed(record), "gate": ""})
|
||||
self.automate("ask", record)
|
||||
return {"id": record["id"], "status": record["status"]}
|
||||
|
||||
def ask_wait(self, ask_id: str, query: dict[str, list[str]]) -> dict:
|
||||
@@ -116,7 +116,7 @@ class Asking:
|
||||
raise ToolError("recommended must be one of the option labels or an empty string")
|
||||
fields = {"question": question, "options": parsed, "agent": field(arguments, "agent").strip(), "recommended": recommended.strip()}
|
||||
record = self.decisions.add({**fields, "status": "pending", "choice": None, "decided": None, "created_ts": time.time()})
|
||||
self.automate("decision", record, {"recommended": record["recommended"], "agent": record["agent"]})
|
||||
self.automate("decision", record)
|
||||
return {"id": record["id"], "status": record["status"], "url": self.url()}
|
||||
|
||||
def decision_wait(self, arguments: dict) -> dict:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .asking import ASK_LIFETIME, ask_state
|
||||
from .council import HUMAN, read_claims
|
||||
from .errors import ToolError
|
||||
from .messaging import DIRECT, TO_ALL
|
||||
from .register import REGISTER, adder, operator_rows, read_register
|
||||
from .signed import operator_signed, signing
|
||||
from .skill_scripts import RADIO, RASCI
|
||||
|
||||
DENIABLE = ("ask", "operator", "rule")
|
||||
DENY_KEYS = ("channel", "reason")
|
||||
DENY_CHANNELS = {**DIRECT, **{name: words for name, words in TO_ALL.items() if name != "mayday"}}
|
||||
REASON_LIMIT = 2000
|
||||
|
||||
|
||||
def denial_note(raw: dict) -> dict:
|
||||
channel, reason = raw["channel"], raw["reason"]
|
||||
if not isinstance(channel, str) or channel not in DENY_CHANNELS:
|
||||
raise ToolError(f"channel must be one of {', '.join(DENY_CHANNELS)}")
|
||||
if not isinstance(reason, str) or len(reason) > REASON_LIMIT:
|
||||
raise ToolError(f"reason must be text of at most {REASON_LIMIT} characters")
|
||||
return {"channel": channel, "reason": " ".join(reason.split())}
|
||||
|
||||
|
||||
def denial_text(what: str, requester: str, reason: str) -> str:
|
||||
words = f"Denied by the operator: {what} (from {requester or 'an agent'})."
|
||||
return f"{words} Reason: {reason}" if reason else words
|
||||
|
||||
|
||||
def request_words(record: dict) -> str:
|
||||
return " ".join(part for part in (record["kind"], record["tool"], record["pattern"]) if part) + f" in {record['scope']}"
|
||||
|
||||
|
||||
def request_status(record: dict, now: float) -> str:
|
||||
lapsed = record["created_ts"] + ASK_LIFETIME <= now or record["expires"] <= now
|
||||
return "expired" if record["status"] == "pending" and lapsed else record["status"]
|
||||
|
||||
|
||||
class Denying:
|
||||
def deny(self, item: dict, proof: dict | None) -> str:
|
||||
"""Args:
|
||||
item: a checked inbox item with verb reject and its note.
|
||||
proof: the relayed signature over the denial item.
|
||||
|
||||
Returns:
|
||||
The outcome words for the stream: what changed and whom the
|
||||
radio told.
|
||||
"""
|
||||
note = item["note"]
|
||||
if item["kind"] == "operator":
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
row = next((row for row in operator_rows(tasks) if row["task"] == item["id"]), None)
|
||||
if row is None or signing.denial_item(operator_signed(row), note["channel"], note["reason"]) != item["signed"]:
|
||||
return "verweigert: die Zeile hat sich seit der Ablehnung geändert"
|
||||
if item["id"] in self.open_jobs():
|
||||
return "läuft schon"
|
||||
run = self.scripts.run(RASCI, ["set", item["id"], "--status", "stopped"])
|
||||
if run["code"] != 0:
|
||||
return f"fehlgeschlagen: {run['command']} → {run['code']}"
|
||||
command, directory = row["command"][:200], row["directory"]
|
||||
return f"gestoppt · {self.tell(note, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}"
|
||||
ledger = self.asks if item["kind"] == "ask" else self.rule_requests
|
||||
record = ledger.get(item["id"])
|
||||
if record is None:
|
||||
return "unbekannt"
|
||||
now = time.time()
|
||||
if (ask_state(record, now)["status"] if item["kind"] == "ask" else request_status(record, now)) == "expired":
|
||||
return "abgelaufen"
|
||||
record, changed = ledger.decide(item["id"], {"status": "denied", "proof": proof, **note})
|
||||
if not changed:
|
||||
return f"schon {record['status']}"
|
||||
if item["kind"] == "ask":
|
||||
tool, command = record["tool"], record["command"][:200]
|
||||
return f"abgelehnt · {self.tell(note, proof, record['session'], f'ask {tool}: {command}')}"
|
||||
return f"abgelehnt · {self.tell(note, proof, record['agent'], f'rule request {request_words(record)}')}"
|
||||
|
||||
def tell(self, note: dict, proof: dict | None, who: str, what: str) -> str:
|
||||
"""Args:
|
||||
note: the chosen channel and the reason.
|
||||
proof: the operator's signature over the denial, kept with the
|
||||
message operator_message proves.
|
||||
who: the requesting agent's name or session; empty when unknown.
|
||||
what: what was denied, in the agent's words.
|
||||
|
||||
Returns:
|
||||
The class and addressee for the outcome line.
|
||||
"""
|
||||
claim = next((claim for claim in read_claims(self.council) if who and claim["address"] and who in (claim["agent"], claim["session"])), None)
|
||||
name, address = (claim["agent"], claim["address"]) if claim else (who, who)
|
||||
channel = note["channel"]
|
||||
direct = channel in DIRECT
|
||||
if direct and not address:
|
||||
return f"kein Empfänger für {channel}"
|
||||
text = denial_text(what, name, note["reason"])
|
||||
to, where = (address, f"{channel} an {address}") if direct else ("*", f"{channel} an alle")
|
||||
record = self.messages.add({"to": name if direct else "*", "address": to, "text": text, "upload": None, "class": channel, "proof": proof, "created_ts": time.time()})
|
||||
targets = ["--to", address] if direct else []
|
||||
run = self.scripts.run(RADIO, [*DENY_CHANNELS[channel], *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"])
|
||||
return where if run["code"] == 0 else f"{where} fehlgeschlagen ({run['code']})"
|
||||
@@ -27,9 +27,10 @@ STORED = {
|
||||
"files": (".json", "application/json", PLAIN_CSP),
|
||||
}
|
||||
DECISIONS = {"approve": "approved", "reject": "rejected"}
|
||||
INBOX_ROUTE = re.compile(r"/api/inbox/(approve|mark)")
|
||||
INBOX_ROUTE = re.compile(r"/api/inbox/(approve|mark|dismiss)")
|
||||
UNDO_ROUTE = re.compile(r"/api/inbox/undo/([0-9a-f]{16})")
|
||||
RULE_ROUTE = re.compile(r"/api/rules/([0-9a-f]{16})/(enable|disable|delete)")
|
||||
REQUEST_ROUTE = re.compile(r"/api/rule-requests/([0-9a-f]{16})/(sign|accept)")
|
||||
RUNNER_ROUTE = re.compile(r"/api/runner/([0-9a-f]{16})/(claim|report)")
|
||||
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
|
||||
SIGN_ROUTE = "/api/sign"
|
||||
@@ -122,8 +123,15 @@ class Handler(Exchange):
|
||||
inbox = INBOX_ROUTE.fullmatch(path)
|
||||
undo = UNDO_ROUTE.fullmatch(path)
|
||||
rule = RULE_ROUTE.fullmatch(path)
|
||||
request = REQUEST_ROUTE.fullmatch(path)
|
||||
if inbox and inbox.group(1) == "approve":
|
||||
return lambda body: self.answer(lambda: hub.approve(body), HTTPStatus.ACCEPTED)
|
||||
if inbox and inbox.group(1) == "dismiss":
|
||||
return lambda body: self.answer(lambda: hub.dismiss(body), HTTPStatus.OK)
|
||||
if request and request.group(2) == "sign":
|
||||
return lambda _body: self.answer(lambda: hub.sign_request(request.group(1)), HTTPStatus.OK)
|
||||
if request:
|
||||
return lambda body: self.answer(lambda: hub.accept_request(request.group(1), body), HTTPStatus.CREATED)
|
||||
if path == SIGN_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
|
||||
if path == RULE_SIGN_ROUTE:
|
||||
@@ -151,7 +159,7 @@ class Handler(Exchange):
|
||||
return None
|
||||
|
||||
def posted(self, path: str) -> bool:
|
||||
return any(pattern.fullmatch(path) for pattern in (INBOX_ROUTE, UNDO_ROUTE, RULE_ROUTE, RUNNER_ROUTE))
|
||||
return any(pattern.fullmatch(path) for pattern in (INBOX_ROUTE, UNDO_ROUTE, RULE_ROUTE, REQUEST_ROUTE, RUNNER_ROUTE))
|
||||
|
||||
def runner(self, path: str, body: bytes) -> None:
|
||||
hub = self.server.hub
|
||||
|
||||
@@ -12,6 +12,7 @@ from .artifacts import Touched
|
||||
from .asking import Asking
|
||||
from .batches import Pending
|
||||
from .cleanup import Cleaning, Spool
|
||||
from .denying import Denying
|
||||
from .engines import Engines, Speech
|
||||
from .events import Log, Watch
|
||||
from .icons import sheet
|
||||
@@ -23,6 +24,7 @@ from .mcp import Mcp
|
||||
from .messaging import Messaging
|
||||
from .names import Names
|
||||
from .offices import Offices
|
||||
from .requesting import Requesting
|
||||
from .reviewing import Reviewing
|
||||
from .rules import Rules
|
||||
from .settings import Settings
|
||||
@@ -57,7 +59,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||
council: Path
|
||||
pages: Path
|
||||
public_port: int
|
||||
@@ -65,6 +67,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox
|
||||
asks: Ledger
|
||||
decisions: Ledger
|
||||
messages: Ledger
|
||||
rule_requests: Ledger
|
||||
uploads: Path
|
||||
host_state: Path
|
||||
transcripts: Transcripts
|
||||
@@ -88,6 +91,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Offices, Inbox
|
||||
spool: Spool
|
||||
office_spool: Spool
|
||||
touched: Touched
|
||||
replay: Path
|
||||
|
||||
def hosts(self) -> tuple[str, str]:
|
||||
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
|
||||
@@ -108,6 +112,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
||||
touched=Touched(reviews.stored),
|
||||
approvers=approvers,
|
||||
speech=Speech(engines, state / "tts"),
|
||||
replay=state / "replay" / "messages.json",
|
||||
rules=Ledger(state / "rules.json"),
|
||||
jobs=Ledger(state / "jobs.json"),
|
||||
marks=Marks(state / "marks.json"),
|
||||
@@ -126,6 +131,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
||||
asks=Ledger(state / "asks.json"),
|
||||
decisions=Ledger(state / "decisions.json"),
|
||||
messages=Ledger(state / "messages.json"),
|
||||
rule_requests=Ledger(state / "rule-requests.json"),
|
||||
uploads=uploads,
|
||||
host_state=host_state,
|
||||
transcripts=Transcripts(transcripts),
|
||||
|
||||
@@ -5,9 +5,10 @@ import time
|
||||
from .approving import INBOX_KINDS
|
||||
from .errors import ToolError
|
||||
from .fields import decode, expect
|
||||
from .jobs import console_of
|
||||
from .names import Directory
|
||||
from .records import digest, iso_epoch
|
||||
from .register import operator_rows, runnable
|
||||
from .register import adder, operator_rows, runnable
|
||||
from .signed import signing
|
||||
|
||||
SNOOZE_LIMIT = 8 * 86400
|
||||
@@ -15,7 +16,7 @@ SNOOZE_LIMIT = 8 * 86400
|
||||
|
||||
class Inbox:
|
||||
def inbox(self, now: float, tasks: dict[str, dict], directory: Directory) -> list[dict]:
|
||||
base = {"agent": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False}
|
||||
base = {"agent": "", "detail": "", "green": False, "gate": "", "score": "", "warnings": [], "options": [], "recommended": "", "command": "", "directory": "", "sha256": "", "runnable": False, "deniable": False, "job": None, "rule": None, "dry_run": None}
|
||||
items = []
|
||||
for review in self.reviews.pending():
|
||||
created = iso_epoch(review["created"]) or now
|
||||
@@ -24,17 +25,26 @@ class Inbox:
|
||||
items.append({**base, "kind": "review", "id": review["id"], "ts": created, **fields})
|
||||
for ask in (ask for ask in self.asks.pending() if ask["expires"] > now):
|
||||
warnings = signing.warnings(ask["command"])
|
||||
fields = {"title": f"{ask['tool']}: {ask['command'][:200]}", "agent": directory.now(ask["session"]), "green": not warnings, "warnings": warnings}
|
||||
fields = {"title": f"{ask['tool']}: {ask['command'][:200]}", "agent": directory.now(ask["session"]), "green": not warnings, "warnings": warnings, "deniable": True}
|
||||
items.append({**base, "kind": "ask", "id": ask["id"], "ts": ask["created_ts"], **fields, "command": ask["command"], "directory": ask["cwd"]})
|
||||
for decision in self.decisions.pending():
|
||||
recommended = decision.get("recommended", "")
|
||||
fields = {"title": decision["question"][:300], "agent": decision["agent"], "green": recommended != "", "options": decision["options"], "recommended": recommended}
|
||||
items.append({**base, "kind": "decision", "id": decision["id"], "ts": decision["created_ts"], **fields})
|
||||
held = self.open_jobs()
|
||||
for row in (row for row in operator_rows(tasks) if row["task"] not in held):
|
||||
ok = runnable(row)
|
||||
fields = {"title": row["command"], "detail": row["why"], "command": row["command"], "directory": row["directory"], "sha256": digest(row["command"]) if ok else "", "runnable": ok}
|
||||
items.append({**base, "kind": "operator", "id": row["task"], "ts": self.watch.first(row["task"], now), **fields})
|
||||
consoles = self.consoles()
|
||||
rows = operator_rows(tasks)
|
||||
for row in rows:
|
||||
busy = row["task"] in held
|
||||
fields = {"title": row["command"], "detail": row["why"], "agent": adder(tasks[row["task"]]), "command": row["command"], "directory": row["directory"], "sha256": digest(row["command"])}
|
||||
state = {"runnable": runnable(row) and not busy, "deniable": not busy, "job": console_of(consoles.get(row["task"]))}
|
||||
items.append({**base, "kind": "operator", "id": row["task"], "ts": self.watch.first(row["task"], now), **fields, **state})
|
||||
listed = {row["task"] for row in rows}
|
||||
for task, job in consoles.items():
|
||||
if task not in listed:
|
||||
fields = {"title": job["command"], "command": job["command"], "directory": job["directory"], "job": console_of(job)}
|
||||
items.append({**base, "kind": "operator", "id": task, "ts": job["created_ts"], **fields})
|
||||
items += self.request_items(base, now)
|
||||
for item in items:
|
||||
mark = self.marks.get(f"{item['kind']}:{item['id']}")
|
||||
item.update(pinned=mark["pinned"], until=mark["until"] if mark["until"] > now else 0.0, held=self.batches.holds(item["kind"], item["id"]))
|
||||
|
||||
@@ -3,23 +3,47 @@ from __future__ import annotations
|
||||
import time
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import bounded, decode, expect
|
||||
from .fields import bounded, decode, expect, field
|
||||
from .register import OPERATOR, REGISTER, operator_rows, read_register, runnable
|
||||
from .signed import operator_signed
|
||||
from .skill_scripts import OPERATOR_SCRIPT
|
||||
from .skill_scripts import OPERATOR_SCRIPT, OUTPUT_LIMIT
|
||||
|
||||
JOB_OPEN = ("queued", "running")
|
||||
RUN_OUTPUT = 64 << 10
|
||||
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
|
||||
|
||||
|
||||
def runner_job(job: dict) -> dict:
|
||||
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")}
|
||||
|
||||
|
||||
def console_of(job: dict | None) -> dict | None:
|
||||
return None if job is None else {**{key: job[key] for key in CONSOLE}, "output": job["output"][-OUTPUT_LIMIT:]}
|
||||
|
||||
|
||||
class Jobs:
|
||||
def open_jobs(self) -> set[str]:
|
||||
return {job["task"] for job in self.jobs.listed() if job["status"] in JOB_OPEN}
|
||||
|
||||
def consoles(self) -> dict[str, dict]:
|
||||
latest: dict[str, dict] = {}
|
||||
for job in sorted(self.jobs.listed(), key=lambda job: job["created_ts"]):
|
||||
latest[job["task"]] = job
|
||||
# Trip-wire: jobs queued before the inbox console carry no dismissed key and must not flood the inbox after an upgrade.
|
||||
return {task: job for task, job in latest.items() if job.get("dismissed") is False}
|
||||
|
||||
def dismiss(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("id",))
|
||||
job_id = field(payload, "id")
|
||||
record = self.jobs.get(job_id)
|
||||
if record is None:
|
||||
raise Missing(job_id)
|
||||
if record["status"] in JOB_OPEN:
|
||||
raise Conflict({"id": job_id, "status": record["status"]})
|
||||
self.jobs.change(job_id, {"dismissed": True})
|
||||
return {"id": job_id, "dismissed": True}
|
||||
|
||||
def queue_job(self, item: dict, proof: dict | None) -> str:
|
||||
row = next((row for row in operator_rows(read_register(self.council / REGISTER)) if row["task"] == item["id"]), None)
|
||||
if row is None or not runnable(row) or operator_signed(row) != item["signed"]:
|
||||
@@ -27,7 +51,7 @@ class Jobs:
|
||||
if item["id"] in self.open_jobs():
|
||||
return "läuft schon"
|
||||
job = {"task": row["task"], "command": row["command"], "directory": row["directory"], "sha256": item["sha256"], "status": "queued", "proof": proof}
|
||||
self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
|
||||
self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None, "dismissed": False})
|
||||
return "an den Runner übergeben"
|
||||
|
||||
def runner_jobs(self) -> dict:
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .council import NEWS, ordered, read_claims
|
||||
from .denying import DENIABLE
|
||||
from .errors import ToolError
|
||||
from .events import VIEWS
|
||||
from .fields import bounded, decode, expect, message_text
|
||||
@@ -29,7 +30,7 @@ SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
|
||||
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
|
||||
NL_ACTIONS = [
|
||||
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
|
||||
{"name": "reject", "description": "Reject reviews or deny asks.", "parameters": {"ids": IDS}},
|
||||
{"name": "reject", "description": "Reject reviews, or deny asks, operator rows and rule requests; the requesting agent gets a post.", "parameters": {"ids": IDS}},
|
||||
{"name": "choose", "description": "Pick an option label of a decision.", "parameters": {"id": {"type": "string"}, "label": {"type": "string"}}},
|
||||
{"name": "snooze", "description": "Hide inbox items for some hours.", "parameters": {"ids": IDS, "hours": {"type": "number", "minimum": 1, "maximum": 192}}},
|
||||
{"name": "pin", "description": "Pin inbox items to the top.", "parameters": {"ids": IDS}},
|
||||
@@ -62,7 +63,8 @@ def nl_ids(arguments: dict, items: dict[str, dict]) -> list[dict]:
|
||||
|
||||
|
||||
def nl_item(item: dict, verb: str, label: str) -> dict:
|
||||
return {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]}
|
||||
entry = {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]}
|
||||
return {**entry, "channel": "post", "reason": ""} if verb == "reject" and item["kind"] in DENIABLE else entry
|
||||
|
||||
|
||||
def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
|
||||
@@ -75,8 +77,10 @@ def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
|
||||
expect(arguments, tuple(schema["parameters"]))
|
||||
if name in ("approve", "reject"):
|
||||
picked = nl_ids(arguments, items)
|
||||
if name == "reject" and any(item["kind"] not in ("review", "ask") for item in picked):
|
||||
raise ToolError("reject takes reviews and asks only")
|
||||
if name == "reject" and any(item["kind"] not in ("review", *DENIABLE) or (item["kind"] != "review" and not item["deniable"]) for item in picked):
|
||||
raise ToolError("reject takes reviews, asks, operator rows and rule requests that wait")
|
||||
if name == "approve" and any(item["kind"] == "rule" for item in picked):
|
||||
raise ToolError("a rule request is accepted in its row with the passkey")
|
||||
if name == "approve" and any(item["kind"] == "decision" and not item["recommended"] for item in picked):
|
||||
raise ToolError("a decision without a recommended option needs choose")
|
||||
if name == "approve" and any(item["kind"] == "operator" and not item["runnable"] for item in picked):
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from .records import read_json, stamp, write_atomic
|
||||
@@ -37,6 +38,17 @@ class Ledger:
|
||||
with self.changed:
|
||||
return self.insert(self.fresh(), fields)
|
||||
|
||||
def add_checked(self, fields: dict, check: Callable[[list[dict]], None]) -> dict:
|
||||
"""Args:
|
||||
fields: the record to add.
|
||||
check: raises to refuse the record; it sees every record under
|
||||
the lock the add holds, so parallel callers cannot pass a
|
||||
cap together.
|
||||
"""
|
||||
with self.changed:
|
||||
check(self.listed())
|
||||
return self.add(fields)
|
||||
|
||||
def get(self, item_id: str) -> dict | None:
|
||||
with self.changed:
|
||||
record = self.items.get(item_id)
|
||||
|
||||
@@ -7,7 +7,9 @@ from . import NAME
|
||||
from .asking import OPTION_LIMIT
|
||||
from .errors import RpcError, ToolError
|
||||
from .fields import WAIT_CAP
|
||||
from .requesting import AGENT_CAP
|
||||
from .reviewing import GATES
|
||||
from .rules import RULE_KINDS
|
||||
|
||||
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
||||
|
||||
@@ -77,6 +79,28 @@ class Mcp:
|
||||
f"(pending, decided) and the chosen label. Waits at most {WAIT_CAP} seconds.",
|
||||
{"id": text_property("Decision id that decision_request returned."), "timeout_seconds": wait},
|
||||
),
|
||||
tool(
|
||||
"rule_request",
|
||||
"Ask the operator for an auto-approval rule in the Agent Orchestrator. Nothing is stored until the operator "
|
||||
"signs the rule with the passkey; the limits are those of the rules form, and an ask rule never covers a "
|
||||
f"chained command or one with a warning. At most {AGENT_CAP} requests of one agent wait at a time. "
|
||||
"Returns the request id and the hub URL; a denial also reaches you over the radio with the reason.",
|
||||
{
|
||||
"kind": {"type": "string", "enum": list(RULE_KINDS), "description": "ask: a tool and command pattern; review: a branch pattern, green gate only; decision: take the recommended option."},
|
||||
"tool": text_property("Tool glob of an ask rule, e.g. Bash; empty for review and decision."),
|
||||
"pattern": text_property("One-line glob over the command (ask), the branch (review) or the agent name (decision)."),
|
||||
"scope": text_property("One-line glob over the working directory (ask) or the worktree (review); * for decision."),
|
||||
"expires": {"type": "integer", "description": "Epoch seconds the rule ends, at most 366 days ahead."},
|
||||
"agent": text_property("Your agent name, shown with the request."),
|
||||
"why": text_property("Why the rule helps, plain text for the operator."),
|
||||
},
|
||||
),
|
||||
tool(
|
||||
"rule_request_wait",
|
||||
f"Wait until the operator accepts or denies a rule request or the timeout passes, then return its status "
|
||||
f"(pending, accepted, denied), the stored rule id and the operator's reason. Waits at most {WAIT_CAP} seconds.",
|
||||
{"id": text_property("Request id that rule_request returned."), "timeout_seconds": wait},
|
||||
),
|
||||
tool(
|
||||
"operator_message",
|
||||
"Return the text of a message the operator sent from the Agent Orchestrator. A radio row from Operator "
|
||||
@@ -102,6 +126,8 @@ class Mcp:
|
||||
"review_list": self.pending,
|
||||
"decision_request": self.decision_request,
|
||||
"decision_wait": self.decision_wait,
|
||||
"rule_request": self.rule_request,
|
||||
"rule_request_wait": self.rule_request_wait,
|
||||
"operator_message": self.operator_message,
|
||||
}
|
||||
name = params.get("name")
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .council import agent, ordered
|
||||
from .council import HUMAN, agent, ordered
|
||||
from .fields import ORDER_NAME
|
||||
from .records import read_json, shown
|
||||
|
||||
@@ -45,6 +45,10 @@ def parties(row: dict) -> dict[str, str]:
|
||||
return found
|
||||
|
||||
|
||||
def adder(row: dict) -> str:
|
||||
return next((name for name, letters in parties(row).items() if "A" in letters and name != HUMAN), "")
|
||||
|
||||
|
||||
def is_open(task: str, row: dict, operator: bool) -> bool:
|
||||
return task.startswith(OPERATOR) == operator and status_of(row) not in ENDED
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .denying import request_status, request_words
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import WAIT_CAP, bounded, decode, expect, field, seconds
|
||||
from .records import iso_epoch
|
||||
from .rules import rule_fields, rule_matches, signed_fields, subject_of
|
||||
from .signed import signing
|
||||
|
||||
# Trip-wire: MCP carries no caller identity, so an agent may name itself anew for each request; AGENT_CAP only spreads the queue, TOTAL_CAP is the bound.
|
||||
AGENT_CAP = 3
|
||||
TOTAL_CAP = 20
|
||||
WHY_LIMIT = 1000
|
||||
RECENT_DAYS = 7
|
||||
RULE_FIELDS = ("kind", "tool", "pattern", "scope", "expires")
|
||||
REQUEST_STATE = ("id", "status", "rule", "reason", "decided")
|
||||
|
||||
|
||||
def request_state(record: dict, now: float) -> dict:
|
||||
return {**{key: record[key] for key in REQUEST_STATE}, "status": request_status(record, now)}
|
||||
|
||||
|
||||
def created_of(record: dict) -> float:
|
||||
return record["created_ts"] if "created_ts" in record else iso_epoch(record["created"]) or 0.0
|
||||
|
||||
|
||||
class Requesting:
|
||||
def open_requests(self, now: float) -> list[dict]:
|
||||
return [record for record in self.rule_requests.pending() if request_status(record, now) == "pending"]
|
||||
|
||||
def rule_request(self, arguments: dict) -> dict:
|
||||
expect(arguments, (*RULE_FIELDS, "agent", "why"))
|
||||
now = time.time()
|
||||
fields = rule_fields(arguments, now)
|
||||
if fields["kind"] == "decision" and fields["scope"] != "*":
|
||||
raise ToolError("a decision rule takes scope *, as the rules form does")
|
||||
name = field(arguments, "agent").strip()
|
||||
why = bounded(arguments, "why", WHY_LIMIT).strip()
|
||||
if not why:
|
||||
raise ToolError("why must name the purpose of the requested rule")
|
||||
|
||||
def capped(records: list[dict]) -> None:
|
||||
pending = [record for record in records if request_status(record, now) == "pending"]
|
||||
if len(pending) >= TOTAL_CAP:
|
||||
raise ToolError(f"{TOTAL_CAP} rule requests wait for the operator; ask again once some are answered")
|
||||
if sum(1 for record in pending if record["agent"] == name) >= AGENT_CAP:
|
||||
raise ToolError(f"{name} already has {AGENT_CAP} pending rule requests; wait for their answer with rule_request_wait")
|
||||
|
||||
record = self.rule_requests.add_checked({**fields, "agent": name, "why": why, "status": "pending", "rule": None, "reason": "", "channel": "", "proof": None, "decided": None, "created_ts": now}, capped)
|
||||
self.log.add({"kind": "rule", "title": f"Regelwunsch von {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}})
|
||||
return {"id": record["id"], "status": record["status"], "url": self.url()}
|
||||
|
||||
def rule_request_wait(self, arguments: dict) -> dict:
|
||||
expect(arguments, ("id", "timeout_seconds"))
|
||||
request_id = field(arguments, "id")
|
||||
record = self.rule_requests.wait(request_id, seconds(arguments, "timeout_seconds", WAIT_CAP))
|
||||
if record is None:
|
||||
raise ToolError(f"no rule request {request_id}")
|
||||
return request_state(record, time.time())
|
||||
|
||||
def dry_run(self, record: dict, now: float) -> dict:
|
||||
"""Returns:
|
||||
How many of the asks, reviews or decisions of the last
|
||||
RECENT_DAYS the requested rule would have approved, matched by
|
||||
the same code that applies a stored rule.
|
||||
"""
|
||||
kind_name = record["kind"]
|
||||
payload = signing.rule_payload(record["id"], [kind_name], record["tool"], record["pattern"], record["scope"], int(record["expires"]))
|
||||
rule = {**{key: record[key] for key in RULE_FIELDS}, "enabled": True, "proof": {"payload": payload, "assertion": None}}
|
||||
ledger = {"ask": self.asks, "review": self.reviews, "decision": self.decisions}[kind_name]
|
||||
since = now - RECENT_DAYS * 86400
|
||||
subjects = [subject_of(kind_name, entry) for entry in ledger.listed() if created_of(entry) >= since]
|
||||
return {"matched": sum(rule_matches(rule, kind_name, subject, now) for subject in subjects), "total": len(subjects), "days": RECENT_DAYS}
|
||||
|
||||
def request_items(self, base: dict, now: float) -> list[dict]:
|
||||
items = []
|
||||
for record in self.open_requests(now):
|
||||
rule = {key: record[key] for key in RULE_FIELDS}
|
||||
fields = {"title": f"Regel: {request_words(record)}", "agent": record["agent"], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True}
|
||||
items.append({**base, "kind": "rule", "id": record["id"], "ts": record["created_ts"], **fields})
|
||||
return items
|
||||
|
||||
def pending_request(self, request_id: str) -> dict:
|
||||
record = self.rule_requests.get(request_id)
|
||||
if record is None:
|
||||
raise Missing(request_id)
|
||||
state = request_state(record, time.time())
|
||||
if state["status"] != "pending":
|
||||
raise Conflict(state)
|
||||
if self.batches.holds("rule", request_id):
|
||||
raise Conflict({"id": request_id, "status": "held"})
|
||||
return record
|
||||
|
||||
def sign_request(self, request_id: str) -> dict:
|
||||
record = self.pending_request(request_id)
|
||||
return {**self.rule_offer(rule_fields(record, time.time())), "sign": True}
|
||||
|
||||
def accept_request(self, request_id: str, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("rule", "assertion"))
|
||||
record = self.pending_request(request_id)
|
||||
now = time.time()
|
||||
rule = payload["rule"]
|
||||
fields = signed_fields(rule, now)
|
||||
if [fields[key] for key in RULE_FIELDS] != [record[key] for key in RULE_FIELDS]:
|
||||
raise ToolError("the signed rule must be exactly the requested one")
|
||||
if self.rules.get(rule["id"]) is not None:
|
||||
raise Conflict({"id": rule["id"], "status": "exists"})
|
||||
proof = self.proven_rule(rule, payload["assertion"])
|
||||
decided, changed = self.rule_requests.decide(request_id, {"status": "accepted", "rule": rule["id"]})
|
||||
if not changed:
|
||||
raise Conflict(request_state(decided, now))
|
||||
self.store_rule(rule, fields, proof, True, now)
|
||||
self.log.add({"kind": "rule", "title": f"Regelwunsch angenommen: {request_words(record)}", "text": f"von {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}})
|
||||
return request_state(decided, now)
|
||||
@@ -66,7 +66,7 @@ class Reviewing:
|
||||
page = self.read_page(field(arguments, "page"))
|
||||
extra = {"notes": notes, "gate": arguments["gate"], "score": score}
|
||||
review = self.reviews.submit({**fields, "title": fields["title"].strip(), **extra}, page, review_files(page))
|
||||
self.automate("review", review, {"item": review_signed(review), "gate": review["gate"]})
|
||||
self.automate("review", review)
|
||||
return {"id": review["id"], "status": review["status"], "url": self.url()}
|
||||
|
||||
def review_proof(self, review_id: str) -> dict:
|
||||
|
||||
@@ -5,18 +5,26 @@ import time
|
||||
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import COMMAND_LIMIT, PATH_LIMIT, bounded, decode, expect
|
||||
from .signed import signing
|
||||
from .signed import ask_signed, passkey, review_signed, signing, verifier
|
||||
|
||||
RULE_KINDS = ("ask", "review", "decision")
|
||||
|
||||
|
||||
def rule_matches(rule: dict, kind_name: str, subject: dict, now: float) -> bool:
|
||||
if not rule["enabled"] or rule["expires"] <= now or rule["kind"] != kind_name:
|
||||
proof = rule.get("proof")
|
||||
if not rule["enabled"] or rule["expires"] <= now or rule["kind"] != kind_name or proof is None:
|
||||
return False
|
||||
if kind_name == "decision":
|
||||
return subject["recommended"] != "" and fnmatch.fnmatchcase(subject["agent"], rule["pattern"])
|
||||
proof = rule.get("proof")
|
||||
return proof is not None and signing.rule_allows(proof["payload"], subject["item"], subject["gate"], now)
|
||||
return signing.rule_allows(proof["payload"], subject["item"], subject["gate"], now)
|
||||
|
||||
|
||||
def subject_of(kind_name: str, record: dict) -> dict:
|
||||
if kind_name == "ask":
|
||||
return {"item": ask_signed(record), "gate": ""}
|
||||
if kind_name == "review":
|
||||
return {"item": review_signed(record), "gate": record.get("gate", "")}
|
||||
return {"recommended": record["recommended"], "agent": record["agent"]}
|
||||
|
||||
|
||||
def rule_fields(raw: dict, now: float) -> dict:
|
||||
@@ -34,38 +42,58 @@ def rule_fields(raw: dict, now: float) -> dict:
|
||||
return {"kind": kind_name, "tool": tool_name, "pattern": pattern, "scope": scope, "expires": int(expires)}
|
||||
|
||||
|
||||
def signed_fields(rule: object, now: float) -> dict:
|
||||
try:
|
||||
signing.checked_rule(rule, now)
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"rule: {error}") from None
|
||||
if len(rule["kinds"]) != 1:
|
||||
raise ToolError("a rule names exactly one kind")
|
||||
fields = rule_fields({**{name: rule[name] for name in ("tool", "pattern", "scope", "expires")}, "kind": rule["kinds"][0]}, now)
|
||||
if fields["tool"] != rule["tool"]:
|
||||
raise ToolError("tool must not carry surrounding blanks")
|
||||
return fields
|
||||
|
||||
|
||||
class Rules:
|
||||
def rule_offer(self, fields: dict) -> dict:
|
||||
rule = signing.rule_payload(self.rules.fresh(), [fields["kind"]], fields["tool"], fields["pattern"], fields["scope"], fields["expires"])
|
||||
return {"payload": rule, "challenge": signing.challenge(rule), "credentials": [key["id"] for key in self.keys()]}
|
||||
|
||||
def proven_rule(self, rule: dict, assertion: object) -> dict:
|
||||
"""Args:
|
||||
rule: the rule payload, already checked by signed_fields.
|
||||
assertion: the passkey assertion over its challenge.
|
||||
|
||||
Returns:
|
||||
The proof to store. The hub verifies it itself, since it settles
|
||||
matching items by the rule.
|
||||
"""
|
||||
try:
|
||||
passkey.verify_assertion(assertion, rule, verifier.pinned(self.approvers), signing.origin_of(self.public_port))
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"nicht signiert: {error}") from None
|
||||
return {"payload": rule, "assertion": assertion}
|
||||
|
||||
def store_rule(self, rule: dict, fields: dict, proof: dict, enabled: bool, now: float) -> dict:
|
||||
return self.rules.insert(rule["id"], {**fields, "expires": float(fields["expires"]), "enabled": enabled, "created_ts": now, "proof": proof})
|
||||
|
||||
def sign_rule(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("kind", "tool", "pattern", "scope", "expires"))
|
||||
fields = rule_fields(payload, time.time())
|
||||
rule = signing.rule_payload(self.rules.fresh(), [fields["kind"]], fields["tool"], fields["pattern"], fields["scope"], fields["expires"])
|
||||
credentials = [key["id"] for key in self.keys()]
|
||||
return {"payload": rule, "challenge": signing.challenge(rule), "sign": fields["kind"] != "decision", "credentials": credentials}
|
||||
return {**self.rule_offer(rule_fields(payload, time.time())), "sign": True}
|
||||
|
||||
def add_rule(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("rule", "assertion", "enabled"))
|
||||
rule, assertion = payload["rule"], payload["assertion"]
|
||||
rule = payload["rule"]
|
||||
now = time.time()
|
||||
try:
|
||||
signing.checked_rule(rule, now)
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"rule: {error}") from None
|
||||
if len(rule["kinds"]) != 1:
|
||||
raise ToolError("a rule names exactly one kind")
|
||||
fields = rule_fields({**{name: rule[name] for name in ("tool", "pattern", "scope", "expires")}, "kind": rule["kinds"][0]}, now)
|
||||
if fields["tool"] != rule["tool"]:
|
||||
raise ToolError("tool must not carry surrounding blanks")
|
||||
signed = fields["kind"] != "decision"
|
||||
if not (isinstance(assertion, dict) if signed else assertion is None):
|
||||
raise ToolError("an ask or review rule carries the passkey assertion; a decision rule carries null")
|
||||
fields = signed_fields(rule, now)
|
||||
if not isinstance(payload["enabled"], bool):
|
||||
raise ToolError("enabled must be true or false")
|
||||
if self.rules.get(rule["id"]) is not None:
|
||||
raise Conflict({"id": rule["id"], "status": "exists"})
|
||||
proof = {"payload": rule, "assertion": assertion} if signed else None
|
||||
return self.rules.insert(rule["id"], {**fields, "expires": float(fields["expires"]), "enabled": payload["enabled"], "created_ts": now, "proof": proof})
|
||||
return self.store_rule(rule, fields, self.proven_rule(rule, payload["assertion"]), payload["enabled"], now)
|
||||
|
||||
def rule_action(self, rule_id: str, verb: str) -> dict:
|
||||
if verb == "delete":
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py")
|
||||
signing = importlib.util.module_from_spec(SIGNING_SPEC)
|
||||
SIGNING_SPEC.loader.exec_module(signing)
|
||||
CLIENT = Path(__file__).resolve().parents[1] / "client"
|
||||
|
||||
|
||||
def client_module(name: str, uses: dict[str, ModuleType]) -> ModuleType:
|
||||
"""Args:
|
||||
name: a module of orchestrator/client, loaded under its own
|
||||
kvb_orchestrator_<name> name.
|
||||
uses: the client modules it imports by their bare names; they are
|
||||
visible under those names only while it loads.
|
||||
"""
|
||||
spec = importlib.util.spec_from_file_location(f"kvb_orchestrator_{name}", CLIENT / f"{name}.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
before = {key: sys.modules.get(key) for key in uses}
|
||||
sys.modules.update(uses)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
for key, old in before.items():
|
||||
if old is None:
|
||||
sys.modules.pop(key, None)
|
||||
else:
|
||||
sys.modules[key] = old
|
||||
return module
|
||||
|
||||
|
||||
signing = client_module("signing", {})
|
||||
passkey = client_module("passkey", {"signing": signing})
|
||||
verifier = client_module("verifier", {"signing": signing, "passkey": passkey})
|
||||
|
||||
|
||||
def ask_signed(ask: dict) -> dict:
|
||||
@@ -18,3 +45,7 @@ def review_signed(review: dict) -> dict:
|
||||
|
||||
def operator_signed(row: dict) -> dict:
|
||||
return signing.operator_item(row["task"], row["command"], row["directory"])
|
||||
|
||||
|
||||
def request_signed(request: dict) -> dict:
|
||||
return signing.rule_request_item(request["id"], request["kind"], request["tool"], request["pattern"], request["scope"], int(request["expires"]))
|
||||
@@ -46,8 +46,10 @@
|
||||
input off.
|
||||
--whisper-model: the ggml model whisper.cpp loads.
|
||||
--scratch: writable directory the engines use as HOME and TMPDIR.
|
||||
--approvers: the pinned passkeys, mounted read-only; the hub only lists
|
||||
their ids and algorithms for the page and never verifies or pins.
|
||||
--approvers: the pinned passkeys, mounted read-only; the hub lists their
|
||||
ids and algorithms for the page, never pins, and verifies a signature
|
||||
only for the denials and rules it acts on itself; every other approval
|
||||
its executor verifies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -403,6 +403,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
.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" %}
|
||||
|
||||
{% include "style-inbox.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -472,6 +474,12 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-inbox.js" %}
|
||||
|
||||
{% include "page-console.js" %}
|
||||
|
||||
{% include "page-deny.js" %}
|
||||
|
||||
{% include "page-rule-request.js" %}
|
||||
|
||||
{% include "page-review.js" %}
|
||||
|
||||
{% include "page-filters.js" %}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const consoles = new Map();
|
||||
const FOLLOW_SLACK = 12;
|
||||
const CONSOLE_POLL = 2000;
|
||||
const jobOpen = (job) => job.status === "queued" || job.status === "running";
|
||||
const withoutOutput = (item) => (item.job ? {...item, job: {...item.job, output: null}} : item);
|
||||
const consoleSizes = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const view = consoles.get(entry.target.dataset.job);
|
||||
if (view && view.follow) {
|
||||
view.out.scrollTop = view.out.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function lasting(seconds) {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
if (total < 60) {
|
||||
return `${total} s`;
|
||||
}
|
||||
if (total < 3600) {
|
||||
return `${Math.floor(total / 60)} min ${total % 60} s`;
|
||||
}
|
||||
return `${Math.floor(total / 3600)} h ${Math.floor((total % 3600) / 60)} min`;
|
||||
}
|
||||
|
||||
function consoleWords(job) {
|
||||
if (job.status === "queued") {
|
||||
return `wartet auf den Runner seit ${lasting(nowSeconds() - job.created_ts)}`;
|
||||
}
|
||||
if (job.status === "running") {
|
||||
return `läuft seit ${lasting(nowSeconds() - job.started)}`;
|
||||
}
|
||||
if (job.status === "refused") {
|
||||
return "der Runner hat die Signatur nicht angenommen";
|
||||
}
|
||||
return `Exit-Code ${job.code} nach ${lasting(job.finished - job.started)}`;
|
||||
}
|
||||
|
||||
function paintConsole(view, job) {
|
||||
const text = job.output || (jobOpen(job) ? "Noch keine Ausgabe." : "Keine Ausgabe.");
|
||||
if (view.out.textContent !== text) {
|
||||
view.out.textContent = text;
|
||||
}
|
||||
view.box.dataset.status = job.status;
|
||||
view.line.replaceChildren(status(job.status), h("span", {}, consoleWords(job)));
|
||||
}
|
||||
|
||||
function consoleOf(job) {
|
||||
let view = consoles.get(job.id);
|
||||
if (!view) {
|
||||
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Ausgabe des Befehls", "data-job": job.id});
|
||||
const line = h("div", {class: "console-status"});
|
||||
view = {box: h("div", {class: "console", "data-job": job.id}, line, out), out, line, follow: true, top: 0};
|
||||
out.addEventListener("scroll", () => {
|
||||
view.top = out.scrollTop;
|
||||
view.follow = out.scrollHeight - out.scrollTop - out.clientHeight <= FOLLOW_SLACK;
|
||||
});
|
||||
consoles.set(job.id, view);
|
||||
consoleSizes.observe(out);
|
||||
}
|
||||
paintConsole(view, job);
|
||||
return view.box;
|
||||
}
|
||||
|
||||
function settleConsoles(items) {
|
||||
const shown = new Set(items.filter((item) => item.job).map((item) => item.job.id));
|
||||
for (const [id, view] of consoles) {
|
||||
if (!shown.has(id)) {
|
||||
consoleSizes.unobserve(view.out);
|
||||
consoles.delete(id);
|
||||
} else if (view.box.isConnected) {
|
||||
view.out.scrollTop = view.follow ? view.out.scrollHeight : view.top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repaintConsoles(items) {
|
||||
for (const item of items) {
|
||||
const view = item.job ? consoles.get(item.job.id) : null;
|
||||
if (view) {
|
||||
paintConsole(view, item.job);
|
||||
}
|
||||
}
|
||||
settleConsoles(items);
|
||||
}
|
||||
|
||||
async function dismissJob(job) {
|
||||
const result = await sendJson("/api/inbox/dismiss", {id: job.id});
|
||||
if (!result.ok) {
|
||||
toast("Schließen nicht möglich", fail(result), false);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
window.setInterval(() => {
|
||||
if (ui.screen === "inbox" && state.inbox.some((item) => item.held || (item.job && jobOpen(item.job)))) {
|
||||
refresh();
|
||||
}
|
||||
}, CONSOLE_POLL);
|
||||
@@ -9,7 +9,7 @@
|
||||
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
||||
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
||||
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator", rule: "Regelwunsch"};
|
||||
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
|
||||
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
|
||||
const UPLOAD_LIMIT = 50 * 1024 * 1024;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const DENY_CHANNELS = [["post", true, "sobald sein Zug endet"], ["call", true, "sofort, weckt ihn"], ["newsletter", false, "sobald ihr Zug endet"], ["broadcast", false, "sofort"], ["pan-pan", false, "sofort, dringend"], ["security", false, "sofort, als Sicherheitsmeldung"]];
|
||||
const DENY_REASON = 2000;
|
||||
const requesterOf = (item) => item.agent || "den anfragenden Agenten";
|
||||
|
||||
function denyNow(item, channel, reason) {
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason}], `Ablehnung (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
}
|
||||
|
||||
function denySheet(item) {
|
||||
const name = `deny-${keyOf(item)}`;
|
||||
const choices = DENY_CHANNELS.map(([id, direct, hint]) => h("label", {class: "deny-channel"},
|
||||
h("input", {type: "radio", name, value: id, checked: id === "post"}),
|
||||
h("span", {}, h("strong", {}, CLASSES[id]), h("small", {}, `an ${direct ? requesterOf(item) : "alle"}, ${hint}`))));
|
||||
const reason = h("textarea", {class: "field", maxlength: DENY_REASON, rows: 3, placeholder: "z. B. erst das Gate grün machen"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger"}, icon("bi-x-lg"), "Ablehnen");
|
||||
confirm.addEventListener("click", () => {
|
||||
const picked = choices.map((label) => label.querySelector("input")).find((input) => input.checked);
|
||||
closeSheet();
|
||||
denyNow(item, picked.value, reason.value);
|
||||
});
|
||||
openSheet("Ablehnen", [
|
||||
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? person(item.agent) : null),
|
||||
h("p", {class: "text"}, item.title),
|
||||
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Kanal"), choices),
|
||||
h("label", {class: "deny-reason"}, "Grund (optional, der Agent liest ihn mit)", reason),
|
||||
h("p", {class: "muted"}, "Der Passkey signiert die Ablehnung samt Kanal und Grund; danach bleiben 3 s zum Rückgängigmachen.",
|
||||
" Mayday bleibt dem Operator vorbehalten."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
|
||||
reason.focus();
|
||||
}
|
||||
|
||||
function denySplit(item) {
|
||||
const main = iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => denyNow(item, "post", ""), `Ablehnen: per Post an ${requesterOf(item)}, 3 s rückgängig`);
|
||||
const more = h("button", {type: "button", class: "btn small danger icon-only", "aria-haspopup": "dialog", ...named("Ablehnen über …", "Ablehnen über einen anderen Kanal oder mit Grund")}, caret());
|
||||
more.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
denySheet(item);
|
||||
});
|
||||
return h("div", {class: "deny-split", role: "group", "aria-label": "Ablehnen"}, main, more);
|
||||
}
|
||||
@@ -56,7 +56,7 @@
|
||||
const visibleInbox = () => state.inbox.filter((item) => ui.snoozed || !item.until);
|
||||
const waitingCount = () => state.inbox.filter((item) => !item.until).length;
|
||||
const isGreen = (item) => item.green && !item.held && !item.until && item.kind !== "operator";
|
||||
const selectable = (item) => !item.held && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
|
||||
const selectable = (item) => !item.held && item.kind !== "rule" && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
|
||||
const entryOf = (item, verb, label) => ({kind: item.kind, id: item.id, verb, label, sha256: item.sha256 || ""});
|
||||
|
||||
function tick() {
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
}
|
||||
|
||||
function approveOne(item) {
|
||||
if (item.kind === "rule") {
|
||||
return acceptRule(item);
|
||||
}
|
||||
if (item.kind === "decision") {
|
||||
if (!item.recommended) {
|
||||
openItem(item);
|
||||
@@ -116,12 +119,22 @@
|
||||
return [h("span", {class: "muted"}, "wird wirksam …")];
|
||||
}
|
||||
const buttons = [];
|
||||
if (item.kind === "review" || item.kind === "ask") {
|
||||
if (item.kind === "review") {
|
||||
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
|
||||
} else if (item.kind === "ask") {
|
||||
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "decision" && item.recommended) {
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
|
||||
} else if (item.kind === "operator" && item.runnable) {
|
||||
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "rule") {
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named("Annehmen", "Annehmen: der Passkey signiert genau diese Regel, dann gilt sie"), on: {click: () => approveOne(item)}}, icon("bi-shield-check"), "Annehmen"));
|
||||
}
|
||||
if (item.deniable) {
|
||||
buttons.push(denySplit(item));
|
||||
}
|
||||
if (item.job && !jobOpen(item.job)) {
|
||||
buttons.push(iconBtn("bi-x-circle", "Schließen", "btn small ghost", () => dismissJob(item.job), "Schließen: Konsole ausblenden, eine erledigte Zeile verschwindet mit ihr"));
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
@@ -144,7 +157,9 @@
|
||||
item.agent ? h("div", {class: "muted"}, "von ", person(item.agent)) : null,
|
||||
item.kind === "review" ? h("div", {class: "muted"}, item.detail, item.score ? ` · ${item.score}` : "") : null,
|
||||
item.kind === "operator" && item.title !== item.command ? h("div", {class: "command"}, h("code", {}, item.command)) : null,
|
||||
item.kind === "operator" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`, item.detail !== "-" ? ` · ${item.detail}` : "") : null,
|
||||
item.kind === "operator" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`, item.detail && item.detail !== "-" ? ` · ${item.detail}` : "") : null,
|
||||
item.job ? consoleOf(item.job) : null,
|
||||
item.kind === "rule" ? ruleRequestBody(item) : null,
|
||||
item.kind === "ask" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`) : null,
|
||||
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
|
||||
@@ -176,8 +191,9 @@
|
||||
ui.selected.delete(key);
|
||||
}
|
||||
}
|
||||
const sig = JSON.stringify([items, [...ui.selected], ui.cursor, ui.snoozed, ui.busy, Math.floor(nowSeconds() / 60)]);
|
||||
const sig = JSON.stringify([items.map(withoutOutput), [...ui.selected], ui.cursor, ui.snoozed, ui.busy, Math.floor(nowSeconds() / 60)]);
|
||||
if (sig === inboxSig) {
|
||||
repaintConsoles(items);
|
||||
return;
|
||||
}
|
||||
inboxSig = sig;
|
||||
@@ -194,8 +210,9 @@
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
|
||||
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
|
||||
h("div", {class: "inbox-actions"}, choose, all),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
|
||||
onLocalhost() ? "" : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
|
||||
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
|
||||
settleConsoles(items);
|
||||
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||
if (current && document.activeElement === document.body) {
|
||||
current.scrollIntoView({block: "nearest"});
|
||||
@@ -214,6 +231,7 @@
|
||||
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? face(item.agent, false) : null, item.agent ? person(item.agent) : null, when(item.ts)),
|
||||
h("p", {class: "text"}, item.title),
|
||||
item.command ? h("div", {class: "command"}, h("code", {}, item.command), copyButton(item.command)) : null,
|
||||
item.kind === "rule" ? ruleRequestBody(item) : null,
|
||||
meta([["Verzeichnis", item.directory], ["Warnungen", item.warnings.join(", ")], ["Id", item.id]]),
|
||||
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
|
||||
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
const COUNTED = {ask: "Asks", review: "Reviews", decision: "Entscheidungen"};
|
||||
|
||||
function ruleRequestBody(item) {
|
||||
const rule = item.rule;
|
||||
const dry = item.dry_run;
|
||||
const pairs = [["Art", RULE_KINDS[rule.kind]], rule.kind === "ask" ? ["Werkzeug (Glob)", rule.tool] : null, ["Muster (Glob)", rule.pattern], rule.kind === "decision" ? null : ["Verzeichnis (Glob)", rule.scope], ["Läuft ab am", absolute.format(stampOf(rule.expires))]];
|
||||
return h("div", {class: "rule-request"},
|
||||
item.detail ? h("p", {class: "text"}, item.detail) : null,
|
||||
meta(pairs.filter(Boolean)),
|
||||
h("p", {class: "muted dry-run-count"}, "Trockenlauf: Die Regel hätte ", h("strong", {}, `${dry.matched} von ${dry.total}`), ` ${COUNTED[rule.kind]} der letzten ${dry.days} Tage freigegeben.`),
|
||||
h("p", {class: "muted"}, RULE_HINTS[rule.kind]));
|
||||
}
|
||||
|
||||
async function acceptRule(item) {
|
||||
const label = `Regel: ${short(item.title)}`;
|
||||
ui.busy = true;
|
||||
const prepared = await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/sign`, {});
|
||||
const signed = prepared.ok && prepared.data ? await assertion(prepared.data, label) : null;
|
||||
if (!prepared.ok || !prepared.data) {
|
||||
toast(`${label}: nicht möglich`, fail(prepared), false);
|
||||
}
|
||||
const result = signed ? await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/accept`, {rule: prepared.data.payload, assertion: signed}) : null;
|
||||
ui.busy = false;
|
||||
if (result) {
|
||||
toast(result.ok ? `Angelegt: ${short(item.title)}` : `${label}: nicht angelegt`, result.ok ? [] : fail(result), result.ok);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
const RULE_KINDS = {ask: "Ask: Werkzeug + Befehlsmuster", review: "Review: Branch-Muster + Gate grün", decision: "Entscheidung: Empfehlung nehmen"};
|
||||
const RULE_HINTS = {
|
||||
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
|
||||
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
|
||||
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option. Der Passkey signiert die Regel einmal; die Entscheidungen selbst bleiben unsigniert.",
|
||||
};
|
||||
|
||||
function rulesScreen() {
|
||||
const kind = h("select", {class: "field", "aria-label": "Art"}, h("option", {value: "ask"}, "Ask: Werkzeug + Befehlsmuster"), h("option", {value: "review"}, "Review: Branch-Muster + Gate grün"), h("option", {value: "decision"}, "Entscheidung: Empfehlung nehmen"));
|
||||
const kind = h("select", {class: "field", "aria-label": "Art"}, Object.entries(RULE_KINDS).map(([value, label]) => h("option", {value}, label)));
|
||||
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
|
||||
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
||||
const scope = h("input", {class: "field", type: "text", value: "*", placeholder: "/home/…/Repositories/*"});
|
||||
@@ -13,11 +20,7 @@
|
||||
toolField.hidden = kind.value !== "ask";
|
||||
scopeField.hidden = kind.value === "decision";
|
||||
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (Agent-Muster)"}[kind.value];
|
||||
hint.textContent = {
|
||||
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
|
||||
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
|
||||
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option; Entscheidungen bleiben unsigniert.",
|
||||
}[kind.value];
|
||||
hint.textContent = RULE_HINTS[kind.value];
|
||||
};
|
||||
kind.addEventListener("change", sync);
|
||||
sync();
|
||||
@@ -58,7 +61,7 @@
|
||||
rule.tool ? h("code", {}, rule.tool) : null,
|
||||
h("code", {}, rule.pattern),
|
||||
rule.kind === "decision" ? null : h("code", {title: "Verzeichnis"}, rule.scope || "–"),
|
||||
rule.kind === "decision" ? null : pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
|
||||
pill(rule.signed ? "signiert" : "unsigniert: wirkt nicht", rule.signed ? "ok" : "bad"),
|
||||
rule.expires <= now ? pill("abgelaufen", "bad") : h("span", {class: "muted"}, `bis ${absolute.format(stampOf(rule.expires))}`),
|
||||
rule.enabled ? pill("aktiv", "ok") : pill("aus", "neutral"),
|
||||
h("span", {class: "spacer"}),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.console { display: grid; margin-top: var(--sp-2); overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); }
|
||||
.console[data-status="failed"], .console[data-status="refused"] { border-color: color-mix(in srgb, var(--bad) 50%, var(--line)); }
|
||||
.console-status { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: 5px 10px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
|
||||
.console-out { min-height: 3.2em; max-height: min(40vh, 320px); margin: 0; padding: 8px 10px; overflow: auto; color: var(--text); font-family: var(--mono); font-size: var(--fs-sm); line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; overscroll-behavior: contain; }
|
||||
.console-out:focus-visible { outline-offset: -2px; }
|
||||
.deny-split { display: inline-flex; }
|
||||
.deny-split > .btn:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
||||
.deny-split > .btn + .btn { min-width: 22px; margin-left: -1px; padding-right: 5px; padding-left: 5px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
||||
.deny-channels { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: var(--sp-2); min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.deny-channels legend { margin-bottom: var(--sp-2); padding: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.deny-channel { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); cursor: pointer; }
|
||||
.deny-channel:has(input:checked) { border-color: var(--bad); box-shadow: inset 3px 0 0 var(--bad); }
|
||||
.deny-channel input { margin-top: 3px; accent-color: var(--bad); }
|
||||
.deny-channel span { display: grid; gap: 1px; }
|
||||
.deny-channel small { color: var(--muted); font-size: var(--fs-xs); }
|
||||
.deny-reason { display: grid; gap: var(--sp-1); color: var(--muted); font-size: var(--fs-sm); }
|
||||
.rule-request { display: grid; gap: var(--sp-1); margin-top: var(--sp-2); padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); }
|
||||
.rule-request p { margin: 0; }
|
||||
.rule-request .meta { margin: 0; }
|
||||
.dry-run-count strong { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
Reference in new issue
Block a user