From 8ba45efd04abde307cd8888687870007034432db Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 6 Nov 2026 08:28:04 +0000 Subject: [PATCH] feat(hub): kvb-hub review hub with passkey-signed approvals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kvb-hub is a local review hub in a container bound to 127.0.0.1: one page, JSON APIs and an MCP endpoint for the operator's inbox, asks, decisions, staged-commit reviews, the radio stream, the agent graph, the worktree tree, push-to-talk and read-aloud radio (Piper, whisper.cpp) and an optional LLM command line. The hub is only a relay. The host executors in hub/client (the runner user service, the PermissionRequest hook and the approval CLI the commit skills call) verify a WebAuthn passkey signature against keys pinned outside every sandbox, refuse replays and fail closed. scripts/hub.sh with make hub-install, hub-check, hub-pin, hub-runner and hub-hook installs and checks the hub; hub-hook writes ~/.claude/settings.json after a backup. make watch-check shows a stuck watch hook. The new kvb-git-diff skill and the commit skills route reviews through the hub; the test_hub_* suites replace test_hub.py. Security review (kvb-think-criticize, three skeptics) fixed four holes: signed rules auto-approved chained shell commands; the client and LLM openers followed redirects, the LLM one with its bearer key; the worktree scan ran git fsmonitor and filter commands from agent-writable repos; compose set no memory, pids or health limits. Documented until core requirement 093, on the operator's word: the hub page is trusted while signing and pinning, local processes can forge the unsigned operator actions, and the container reads the host /tmp. Committed on the operator's word despite the gate: the passkey path is not yet verified live. Accepted MEDUSA false positives (137 findings on 115 lines, rows in security-accepted.jsonl): EXA002 x17, MEDUSA-MM-SCAN-058 x12, B603 x8, MEDUSA-FTSC-SCAN-089 x7, MEDUSA-PRIV-SCAN-762 x6, AIC-EXFIL-005 x5, EXA006 x5, MEDUSA-FTSCAN-231 x5, WEB-SSRF x5, MEDUSA-INF-SCAN-2057 x3, RH005 x3, TC001 x3, LO002 x2, MEDUSA-AEXP-SCAN-096 x2, MEDUSA-CGS-SCAN-028 x2, MEDUSA-INF-SCAN-1350 x2, MEDUSA-INF-SCAN-2369 x2, MEDUSA-INF-SCAN-2440 x2, MEDUSA-INF-SCAN-2638 x2, MLSC-SERIAL-002 x2, PL002 x2, PQC005 x2, SAST-PATH-001 x2, TC004 x2, TC005 x2, TC006 x2, TC007 x2, AA-146, EXA007, FINE-050, GB-ENCODE-001, GB-ENCODE-003, MEDUSA-CGS-SCAN-029, MEDUSA-CGS-SCAN-103, MEDUSA-DP-SCAN-1034, MEDUSA-DP-SCAN-1916, MEDUSA-DP-SCAN-954, MEDUSA-EXF26-TOOL-004, MEDUSA-FT-TRIGGER-005, MEDUSA-INF-SCAN-2206, MEDUSA-INF-SCAN-2457, MEDUSA-INF-WEB-003, MEDUSA-INFRA-SCAN-030, MEDUSA-MP-SCAN-1135, MEDUSA-RLHF-POISON-002, MEDUSA-RT-SCAN-006, MM-ENCODE-001, MP-124, PQC001, PQC002, PQC006, RP001, TC009, TC010, WEB-FLASK-004. 🎯 Fix: 80 % 💥 Break: 4 % 💸 Cost: 129 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 60 🚦 Gate: ⛔ fix 80 % < 90; cost 129 % > 100: ask the operator before the commit ``` /home/kevinveenbirkenbach/Repositories/github.com/kevinveenbirkenbach/skills/.worktrees/feature/kvb-hub ``` Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 1 + Makefile | 26 +- README.md | 12 + hub/.env | 5 + hub/Dockerfile | 40 ++ hub/client/approval.py | 113 ++++++ hub/client/hub_api.py | 31 ++ hub/client/passkey.py | 239 +++++++++++ hub/client/permission_hook.py | 120 ++++++ hub/client/runner.py | 158 ++++++++ hub/client/signing.py | 142 +++++++ hub/client/verifier.py | 124 ++++++ hub/compose.yml | 132 +++++++ hub/kvb_hub/__init__.py | 1 + hub/kvb_hub/approving.py | 163 ++++++++ hub/kvb_hub/asking.py | 128 ++++++ hub/kvb_hub/batches.py | 63 +++ hub/kvb_hub/council.py | 115 ++++++ hub/kvb_hub/crons.py | 67 ++++ hub/kvb_hub/engines.py | 205 ++++++++++ hub/kvb_hub/errors.py | 29 ++ hub/kvb_hub/events.py | 134 +++++++ hub/kvb_hub/exchange.py | 108 +++++ hub/kvb_hub/fields.py | 106 +++++ hub/kvb_hub/graph.py | 80 ++++ hub/kvb_hub/handler.py | 207 ++++++++++ hub/kvb_hub/hub.py | 115 ++++++ hub/kvb_hub/inbox.py | 56 +++ hub/kvb_hub/jobs.py | 65 +++ hub/kvb_hub/language.py | 186 +++++++++ hub/kvb_hub/ledger.py | 123 ++++++ hub/kvb_hub/lifetimes.py | 79 ++++ hub/kvb_hub/mcp.py | 152 +++++++ hub/kvb_hub/messaging.py | 113 ++++++ hub/kvb_hub/names.py | 139 +++++++ hub/kvb_hub/orbits.py | 121 ++++++ hub/kvb_hub/phrasing.py | 58 +++ hub/kvb_hub/records.py | 71 ++++ hub/kvb_hub/register.py | 107 +++++ hub/kvb_hub/review_page.py | 98 +++++ hub/kvb_hub/reviewing.py | 105 +++++ hub/kvb_hub/rules.py | 78 ++++ hub/kvb_hub/settings.py | 198 ++++++++++ hub/kvb_hub/signed.py | 20 + hub/kvb_hub/skill_scripts.py | 44 +++ hub/kvb_hub/sources.py | 53 +++ hub/kvb_hub/timeline.py | 115 ++++++ hub/kvb_hub/transcripts.py | 194 +++++++++ hub/kvb_hub/tree.py | 167 ++++++++ hub/kvb_hub/view.py | 122 ++++++ hub/requirements-piper.txt | 19 + hub/requirements.txt | 1 + hub/server.py | 97 +++++ hub/systemd/kvb-hub-runner.service | 12 + hub/templates/ambience.js | 216 ++++++++++ hub/templates/index.html.j2 | 482 +++++++++++++++++++++++ hub/templates/page-agent.js | 136 +++++++ hub/templates/page-alerts.js | 75 ++++ hub/templates/page-api.js | 17 + hub/templates/page-board.js | 96 +++++ hub/templates/page-boot.js | 63 +++ hub/templates/page-constants.js | 71 ++++ hub/templates/page-dom.js | 124 ++++++ hub/templates/page-filters.js | 151 +++++++ hub/templates/page-graph-charts.js | 199 ++++++++++ hub/templates/page-graph-layout.js | 144 +++++++ hub/templates/page-inbox.js | 222 +++++++++++ hub/templates/page-keyboard.js | 86 ++++ hub/templates/page-load.js | 43 ++ hub/templates/page-music.js | 148 +++++++ hub/templates/page-palette.js | 151 +++++++ hub/templates/page-passkey.js | 85 ++++ hub/templates/page-pulses.js | 54 +++ hub/templates/page-radio.js | 86 ++++ hub/templates/page-review.js | 149 +++++++ hub/templates/page-rules.js | 74 ++++ hub/templates/page-screens.js | 93 +++++ hub/templates/page-settings.js | 139 +++++++ hub/templates/page-sky.js | 209 ++++++++++ hub/templates/page-space-scene.js | 197 +++++++++ hub/templates/page-space-steer.js | 194 +++++++++ hub/templates/page-speech.js | 173 ++++++++ hub/templates/page-state.js | 60 +++ hub/templates/page-stream.js | 186 +++++++++ hub/templates/page-time.js | 131 ++++++ hub/templates/page-tree.js | 87 ++++ hub/templates/space-paint.js | 191 +++++++++ hub/templates/space-pick.js | 66 ++++ hub/templates/space-scene.js | 227 +++++++++++ hub/templates/space-view.js | 127 ++++++ hub/templates/space.js | 16 + hub/templates/voice.js | 71 ++++ scripts/hub.sh | 174 ++++++++ scripts/install.sh | 6 + scripts/watch-check.sh | 26 ++ skills/kvb-git-commit-next/SKILL.md | 5 +- skills/kvb-git-commit-no-verify/SKILL.md | 5 +- skills/kvb-git-diff/SKILL.md | 44 +++ skills/kvb-org-radio/SKILL.md | 4 +- tests/hub_case.py | 213 ++++++++++ tests/hub_engines.py | 72 ++++ tests/hub_support.py | 165 ++++++++ tests/test_hub_ambience.py | 87 ++++ tests/test_hub_approval.py | 277 +++++++++++++ tests/test_hub_asks.py | 174 ++++++++ tests/test_hub_board.py | 172 ++++++++ tests/test_hub_executors.py | 235 +++++++++++ tests/test_hub_graph.py | 145 +++++++ tests/test_hub_inbox.py | 104 +++++ tests/test_hub_mcp.py | 97 +++++ tests/test_hub_page.py | 73 ++++ tests/test_hub_reviews.py | 180 +++++++++ tests/test_hub_settings.py | 132 +++++++ tests/test_hub_space.py | 249 ++++++++++++ tests/test_hub_speech.py | 193 +++++++++ tests/test_hub_state.py | 217 ++++++++++ tests/test_hub_stream.py | 170 ++++++++ tests/test_hub_tree.py | 72 ++++ tests/test_hub_voice.py | 89 +++++ 119 files changed, 13672 insertions(+), 4 deletions(-) create mode 100644 hub/.env create mode 100644 hub/Dockerfile create mode 100755 hub/client/approval.py create mode 100644 hub/client/hub_api.py create mode 100644 hub/client/passkey.py create mode 100755 hub/client/permission_hook.py create mode 100755 hub/client/runner.py create mode 100644 hub/client/signing.py create mode 100644 hub/client/verifier.py create mode 100644 hub/compose.yml create mode 100644 hub/kvb_hub/__init__.py create mode 100644 hub/kvb_hub/approving.py create mode 100644 hub/kvb_hub/asking.py create mode 100644 hub/kvb_hub/batches.py create mode 100644 hub/kvb_hub/council.py create mode 100644 hub/kvb_hub/crons.py create mode 100644 hub/kvb_hub/engines.py create mode 100644 hub/kvb_hub/errors.py create mode 100644 hub/kvb_hub/events.py create mode 100644 hub/kvb_hub/exchange.py create mode 100644 hub/kvb_hub/fields.py create mode 100644 hub/kvb_hub/graph.py create mode 100644 hub/kvb_hub/handler.py create mode 100644 hub/kvb_hub/hub.py create mode 100644 hub/kvb_hub/inbox.py create mode 100644 hub/kvb_hub/jobs.py create mode 100644 hub/kvb_hub/language.py create mode 100644 hub/kvb_hub/ledger.py create mode 100644 hub/kvb_hub/lifetimes.py create mode 100644 hub/kvb_hub/mcp.py create mode 100644 hub/kvb_hub/messaging.py create mode 100644 hub/kvb_hub/names.py create mode 100644 hub/kvb_hub/orbits.py create mode 100644 hub/kvb_hub/phrasing.py create mode 100644 hub/kvb_hub/records.py create mode 100644 hub/kvb_hub/register.py create mode 100644 hub/kvb_hub/review_page.py create mode 100644 hub/kvb_hub/reviewing.py create mode 100644 hub/kvb_hub/rules.py create mode 100644 hub/kvb_hub/settings.py create mode 100644 hub/kvb_hub/signed.py create mode 100644 hub/kvb_hub/skill_scripts.py create mode 100644 hub/kvb_hub/sources.py create mode 100644 hub/kvb_hub/timeline.py create mode 100644 hub/kvb_hub/transcripts.py create mode 100644 hub/kvb_hub/tree.py create mode 100644 hub/kvb_hub/view.py create mode 100644 hub/requirements-piper.txt create mode 100644 hub/requirements.txt create mode 100755 hub/server.py create mode 100644 hub/systemd/kvb-hub-runner.service create mode 100644 hub/templates/ambience.js create mode 100644 hub/templates/index.html.j2 create mode 100644 hub/templates/page-agent.js create mode 100644 hub/templates/page-alerts.js create mode 100644 hub/templates/page-api.js create mode 100644 hub/templates/page-board.js create mode 100644 hub/templates/page-boot.js create mode 100644 hub/templates/page-constants.js create mode 100644 hub/templates/page-dom.js create mode 100644 hub/templates/page-filters.js create mode 100644 hub/templates/page-graph-charts.js create mode 100644 hub/templates/page-graph-layout.js create mode 100644 hub/templates/page-inbox.js create mode 100644 hub/templates/page-keyboard.js create mode 100644 hub/templates/page-load.js create mode 100644 hub/templates/page-music.js create mode 100644 hub/templates/page-palette.js create mode 100644 hub/templates/page-passkey.js create mode 100644 hub/templates/page-pulses.js create mode 100644 hub/templates/page-radio.js create mode 100644 hub/templates/page-review.js create mode 100644 hub/templates/page-rules.js create mode 100644 hub/templates/page-screens.js create mode 100644 hub/templates/page-settings.js create mode 100644 hub/templates/page-sky.js create mode 100644 hub/templates/page-space-scene.js create mode 100644 hub/templates/page-space-steer.js create mode 100644 hub/templates/page-speech.js create mode 100644 hub/templates/page-state.js create mode 100644 hub/templates/page-stream.js create mode 100644 hub/templates/page-time.js create mode 100644 hub/templates/page-tree.js create mode 100644 hub/templates/space-paint.js create mode 100644 hub/templates/space-pick.js create mode 100644 hub/templates/space-scene.js create mode 100644 hub/templates/space-view.js create mode 100644 hub/templates/space.js create mode 100644 hub/templates/voice.js create mode 100644 scripts/hub.sh create mode 100644 scripts/watch-check.sh create mode 100644 skills/kvb-git-diff/SKILL.md create mode 100644 tests/hub_case.py create mode 100644 tests/hub_engines.py create mode 100644 tests/hub_support.py create mode 100644 tests/test_hub_ambience.py create mode 100644 tests/test_hub_approval.py create mode 100644 tests/test_hub_asks.py create mode 100644 tests/test_hub_board.py create mode 100644 tests/test_hub_executors.py create mode 100644 tests/test_hub_graph.py create mode 100644 tests/test_hub_inbox.py create mode 100644 tests/test_hub_mcp.py create mode 100644 tests/test_hub_page.py create mode 100644 tests/test_hub_reviews.py create mode 100644 tests/test_hub_settings.py create mode 100644 tests/test_hub_space.py create mode 100644 tests/test_hub_speech.py create mode 100644 tests/test_hub_state.py create mode 100644 tests/test_hub_stream.py create mode 100644 tests/test_hub_tree.py create mode 100644 tests/test_hub_voice.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 964f654..5020391 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,7 @@ jobs: run: | python -m pip install --upgrade pip pip install --group lint + pip install --requirement hub/requirements.txt sudo apt-get update && sudo apt-get install -y shellcheck npm install -g markdownlint-cli2 diff --git a/Makefile b/Makefile index ae1689d..2fa1c76 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install project test update budget +.PHONY: install project test update budget hub-install hub-check hub-pin hub-runner hub-hook watch-check # Install the skills globally into $HOME (.agents/skills + .claude/skills). install: @@ -23,3 +23,27 @@ update: # Count skill description words against the listing budget: make budget [INDEX=1] budget: @python3 scripts/budget.py --repo . --top 5 $(if $(INDEX),--index) + +# Install, build and start only the kvb-hub, without touching the installed skills. +hub-install: + @bash scripts/hub.sh install + +# Check the installed kvb-hub: image, container, logs, HTTP, pinned keys, hook, runner, disk. +hub-check: + @bash scripts/hub.sh check + +# Param: CREDENTIAL the blob the hub's "Passkey registrieren" shows: make hub-pin CREDENTIAL= +hub-pin: + @bash scripts/hub.sh pin "$(CREDENTIAL)" + +# Install, enable and restart the kvb-hub runner user service. +hub-runner: + @bash scripts/hub.sh runner + +# Register the kvb-hub PermissionRequest hook in ~/.claude/settings.json. +hub-hook: + @bash scripts/hub.sh hook + +# Show whether the agents-council watch hook is stuck. +watch-check: + @bash scripts/watch-check.sh diff --git a/README.md b/README.md index 8eeada0..19b4bba 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ flowchart LR git --> git_changelog[kvb-git-changelog] git --> git_hotfix[kvb-git-hotfix] git --> git_push[kvb-git-push] + git --> git_diff[kvb-git-diff] check --> check_legal[kvb-check-legal] check --> check_security[kvb-check-security] check --> check_principles[kvb-check-principles] @@ -161,6 +162,7 @@ flowchart LR changelog[kvb-git-changelog] hotfix[kvb-git-hotfix] git_push[kvb-git-push] + git_diff[kvb-git-diff] end subgraph cat_check [check] advocate[kvb-check-legal] @@ -248,6 +250,8 @@ flowchart LR clean --> sceptic clean --> refactor commit_no_verify --> advocate + commit_next --> git_diff + commit_no_verify --> git_diff advocate --> dialectic confidence --> break_risk confidence --> gate @@ -376,6 +380,8 @@ flowchart LR - Node.js (provides `npx skills`). - `jq` (only required for `make update`). +- Python `jinja2` for `make test` (pinned in `hub/requirements.txt`). +- Docker with the compose plugin (optional, runs the kvb-hub review hub). ## Installation 🚀 @@ -393,6 +399,12 @@ make project TARGET=/path/to/repo Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers five hooks: its prompt hook and a PostToolBatch hook deliver the immediate radio (`call`, `broadcast`, `security`, `pan-pan`, `mayday`) mid-turn, a Stop hook delivers the post (`post`, `newsletter` and call receipts) when the turn ends, an `asyncRewake` Stop hook wakes an idle session for the radio, and a PreToolUse hook refuses every writing action while a mayday holds, except those of its sender and of the agents that join it with `radio.py assist`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved. +The global install also runs the kvb-hub, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `hub/` to `~/.local/share/kvb-hub`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-hub`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_HUB_PORT` in `hub/.env`; the hub page is `http://localhost:/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_HUB_OPERATOR_VOICE` in `hub/.env`. + +Without reinstalling the skills, `make hub-install` installs, builds and starts only the hub, `make hub-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, disk; exit 1 on a failure), `make hub-pin CREDENTIAL=` pins the passkey that "Passkey registrieren" shows, `make hub-runner` enables the runner service, and `make hub-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier kvb-hub entry and keeping the file mode, after a copy to `settings.json.before-kvb-hub-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage). + +The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-hub/client/approval.py pin --port --approvers ~/.config/kvb-hub/approvers.json --credential ''`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-hub/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-hub-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-hub-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port --timeout --approvers ~/.config/kvb-hub/approvers.json --replay ~/.local/state/kvb-hub-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap. + Restart your agent afterwards so it loads the new skills. ## Maintenance 🔧 diff --git a/hub/.env b/hub/.env new file mode 100644 index 0000000..fb4b5a6 --- /dev/null +++ b/hub/.env @@ -0,0 +1,5 @@ +KVB_HUB_PORT=8737 +KVB_HUB_LLM_URL= +KVB_HUB_LLM_MODEL= +KVB_HUB_LLM_KEY_FILE= +KVB_HUB_OPERATOR_VOICE=en_US-libritts-high:0 diff --git a/hub/Dockerfile b/hub/Dockerfile new file mode 100644 index 0000000..3e892ed --- /dev/null +++ b/hub/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.12-slim AS whisper + +RUN apt-get update && apt-get install --yes --no-install-recommends build-essential cmake git && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 --branch v1.9.5 https://github.com/ggml-org/whisper.cpp /src \ + && test "$(cd /src && git rev-parse HEAD)" = d1be6fde11ac6e0407606b4e42fe72d34add8037 \ + && cmake -S /src -B /build -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DGGML_OPENMP=OFF -DWHISPER_BUILD_TESTS=OFF \ + && cmake --build /build --config Release --target whisper-cli --parallel \ + && install -D --mode=0755 /build/bin/whisper-cli /out/whisper-cli + +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/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir --root-user-action=ignore --requirement /app/requirements.txt + +COPY requirements-piper.txt /opt/piper/requirements.txt +RUN python3 -m venv --without-pip /opt/piper \ + && pip --python /opt/piper/bin/python install --no-cache-dir --root-user-action=ignore --require-hashes --only-binary=:all: --requirement /opt/piper/requirements.txt + +ADD --checksum=sha256:9127a559e11603f10b366d1a20ac7426826081dbc521de4c2420c57728d73f0f --chmod=0644 \ + https://huggingface.co/rhasspy/piper-voices/resolve/e7229f7d33a10e145e109b886d661984201ac3c4/en/en_US/libritts/high/en_US-libritts-high.onnx \ + /opt/voices/en_US-libritts-high.onnx +ADD --checksum=sha256:2efdc6d7f954588b8180132cbd9b8001933fdd00932c92bc92fd0d2028a9eb3d --chmod=0644 \ + https://huggingface.co/rhasspy/piper-voices/resolve/e7229f7d33a10e145e109b886d661984201ac3c4/en/en_US/libritts/high/en_US-libritts-high.onnx.json \ + /opt/voices/en_US-libritts-high.onnx.json +ADD --checksum=sha256:c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9 --chmod=0644 \ + https://huggingface.co/ggerganov/whisper.cpp/resolve/5359861c739e955e79d9a303bcbc70fb988958b1/ggml-base-q8_0.bin \ + /opt/whisper/ggml-base-q8_0.bin +COPY --from=whisper /out/whisper-cli /opt/whisper/whisper-cli + +COPY server.py /app/server.py +COPY kvb_hub /app/kvb_hub +COPY client/signing.py /app/client/signing.py +COPY templates /app/templates + +ENTRYPOINT ["python3", "/app/server.py"] diff --git a/hub/client/approval.py b/hub/client/approval.py new file mode 100755 index 0000000..41faf4f --- /dev/null +++ b/hub/client/approval.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Args: + pin --port --approvers --credential : pins the passkey + that the kvb-hub settings panel registered (the blob it shows) in + , written with mode 0600. Run it on the host, outside every + sandbox, e.g. --approvers ~/.config/kvb-hub/approvers.json. + verify --port --approvers --replay --review + --gate : run in the worktree right before the commit; + fetches the review's signature from the kvb-hub on 127.0.0.1: + and checks it against the repository, worktree, branch and the tree + git write-tree prints, as this process sees them. + +Returns: + pin: exit code 0 and the pinned credential on stdout; 1 and the reason + on stderr when the blob is no valid registration. + verify: exit code 0 when a pinned key signed this exact commit; 1 and + the reason on stderr otherwise. Commit only on 0. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +from hub_api import GUARD, SLACK, base_url, load +from passkey import attested, b64decode +from signing import ALGORITHMS, ID, Refused, origin_of, review_item +from verifier import Trust, check, pin + +GIT_TIMEOUT = 30 + + +def git(cwd: Path, args: list[str]) -> str: + try: + done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=GIT_TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refused(f"git {args[0]} did not run: {error}") from error + if done.returncode != 0: + raise Refused(f"git {' '.join(args)} failed: {done.stderr.strip()}") + return done.stdout.strip() + + +def staged(cwd: Path, review_id: str) -> dict: + repo = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) + worktree = git(cwd, ["rev-parse", "--show-toplevel"]) + branch = git(cwd, ["symbolic-ref", "--short", "HEAD"]) + return review_item(review_id, repo, worktree, branch, git(cwd, ["write-tree"])) + + +def fetch(port: int, path: str) -> dict: + request = urllib.request.Request(f"{base_url(port)}{path}", headers=GUARD) + try: + data = load(request, SLACK) + except (OSError, ValueError) as error: + raise Refused(f"the kvb-hub did not answer: {error}") from error + if not isinstance(data, dict): + raise Refused("the kvb-hub answered no object") + return data + + +def verify_commit(args: argparse.Namespace) -> str: + review = fetch(args.port, f"/api/reviews/{args.review}") + if review.get("status") != "approved": + raise Refused(f"the review is {review.get('status')}, not approved") + item = staged(Path.cwd(), args.review) + check(review.get("proof"), item, args.gate, Trust(approvers=args.approvers, replay=args.replay, origin=origin_of(args.port)), time.time()) + return f"verified: the operator signed review {args.review}, tree {item['tree']} on {item['branch']} in {item['worktree']}" + + +def pin_credential(args: argparse.Namespace) -> str: + try: + credential = json.loads(b64decode(args.credential, "credential")) + except (ValueError, RecursionError) as error: + raise Refused("the credential blob is no JSON") from error + entry = attested(credential, origin_of(args.port)) + entries = pin(args.approvers, entry, time.time()) + return f"pinned {entry['id']} ({ALGORITHMS[entry['alg']]}) in {args.approvers}; {len(entries)} key(s) pinned" + + +def review_id(text: str) -> str: + if not ID.fullmatch(text): + raise argparse.ArgumentTypeError("a review id is 16 hex digits") + return text + + +def main() -> int: + parser = argparse.ArgumentParser(prog="approval") + commands = parser.add_subparsers(dest="command", required=True) + pinning = commands.add_parser("pin") + verifying = commands.add_parser("verify") + for sub in (pinning, verifying): + sub.add_argument("--port", type=int, required=True) + sub.add_argument("--approvers", type=Path, required=True) + pinning.add_argument("--credential", required=True) + verifying.add_argument("--replay", type=Path, required=True) + verifying.add_argument("--review", type=review_id, required=True) + verifying.add_argument("--gate", choices=("pass", "fail"), required=True) + args = parser.parse_args() + try: + print(pin_credential(args) if args.command == "pin" else verify_commit(args)) + except (Refused, OSError) as error: + print(f"refused: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hub/client/hub_api.py b/hub/client/hub_api.py new file mode 100644 index 0000000..bfd3546 --- /dev/null +++ b/hub/client/hub_api.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import json +import urllib.request + +SLACK = 15.0 +GUARD = {"X-Kvb-Hub": "1"} + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect) + + +def base_url(port: int) -> str: + return f"http://127.0.0.1:{port}" + + +def load(request: urllib.request.Request, timeout: float) -> object: + with OPENER.open(request, timeout=timeout) as response: + return json.loads(response.read()) + + +def call(request: urllib.request.Request, timeout: float) -> dict: + data = load(request, timeout) + if not isinstance(data, dict): + raise TypeError("the hub answered no object") + return data diff --git a/hub/client/passkey.py b/hub/client/passkey.py new file mode 100644 index 0000000..c3dde44 --- /dev/null +++ b/hub/client/passkey.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import subprocess +import tempfile +from pathlib import Path + +from signing import RP_ID, Refused, b64encode, challenge + +try: + from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa +except ImportError: + serialization = None + +FLAG_UP = 0x01 +FLAG_AT = 0x40 +EC_PREFIX = bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200") +ED_PREFIX = bytes.fromhex("302a300506032b6570032100") +RSA_IDENTIFIER = bytes.fromhex("300d06092a864886f70d0101010500") +B64URL = re.compile(r"[A-Za-z0-9_-]+") +ASSERTION_KEYS = ("id", "clientDataJSON", "authenticatorData", "signature") +CBOR_DEPTH = 16 +TIMEOUT = 30 + + +def b64decode(text: object, name: str) -> bytes: + if not isinstance(text, str) or not B64URL.fullmatch(text): + raise Refused(f"{name} is no base64url text") + try: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + except ValueError as error: + raise Refused(f"{name} is no base64url text") from error + + +def cbor(data: bytes, offset: int, depth: int) -> tuple[object, int]: + if depth > CBOR_DEPTH or offset >= len(data): + raise Refused("the CBOR data is truncated or nests too deep") + major, info = data[offset] >> 5, data[offset] & 0x1F + offset += 1 + value = info + if 24 <= info <= 27: + size = 1 << (info - 24) + if offset + size > len(data): + raise Refused("the CBOR data is truncated") + value = int.from_bytes(data[offset : offset + size], "big") + offset += size + elif info > 27: + raise Refused("the CBOR data uses an indefinite or reserved length") + if major == 0: + return value, offset + if major == 1: + return -1 - value, offset + if major in (2, 3): + chunk = data[offset : offset + value] + if len(chunk) != value: + raise Refused("the CBOR data is truncated") + if major == 2: + return chunk, offset + value + try: + return chunk.decode("utf-8"), offset + value + except UnicodeDecodeError as error: + raise Refused("the CBOR text is no UTF-8") from error + if major == 4: + items = [] + for _ in range(value): + item, offset = cbor(data, offset, depth + 1) + items.append(item) + return items, offset + if major == 5: + entries: dict = {} + for _ in range(value): + key, offset = cbor(data, offset, depth + 1) + if isinstance(key, bool) or not isinstance(key, int | str) or key in entries: + raise Refused("the CBOR map holds a bad or repeated key") + entries[key], offset = cbor(data, offset, depth + 1) + return entries, offset + if major == 7 and info in (20, 21, 22): + return {20: False, 21: True, 22: None}[info], offset + raise Refused("the CBOR data holds an unsupported item") + + +def der(tag: int, body: bytes) -> bytes: + size = len(body) + if size < 0x80: + return bytes([tag, size]) + body + octets = size.to_bytes((size.bit_length() + 7) // 8, "big") + return bytes([tag, 0x80 | len(octets)]) + octets + body + + +def der_integer(value: bytes) -> bytes: + digits = value.lstrip(b"\x00") or b"\x00" + return der(0x02, b"\x00" + digits if digits[0] & 0x80 else digits) + + +def octets(value: object, size: int) -> bool: + return isinstance(value, bytes) and len(value) == size + + +def cose_key(key: object) -> tuple[int, bytes]: + if not isinstance(key, dict): + raise Refused("the credential public key is no COSE map") + kty, alg = key.get(1), key.get(3) + if kty == 2 and alg == -7 and key.get(-1) == 1 and octets(key.get(-2), 32) and octets(key.get(-3), 32): + return alg, EC_PREFIX + b"\x04" + key[-2] + key[-3] + if kty == 1 and alg == -8 and key.get(-1) == 6 and octets(key.get(-2), 32): + return alg, ED_PREFIX + key[-2] + if kty == 3 and alg == -257 and isinstance(key.get(-1), bytes) and isinstance(key.get(-2), bytes) and key[-1] and key[-2]: + public = der(0x30, der_integer(key[-1]) + der_integer(key[-2])) + return alg, der(0x30, RSA_IDENTIFIER + der(0x03, b"\x00" + public)) + raise Refused("the key is none of ES256 on P-256, EdDSA on Ed25519 or RS256") + + +def client_data(raw: bytes, kind: str, origin: str) -> dict: + try: + client = json.loads(raw) + except (ValueError, RecursionError) as error: + raise Refused("clientDataJSON is no JSON") from error + if not isinstance(client, dict) or client.get("type") != kind: + raise Refused(f"clientDataJSON is no {kind}") + if client.get("origin") != origin or client.get("crossOrigin") is True: + raise Refused(f"the origin is not {origin}") + return client + + +def rp_flags(data: bytes, size: int) -> int: + if len(data) < size or data[:32] != hashlib.sha256(RP_ID.encode("ascii")).digest(): + raise Refused(f"the rpIdHash is not the one of {RP_ID}") + if not data[32] & FLAG_UP: + raise Refused("the user presence flag is missing") + return data[32] + + +def attested(credential: object, origin: str) -> dict: + """Args: + credential: {"id", "attestationObject", "clientDataJSON"} as the + settings panel encodes the navigator.credentials.create answer. + origin: the only origin the registration may come from. + + Returns: + The entry to pin: {"id", "alg", "spki"}, the key as base64url DER. + """ + if not isinstance(credential, dict) or set(credential) != {"id", "attestationObject", "clientDataJSON"}: + raise Refused("the credential needs exactly id, attestationObject and clientDataJSON") + raw_id = b64decode(credential["id"], "id") + client_data(b64decode(credential["clientDataJSON"], "clientDataJSON"), "webauthn.create", origin) + blob = b64decode(credential["attestationObject"], "attestationObject") + attestation, end = cbor(blob, 0, 0) + if end != len(blob) or not isinstance(attestation, dict) or not isinstance(attestation.get("authData"), bytes): + raise Refused("the attestationObject holds no authData") + data = attestation["authData"] + if not rp_flags(data, 55) & FLAG_AT: + raise Refused("the registration carries no attested credential") + size = int.from_bytes(data[53:55], "big") + if data[55 : 55 + size] != raw_id: + raise Refused("the attested credential id differs from the id") + key, _ = cbor(data, 55 + size, 0) + alg, spki = cose_key(key) + return {"id": b64encode(raw_id), "alg": alg, "spki": b64encode(spki)} + + +def by_library(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool: + try: + key = serialization.load_der_public_key(spki) + if alg == -7 and isinstance(key, ec.EllipticCurvePublicKey) and isinstance(key.curve, ec.SECP256R1): + key.verify(signature, data, ec.ECDSA(hashes.SHA256())) + elif alg == -8 and isinstance(key, ed25519.Ed25519PublicKey): + key.verify(signature, data) + elif alg == -257 and isinstance(key, rsa.RSAPublicKey): + key.verify(signature, data, padding.PKCS1v15(), hashes.SHA256()) + else: + return False + except (InvalidSignature, UnsupportedAlgorithm, ValueError, TypeError): + return False + return True + + +def key_type(alg: int, spki: bytes) -> bool: + if alg == -7: + return len(spki) == len(EC_PREFIX) + 65 and spki.startswith(EC_PREFIX) + if alg == -8: + return len(spki) == len(ED_PREFIX) + 32 and spki.startswith(ED_PREFIX) + return alg == -257 and RSA_IDENTIFIER in spki[:24] + + +def by_openssl(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool: + if not key_type(alg, spki): + return False + with tempfile.TemporaryDirectory(prefix="kvb-hub-verify-") as folder: + key, message, sealed = (Path(folder) / name for name in ("key.pem", "data.bin", "signature.bin")) + key.write_text(f"-----BEGIN PUBLIC KEY-----\n{base64.encodebytes(spki).decode('ascii')}-----END PUBLIC KEY-----\n", encoding="ascii") + message.write_bytes(data) + sealed.write_bytes(signature) + if alg == -8: + argv = ["openssl", "pkeyutl", "-verify", "-pubin", "-inkey", str(key), "-rawin", "-in", str(message), "-sigfile", str(sealed)] + else: + argv = ["openssl", "dgst", "-sha256", "-verify", str(key), "-signature", str(sealed), str(message)] + try: + done = subprocess.run(argv, capture_output=True, timeout=TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired): + return False + return done.returncode == 0 + + +def verify_signature(alg: int, spki: bytes, data: bytes, signature: bytes) -> bool: + if serialization is None: + return by_openssl(alg, spki, data, signature) + return by_library(alg, spki, data, signature) + + +def verify_assertion(assertion: object, payload: dict, credentials: list[dict], origin: str) -> int: + """Args: + assertion: {"id", "clientDataJSON", "authenticatorData", "signature"} + from navigator.credentials.get, base64url. + payload: the payload whose challenge the operator signed. + credentials: the pinned keys. + origin: the only origin the signature may come from. + + Returns: + The authenticator's signature counter. + """ + if not isinstance(assertion, dict) or not all(isinstance(assertion.get(name), str) for name in ASSERTION_KEYS): + raise Refused("the assertion is incomplete") + credential = next((entry for entry in credentials if entry["id"] == assertion["id"]), None) + if credential is None: + raise Refused("the signing key is not pinned") + raw = b64decode(assertion["clientDataJSON"], "clientDataJSON") + if client_data(raw, "webauthn.get", origin).get("challenge") != challenge(payload): + raise Refused("the challenge is not the one of the payload") + data = b64decode(assertion["authenticatorData"], "authenticatorData") + rp_flags(data, 37) + signed = data + hashlib.sha256(raw).digest() + if not verify_signature(credential["alg"], b64decode(credential["spki"], "spki"), signed, b64decode(assertion["signature"], "signature")): + raise Refused("the signature does not verify with the pinned key") + return int.from_bytes(data[33:37], "big") diff --git a/hub/client/permission_hook.py b/hub/client/permission_hook.py new file mode 100755 index 0000000..7f983c1 --- /dev/null +++ b/hub/client/permission_hook.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Args: + --port: port of the kvb-hub on 127.0.0.1; the signature's origin is + http://localhost:. + --timeout: seconds to wait for the operator's decision; keep it below the + hook's timeout in settings.json. + --approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.json. + --replay: this hook's replay store, outside the hub's reach, e.g. + ~/.local/state/kvb-hub-approvals/hook.json. + stdin: the Claude Code PermissionRequest hook input. + +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 kvb-hub. Nothing on timeout, + error, a missing or invalid signature or a hub that is down, so + Claude Code shows its own dialog. The exit code is 0. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +import urllib.request +from pathlib import Path + +import hub_api +import signing +import verifier + +POLL = 25.0 +NAME_LIMIT = 200 +COMMAND_LIMIT = 4000 +PATH_LIMIT = 4096 +DENIED = "The operator denied this in the kvb-hub." + + +def summary(tool_input: object) -> str: + if isinstance(tool_input, dict): + for key in ("command", "file_path", "url", "pattern", "path"): + if isinstance(tool_input.get(key), str): + return tool_input[key] + return json.dumps(tool_input, ensure_ascii=False) + + +def facts(hook: dict) -> dict: + return { + "tool": str(hook.get("tool_name") or "?"), + "command": summary(hook.get("tool_input")), + "cwd": str(hook.get("cwd") or ""), + "session": str(hook.get("session_id") or ""), + } + + +def create(base: str, hook: dict, ask: dict, timeout: float) -> str: + payload = { + "tool": ask["tool"][:NAME_LIMIT], + "command": ask["command"][:COMMAND_LIMIT], + "cwd": ask["cwd"][:PATH_LIMIT], + "session": ask["session"][:NAME_LIMIT], + "agent": str(hook.get("agent_type") or "")[:NAME_LIMIT], + "timeout": timeout, + } + headers = {"Content-Type": "application/json", **hub_api.GUARD} + request = urllib.request.Request(f"{base}/api/asks", data=json.dumps(payload).encode(), headers=headers, method="POST") + ask_id = hub_api.call(request, hub_api.SLACK)["id"] + if not isinstance(ask_id, str) or not signing.ID.fullmatch(ask_id): + raise ValueError("the hub answered no ask id") + return ask_id + + +def status(base: str, ask_id: str, wait: float) -> dict: + answer = hub_api.call(urllib.request.Request(f"{base}/api/asks/{ask_id}?wait={wait:.1f}"), wait + hub_api.SLACK) + if not isinstance(answer.get("status"), str): + raise TypeError("the hub answered no status") + return answer + + +def decision(answer: dict, item: dict, trust: verifier.Trust) -> dict | None: + if answer["status"] == "denied": + return {"behavior": "deny", "message": DENIED} + if answer["status"] != "allowed": + return None + try: + verifier.check(answer.get("proof"), item, "", trust, time.time()) + except (signing.Refused, OSError) as error: + print(f"kvb-hub: not signed, Claude Code asks instead: {error}", file=sys.stderr) + return None + return {"behavior": "allow"} + + +def main() -> int: + parser = argparse.ArgumentParser(prog="permission_hook") + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--timeout", type=float, required=True) + parser.add_argument("--approvers", type=Path, required=True) + parser.add_argument("--replay", type=Path, required=True) + args = parser.parse_args() + base = hub_api.base_url(args.port) + trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port)) + deadline = time.monotonic() + args.timeout + try: + hook = json.load(sys.stdin) + ask = facts(hook) + ask_id = create(base, hook, ask, args.timeout) + answer = {"status": "pending"} + while answer["status"] == "pending" and (left := deadline - time.monotonic()) > 0: + answer = status(base, ask_id, min(left, POLL)) + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return 0 + chosen = decision(answer, signing.ask_item(ask_id, ask["tool"], ask["command"], ask["cwd"], ask["session"]), trust) + if chosen is not None: + print(json.dumps({"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": chosen}})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hub/client/runner.py b/hub/client/runner.py new file mode 100755 index 0000000..ccf5e79 --- /dev/null +++ b/hub/client/runner.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Args: + --port: port of the kvb-hub on 127.0.0.1; the signature's origin is + http://localhost:. + --interval: seconds between two polls of the hub. + --approvers: the pinned passkeys, e.g. ~/.config/kvb-hub/approvers.json. + --replay: the runner's replay store, outside the hub's reach, e.g. + ~/.local/state/kvb-hub-approvals/runner.json. + --once: handle the queued jobs once and exit. + +Returns: + Runs as the operator, outside any sandbox, every operator command a + pinned passkey signed in the kvb-hub: exactly the signed text, with bash + in the signed directory, each signature once and before its expiry. + Refuses every other job. Posts the last 200 output lines back while it + runs and the exit code at its end. The exit code is 0. +""" + +from __future__ import annotations + +import argparse +import collections +import json +import subprocess +import threading +import time +import urllib.error +import urllib.request +from collections.abc import Callable +from pathlib import Path + +import hub_api +import signing +import verifier + +LINES = 200 +BEAT = 2.0 +REFUSED = "refused: no valid operator signature for this command" + + +def call(base: str, path: str, payload: dict | None) -> dict: + headers = dict(hub_api.GUARD) + data = None + if payload is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(payload).encode() + request = urllib.request.Request(f"{base}{path}", data=data, headers=headers, method="GET" if payload is None else "POST") + return hub_api.call(request, hub_api.SLACK) + + +def collect(stream: object, tail: collections.deque, lock: threading.Lock) -> None: + for line in stream: + with lock: + tail.append(line) + + +def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None: + """Args: + job: the claimed job with task, command, directory and proof. + trust: the pinned keys, the runner's replay store and the origin. + now: the current time in epoch seconds. + + Returns: + Why the job must not run, or None when a pinned key signed it. + """ + try: + verifier.check(job.get("proof"), signing.operator_item(job["task"], job["command"], job["directory"]), "", trust, now) + except (signing.Refused, OSError) as error: + return f"{REFUSED}: {error}" + return None + + +def execute(job: dict, report: Callable[[dict], None]) -> int: + """Args: + job: the claimed and verified job with command and directory. + report: called with {"output", "code", "refused"}; code None while + the command runs. + + Returns: + The exit code. + """ + tail: collections.deque = collections.deque(maxlen=LINES) + lock = threading.Lock() + try: + process = subprocess.Popen( + ["bash", "-c", job["command"]], + cwd=job["directory"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + errors="replace", + start_new_session=True, + ) + except OSError as error: + report({"output": f"could not start: {error}", "code": 127, "refused": False}) + return 127 + reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True) + reader.start() + while True: + try: + code = process.wait(BEAT) + break + except subprocess.TimeoutExpired: + with lock: + output = "".join(tail) + report({"output": output, "code": None, "refused": False}) + reader.join() + report({"output": "".join(tail), "code": code, "refused": False}) + return code + + +def sender(base: str, job_id: str) -> Callable[[dict], None]: + def send(result: dict) -> None: + try: + call(base, f"/api/runner/{job_id}/report", result) + except (OSError, ValueError, TypeError): + return + + return send + + +def cycle(base: str, trust: verifier.Trust) -> None: + for job in call(base, "/api/runner/jobs", None)["jobs"]: + try: + claimed = call(base, f"/api/runner/{job['id']}/claim", {}) + except urllib.error.HTTPError: + continue + send = sender(base, claimed["id"]) + reason = refusal(claimed, trust, time.time()) + if reason is None: + execute(claimed, send) + else: + send({"output": reason, "code": None, "refused": True}) + + +def main() -> int: + parser = argparse.ArgumentParser(prog="runner") + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--interval", type=float, required=True) + parser.add_argument("--approvers", type=Path, required=True) + parser.add_argument("--replay", type=Path, required=True) + parser.add_argument("--once", action="store_true") + args = parser.parse_args() + base = hub_api.base_url(args.port) + trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port)) + while True: + try: + cycle(base, trust) + except (OSError, ValueError, KeyError, TypeError): + pass + if args.once: + return 0 + time.sleep(args.interval) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hub/client/signing.py b/hub/client/signing.py new file mode 100644 index 0000000..3f7809f --- /dev/null +++ b/hub/client/signing.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import base64 +import fnmatch +import hashlib +import json +import re +from pathlib import Path + +RP_ID = "localhost" +VERSION = 1 +LIFETIME = 900 +RULE_LIFETIME = 366 * 86400 +ALGORITHMS = {-7: "ES256", -8: "EdDSA", -257: "RS256"} +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]") +APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"} +RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"} +WARNINGS = ( + ("löscht rekursiv", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")), + ("erzwingt", re.compile(r"--force\b|\bpush\b.*\s-f\b")), + ("pusht", re.compile(r"\bgit\b.*\bpush\b")), + ("root", re.compile(r"\bsudo\b|\bdoas\b")), + ("Pipe in eine Shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")), + ("verwirft Änderungen", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")), + ("Docker löscht", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")), +) + + +class Refused(Exception): + pass + + +def origin_of(port: int) -> str: + return f"http://{RP_ID}:{port}" + + +def b64encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def canonical(value: object) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False).encode("ascii") + + +def challenge(payload: dict) -> str: + return b64encode(hashlib.sha256(canonical(payload)).digest()) + + +def item_hash(item: dict) -> str: + return hashlib.sha256(canonical(item)).hexdigest() + + +def ask_item(ask_id: str, tool: str, command: str, cwd: str, session: str) -> dict: + return {"kind": "ask", "id": ask_id, "tool": tool, "command": command, "cwd": cwd, "session": session} + + +def review_item(review_id: str, repo: str, worktree: str, branch: str, tree: str) -> dict: + return {"kind": "review", "id": review_id, "repo": repo, "worktree": worktree, "branch": branch, "tree": tree} + + +def operator_item(task: str, command: str, directory: str) -> dict: + return {"kind": "operator", "id": task, "command": command, "directory": directory} + + +def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict: + return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires} + + +def rule_payload(rule_id: str, kinds: list[str], tool: str, pattern: str, scope: str, expires: int) -> dict: + return {"v": VERSION, "kind": "rule", "id": rule_id, "kinds": kinds, "tool": tool, "pattern": pattern, "scope": scope, "expires": expires} + + +def warnings(command: str) -> list[str]: + return [label for label, pattern in WARNINGS if pattern.search(command)] + + +def expiry(value: object, now: float, cap: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise Refused("the expiry is no whole number of epoch seconds") + if value <= now: + raise Refused("the signature has expired") + if value > now + cap: + raise Refused("the expiry lies too far ahead") + return value + + +def checked_approval(payload: object, now: float) -> dict: + if not isinstance(payload, dict) or set(payload) != APPROVAL_KEYS or payload["v"] != VERSION or payload["kind"] != "approve": + raise Refused("the payload is no approval") + items = payload["items"] + if not isinstance(items, list) or not items or not all(isinstance(entry, str) and HASH.fullmatch(entry) for entry in items): + raise Refused("the approval lists no item hashes") + if not isinstance(payload["nonce"], str) or not NONCE.fullmatch(payload["nonce"]): + raise Refused("the approval holds no nonce") + expiry(payload["expires"], now, LIFETIME) + return payload + + +def checked_rule(payload: object, now: float) -> dict: + if not isinstance(payload, dict) or set(payload) != RULE_KEYS or payload["v"] != VERSION or payload["kind"] != "rule": + raise Refused("the payload is no rule") + if not isinstance(payload["id"], str) or not ID.fullmatch(payload["id"]): + raise Refused("the rule holds no id") + kinds = payload["kinds"] + if not isinstance(kinds, list) or not kinds or not all(isinstance(kind, str) for kind in kinds): + raise Refused("the rule names no kinds") + if not isinstance(payload["tool"], str) or not all(isinstance(payload[name], str) and payload[name] for name in ("pattern", "scope")): + raise Refused("the rule needs a tool text, a pattern and a scope") + expiry(payload["expires"], now, RULE_LIFETIME) + return payload + + +def rule_allows(rule: dict, item: dict, gate: str, now: float) -> bool: + if rule["expires"] <= now or item["kind"] not in rule["kinds"]: + return False + if item["kind"] == "ask": + named = fnmatch.fnmatchcase(item["tool"], rule["tool"]) and fnmatch.fnmatchcase(item["cwd"], rule["scope"]) + plain = not CHAINED.search(item["command"]) and not warnings(item["command"]) + return named and plain and fnmatch.fnmatchcase(item["command"], rule["pattern"]) + if item["kind"] == "review": + return gate == "pass" and fnmatch.fnmatchcase(item["branch"], rule["pattern"]) and fnmatch.fnmatchcase(item["worktree"], rule["scope"]) + return False + + +def load_pinned(path: Path) -> list[dict]: + try: + mode = path.stat().st_mode + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + except (OSError, ValueError, RecursionError) as error: + raise Refused(f"the pinned keys in {path} are unreadable") from error + if mode & 0o022: + raise Refused(f"{path} is writable by others") + entries = data.get("credentials") if isinstance(data, dict) else None + shaped = isinstance(entries, list) and all(isinstance(entry, dict) and isinstance(entry.get("id"), str) and entry.get("alg") in ALGORITHMS and isinstance(entry.get("spki"), str) for entry in entries) + if not shaped: + raise Refused(f"the pinned keys in {path} are malformed") + return entries diff --git a/hub/client/verifier.py b/hub/client/verifier.py new file mode 100644 index 0000000..1bd9dc1 --- /dev/null +++ b/hub/client/verifier.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import datetime +import fcntl +import json +import os +import tempfile +from pathlib import Path +from typing import NamedTuple + +from passkey import verify_assertion +from signing import ( + Refused, + checked_approval, + checked_rule, + item_hash, + load_pinned, + rule_allows, +) + + +class Trust(NamedTuple): + approvers: Path + replay: Path + origin: str + + +def pinned(path: Path) -> list[dict]: + entries = load_pinned(path) + if not entries: + raise Refused("no key is pinned") + return entries + + +def write_private(path: Path, data: dict) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-") + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=1, sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(name, path) + except OSError: + Path(name).unlink(missing_ok=True) + raise + + +def pin(path: Path, entry: dict, now: float) -> list[dict]: + stamp = datetime.datetime.fromtimestamp(now, datetime.UTC).isoformat(timespec="seconds") + entries = [old for old in load_pinned(path) if old["id"] != entry["id"]] + [{**entry, "pinned": stamp}] + write_private(path, {"credentials": entries}) + return entries + + +def read_store(store: Path) -> dict: + try: + data = json.loads(store.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"used": {}, "counts": {}} + except (OSError, ValueError, RecursionError) as error: + raise Refused(f"the replay store {store} is unreadable") from error + counts = data.get("counts") if isinstance(data, dict) else None + shaped = isinstance(counts, dict) and all(isinstance(last, dict) and isinstance(last.get("count"), int) and isinstance(last.get("nonce"), str) for last in counts.values()) + if not shaped or not isinstance(data.get("used"), dict): + raise Refused(f"the replay store {store} is malformed") + return data + + +def consume(store: Path, key: str, expires: int, credential: str, count: int, nonce: str, now: float) -> None: + """Args: + store: the executor's own replay store, outside the hub's reach. + key: the one-time id, nonce and item hash. + expires: until when the id must stay known. + credential: the id of the key that signed. + count: its signature counter; 0 when the authenticator keeps none. + nonce: the payload nonce; one bulk signature carries one counter. + now: the current time in epoch seconds. + """ + store.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor = os.open(store.with_name(f"{store.name}.lock"), os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(descriptor, fcntl.LOCK_EX) + state = read_store(store) + used = {name: until for name, until in state["used"].items() if isinstance(until, int | float) and until > now} + if key in used: + raise Refused("this signature was already used for this item") + last = state["counts"].get(credential) + if last is not None and not (count > last["count"] or (count == last["count"] and (count == 0 or nonce == last["nonce"]))): + raise Refused("the signature counter did not grow: a cloned authenticator or a replay") + write_private(store, {"used": {**used, key: expires}, "counts": {**state["counts"], credential: {"count": count, "nonce": nonce}}}) + finally: + os.close(descriptor) + + +def check(proof: object, item: dict, gate: str, trust: Trust, now: float) -> None: + """Args: + proof: {"payload", "assertion"} the kvb-hub relayed for the item. + item: the executor's own item, built from what it is about to do. + gate: pass or fail as the executor itself saw it; a rule approves a + review on pass only. + trust: the pinned keys, the executor's replay store and the origin. + now: the current time in epoch seconds. + + Raises: + Refused with the reason, unless a pinned key signed an unexpired + approval that lists the item and is used here for the first time, or + an unexpired rule whose pattern the item matches. + """ + if not isinstance(proof, dict) or set(proof) != {"payload", "assertion"}: + raise Refused("no signature") + payload = proof["payload"] + if isinstance(payload, dict) and payload.get("kind") == "rule": + checked_rule(payload, now) + verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin) + if not rule_allows(payload, item, gate, now): + raise Refused("the signed rule does not match this item") + return + checked_approval(payload, now) + count = verify_assertion(proof["assertion"], payload, pinned(trust.approvers), trust.origin) + digest = item_hash(item) + if digest not in payload["items"]: + raise Refused("the signature does not list this item") + consume(trust.replay, f"{payload['nonce']}:{digest}", payload["expires"], proof["assertion"]["id"], count, payload["nonce"], now) diff --git a/hub/compose.yml b/hub/compose.yml new file mode 100644 index 0000000..d14f1bd --- /dev/null +++ b/hub/compose.yml @@ -0,0 +1,132 @@ +name: kvb-hub + +services: + hub: + build: . + image: kvb-hub:local + restart: unless-stopped + init: true + user: "${KVB_HUB_USER:?written into .env by scripts/install.sh}" + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + 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 + ports: + - "127.0.0.1:${KVB_HUB_PORT:?set in .env}:8080" + extra_hosts: + - "host.docker.internal:host-gateway" + command: + - --bind + - 0.0.0.0 + - --listen-port + - "8080" + - --public-port + - "${KVB_HUB_PORT:?set in .env}" + - --council + - /tmp/agents-council + - --pages + - /tmp/kvb-git-diff + - --state + - /state + - --host-state + - "${KVB_HUB_STATE:?written into .env by scripts/install.sh}" + - --skills + - /skills + - --transcripts + - /projects + - --templates + - /app/templates + - --repos + - "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}" + - --worktrees + - "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}" + - --timers + - /timers + - --llm-url + - "${KVB_HUB_LLM_URL}" + - --llm-model + - "${KVB_HUB_LLM_MODEL}" + - --llm-key-file + - /run/kvb-hub/llm.key + - --piper + - /opt/piper/bin/piper + - --voices + - /opt/voices + - --operator-voice + - "${KVB_HUB_OPERATOR_VOICE:?set in .env}" + - --whisper + - /opt/whisper/whisper-cli + - --whisper-model + - /opt/whisper/ggml-base-q8_0.bin + - --scratch + - /scratch + - --approvers + - /approvers/approvers.json + tmpfs: + - /scratch:size=64m,mode=1777,noexec,nosuid,nodev + volumes: + - type: bind + source: /tmp + target: /tmp + read_only: true + - type: bind + source: "${KVB_HUB_COUNCIL:?written into .env by scripts/install.sh}" + target: /tmp/agents-council + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_SKILLS:?written into .env by scripts/install.sh}" + target: /skills + read_only: true + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_PROJECTS:?written into .env by scripts/install.sh}" + target: /projects + read_only: true + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_STATE:?written into .env by scripts/install.sh}" + target: /state + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}" + target: "${KVB_HUB_REPOS:?written into .env by scripts/install.sh}" + read_only: true + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}" + target: "${KVB_HUB_WORKTREES:?written into .env by scripts/install.sh}" + read_only: true + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_TIMERS:?written into .env by scripts/install.sh}" + target: /timers + read_only: true + bind: + create_host_path: false + - type: bind + source: "${KVB_HUB_APPROVERS:?written into .env by scripts/install.sh}" + target: /approvers + read_only: true + bind: + create_host_path: false + - type: bind + # /dev/null is an empty key file, which server.py --llm-key-file reads as no key + source: "${KVB_HUB_LLM_KEY_FILE:-/dev/null}" + target: /run/kvb-hub/llm.key + read_only: true + bind: + create_host_path: false diff --git a/hub/kvb_hub/__init__.py b/hub/kvb_hub/__init__.py new file mode 100644 index 0000000..77f5e1f --- /dev/null +++ b/hub/kvb_hub/__init__.py @@ -0,0 +1 @@ +NAME = "kvb-hub" diff --git a/hub/kvb_hub/approving.py b/hub/kvb_hub/approving.py new file mode 100644 index 0000000..0065d02 --- /dev/null +++ b/hub/kvb_hub/approving.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import secrets +import time + +from .asking import ask_state +from .errors import Conflict, Missing, ToolError +from .fields import decode, expect, field +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 + +BATCH_LIMIT = 100 +INBOX_KINDS = ("review", "ask", "decision", "operator") +VERBS = ("approve", "reject") + + +class Approving: + def keys(self) -> list[dict]: + try: + entries = signing.load_pinned(self.approvers) + except signing.Refused: + return [] + return [{"id": entry["id"], "alg": signing.ALGORITHMS[entry["alg"]]} for entry in entries] + + def covered(self, proof: object, signed: list[dict], now: float) -> dict | None: + """Args: + proof: {"payload", "assertion"} from the page, or None. + signed: the executor items the approval must list. + now: the current time in epoch seconds. + + Returns: + The proof to relay, None when nothing needs a signature. The hub + checks the shape only; the executors verify the signature. + """ + if not signed: + if proof is not None: + raise ToolError("proof must be null when no item needs a signature") + return None + if not isinstance(proof, dict): + raise ToolError("nicht signiert: an approval of an ask, review or operator command needs the passkey signature") + expect(proof, ("payload", "assertion")) + try: + payload = signing.checked_approval(proof["payload"], now) + except signing.Refused as error: + 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") + return {"payload": payload, "assertion": proof["assertion"]} + + def automate(self, kind_name: str, record: dict, subject: dict) -> None: + now = time.time() + 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} + 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"]}}) + + def apply(self, batch: dict) -> None: + for item in batch["items"]: + try: + outcome = self.settle_item(item, batch["proof"] if item["signed"] is not None else None) + except (OSError, ToolError) as error: + outcome = f"fehlgeschlagen: {error}" + verb = "Freigabe" if item["verb"] == "approve" else "Ablehnung" + label = f" → {item['label']}" if item["label"] else "" + ref = {"kind": item["kind"], "id": item["id"], "verb": item["verb"], "source": batch["source"], "outcome": outcome} + self.log.add({"kind": "approval", "title": f"{verb} {item['kind']}: {item['title']}{label}", "text": f"{outcome} ({batch['source']})", "ref": ref}) + + def settle_item(self, item: dict, proof: dict | None) -> str: + approve = item["verb"] == "approve" + if item["kind"] == "operator": + return self.queue_job(item, proof) + if item["kind"] == "ask": + record = self.asks.get(item["id"]) + if record is None or ask_state(record, time.time())["status"] == "expired": + return "abgelaufen" + record, changed = self.asks.decide(item["id"], {"status": "allowed" if approve else "denied", "proof": proof}) + elif item["kind"] == "review": + record, changed = self.reviews.decide(item["id"], {"status": "approved" if approve else "rejected", "proof": proof}) + else: + record, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"]}) + if record is None: + return "unbekannt" + return record["status"] if changed else f"schon {record['status']}" + + 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"] + 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 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)} + item_id = field(raw, "id") + record = {"review": self.reviews, "ask": self.asks, "decision": self.decisions}[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"] + if status != "pending": + raise Conflict({"id": item_id, "status": status}) + label = "" + if kind_name == "decision": + 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"] + 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} + + def checked(self, raw: object) -> list[dict]: + if not isinstance(raw, list) or not 1 <= len(raw) <= BATCH_LIMIT: + raise ToolError(f"items must be a list of 1 to {BATCH_LIMIT} objects") + tasks = read_register(self.council / REGISTER) + items = [self.item_of(entry, tasks) for entry in raw] + keys = [(item["kind"], item["id"]) for item in items] + if len(set(keys)) != len(keys): + raise ToolError("items must be distinct") + if len(items) > 1 and any(item["kind"] == "review" and item["verb"] == "approve" and item["gate"] != "pass" for item in items): + raise ToolError("a review whose gate is not green is approved only on its own") + if any(self.batches.holds(kind_name, item_id) for kind_name, item_id in keys): + raise Conflict({"status": "held"}) + return items + + def sign(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("items",)) + hashes = [signing.item_hash(item["signed"]) for item in self.checked(payload["items"]) if item["signed"] is not None] + credentials = [key["id"] for key in self.keys()] + if not hashes: + return {"payload": None, "challenge": None, "credentials": credentials} + request = signing.approval_payload(hashes, secrets.token_hex(16), int(time.time()) + signing.LIFETIME) + return {"payload": request, "challenge": signing.challenge(request), "credentials": credentials} + + def approve(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("items", "proof")) + items = self.checked(payload["items"]) + proof = self.covered(payload["proof"], [item["signed"] for item in items if item["signed"] is not None], time.time()) + return self.batches.add(items, "operator", proof, self.apply) + + def undo(self, batch_id: str) -> dict: + batch = self.batches.undo(batch_id) + titles = ", ".join(item["title"][:80] for item in batch["items"]) + self.log.add({"kind": "approval", "title": f"Rückgängig: {titles}", "text": batch["source"], "ref": {"batch": batch_id}}) + return {"id": batch_id, "status": "undone"} diff --git a/hub/kvb_hub/asking.py b/hub/kvb_hub/asking.py new file mode 100644 index 0000000..86d7643 --- /dev/null +++ b/hub/kvb_hub/asking.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import time + +from .errors import Conflict, Missing, ToolError +from .fields import ( + COMMAND_LIMIT, + PATH_LIMIT, + WAIT_CAP, + bounded, + decode, + expect, + field, + instant, + message_text, + seconds, +) +from .signed import ask_signed + +ASK_LIFETIME = 3600 +DESCRIPTION_LIMIT = 1000 +OPTION_LIMIT = 12 +ASK_TEXTS = {"command": COMMAND_LIMIT, "cwd": PATH_LIMIT, "session": 200, "agent": 200} +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")} + + +def ask_row(ask: dict, names: dict[str, str]) -> dict: + keys = ("id", "tool", "command", "cwd", "session", "created_ts", "expires") + return {**{key: ask[key] for key in keys}, "agent": names.get(ask["session"], ""), "subagent": ask["agent"]} + + +def decision_state(decision: dict) -> dict: + return {key: decision[key] for key in ("id", "status", "choice", "decided")} + + +def decision_row(decision: dict) -> dict: + return {key: decision[key] for key in ("id", "question", "options", "agent", "created_ts")} + + +def option_of(option: object) -> dict: + if not isinstance(option, dict): + raise ToolError("every option must be an object with label and description") + expect(option, ("label", "description")) + label = field(option, "label").strip() + return {"label": label, "description": bounded(option, "description", DESCRIPTION_LIMIT).strip()} + + +class Asking: + def ask(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("tool", *ASK_TEXTS, "timeout")) + values = {name: bounded(payload, name, limit) for name, limit in ASK_TEXTS.items()} + lifetime = seconds(payload["timeout"], ASK_LIFETIME) + 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": ""}) + return {"id": record["id"], "status": record["status"]} + + def ask_wait(self, ask_id: str, query: dict[str, list[str]]) -> dict: + wait = instant(query, "wait") + record = self.asks.get(ask_id) + if record is None: + raise Missing(ask_id) + left = max(record["expires"] - time.time(), 0.0) + record = self.asks.wait(ask_id, min(wait or 0.0, WAIT_CAP, left)) + return ask_state(record, time.time()) + + def settle(self, kind_name: str, item_id: str, verb: str, body: bytes) -> dict: + if kind_name == "asks" and verb in ANSWERS: + record = self.asks.get(item_id) + if record is None: + raise Missing(item_id) + if ask_state(record, time.time())["status"] == "expired": + raise Conflict(ask_state(record, time.time())) + proof = None + if verb == "allow": + payload = decode(body) + expect(payload, ("proof",)) + proof = self.covered(payload["proof"], [ask_signed(record)], time.time()) + decided, changed = self.asks.decide(item_id, {"status": ANSWERS[verb], "proof": proof}) + if not changed: + raise Conflict(ask_state(decided, time.time())) + return ask_state(decided, time.time()) + if kind_name != "decisions" or verb != "choose": + raise Missing(verb) + payload = decode(body) + expect(payload, ("label",)) + label = field(payload, "label") + record = self.decisions.get(item_id) + if record is None: + raise Missing(item_id) + if label not in [option["label"] for option in record["options"]]: + raise ToolError(f"label must be one of the options of decision {item_id}") + decided, changed = self.decisions.decide(item_id, {"status": "decided", "choice": label}) + if not changed: + raise Conflict(decision_state(decided)) + return decision_state(decided) + + def decision_request(self, arguments: dict) -> dict: + expect(arguments, ("question", "options", "agent", "recommended")) + question = message_text(arguments, "question") + options = arguments["options"] + if not isinstance(options, list) or not 1 <= len(options) <= OPTION_LIMIT: + raise ToolError(f"options must be a list of 1 to {OPTION_LIMIT} objects") + parsed = [option_of(option) for option in options] + if len({option["label"] for option in parsed}) != len(parsed): + raise ToolError("option labels must be distinct") + recommended = arguments["recommended"] + if not isinstance(recommended, str) or recommended.strip() not in ["", *(option["label"] for option in parsed)]: + 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"]}) + return {"id": record["id"], "status": record["status"], "url": self.url()} + + def decision_wait(self, arguments: dict) -> dict: + expect(arguments, ("id", "timeout_seconds")) + decision_id = field(arguments, "id") + record = self.decisions.wait(decision_id, seconds(arguments["timeout_seconds"], WAIT_CAP)) + if record is None: + raise ToolError(f"no decision {decision_id}") + return decision_state(record) diff --git a/hub/kvb_hub/batches.py b/hub/kvb_hub/batches.py new file mode 100644 index 0000000..35649d9 --- /dev/null +++ b/hub/kvb_hub/batches.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import secrets +import threading +import time +from collections.abc import Callable + +from .errors import Conflict, Missing + +UNDO = 3.0 + + +class Pending: + def __init__(self) -> None: + self.lock = threading.Lock() + self.batches: dict[str, dict] = {} + self.applied: set[str] = set() + + def add(self, items: list[dict], source: str, proof: dict | None, apply: Callable[[dict], None]) -> dict: + """Args: + items: the checked inbox items. + source: operator, or rule: for an automatic approval. + proof: the signature relayed to the executors once the undo + window passed; None when no item needs one. + apply: settles the batch after the undo window. + + Returns: + The batch without its proof and timer. + """ + batch_id = secrets.token_hex(8) + timer = threading.Timer(UNDO, self.fire, args=(batch_id, apply)) + timer.daemon = True + batch = {"id": batch_id, "items": items, "source": source, "due": time.time() + UNDO} + with self.lock: + self.batches[batch_id] = {**batch, "proof": proof, "timer": timer} + timer.start() + return dict(batch) + + def fire(self, batch_id: str, apply: Callable[[dict], None]) -> None: + with self.lock: + batch = self.batches.pop(batch_id, None) + if batch is not None: + self.applied.add(batch_id) + if batch is not None: + apply(batch) + + def undo(self, batch_id: str) -> dict: + with self.lock: + batch = self.batches.pop(batch_id, None) + if batch is None and batch_id in self.applied: + raise Conflict({"id": batch_id, "status": "applied"}) + if batch is None: + raise Missing(batch_id) + batch["timer"].cancel() + return batch + + def holds(self, kind_name: str, item_id: str) -> bool: + with self.lock: + return any(item["kind"] == kind_name and item["id"] == item_id for batch in self.batches.values() for item in batch["items"]) + + def listed(self) -> list[dict]: + with self.lock: + return [{key: batch[key] for key in ("id", "items", "source", "due")} for batch in self.batches.values()] diff --git a/hub/kvb_hub/council.py b/hub/kvb_hub/council.py new file mode 100644 index 0000000..c3102ac --- /dev/null +++ b/hub/kvb_hub/council.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from pathlib import Path + +from .records import epoch, read_json, shown + +NEWS = "news.jsonl" +HUMAN = "Operator" +SESSION = " session" +ALARMS = (("MAYDAY", "mayday"), ("PAN-PAN", "pan-pan")) +CLASSES = ("broadcast", "direct", "security", "pan-pan", "mayday") +MODES = ("evening", "sunset") +RADIO_LIMIT = 5000 +GRANT_LIMIT = 50 +MAYDAY_ACTIVE = 7200 + + +def agent(name: object) -> str: + if not isinstance(name, str): + return "" + text = name.strip() + if text.lower().endswith(SESSION): + return text[: -len(SESSION)].strip() + return text + + +def subagent(name: str) -> bool: + return name.lower().startswith("subagent") + + +def ordered(names: set[str]) -> list[str]: + return sorted(names, key=lambda name: (name.lower(), name)) + + +def kind(row: dict, names: list[str]) -> str: + if row.get("class") in CLASSES: + return row["class"] + text = row.get("text") + for prefix, alarm in ALARMS: + if isinstance(text, str) and text.lstrip().startswith(prefix): + return alarm + return "direct" if names else "broadcast" + + +def radio_row(row: dict, ts: float) -> dict: + to = row.get("to") + names = [name for name in map(agent, to) if name] if isinstance(to, list) else [] + return {"ts": ts, "from": agent(row.get("from")), "to": names, "class": kind(row, names), "text": shown(row.get("text"))} + + +def radio_rows(rows: list[dict], begin: float, end: float) -> list[dict]: + found = [radio_row(row, ts) for row in rows if (ts := epoch(row.get("ts"))) is not None and begin <= ts <= end] + return sorted(found, key=lambda row: row["ts"])[-RADIO_LIMIT:] + + +def claim_row(data: dict) -> dict: + name = data.get("name") + return { + "office": shown(data.get("office")), + "agent": agent(name), + "address": name.strip() if isinstance(name, str) else "", + "session": shown(data.get("session")), + "runtime": shown(data.get("runtime")), + "since": shown(data.get("since")), + "beat": epoch(data.get("beat")), + "work": shown(data.get("work")), + } + + +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)] + + +def sessions(claims: list[dict]) -> dict[str, str]: + return {claim["session"]: claim["agent"] for claim in claims if claim["agent"] and claim["session"] != "-"} + + +def home(workdir: object, homes: dict[str, str]) -> str: + if not isinstance(workdir, str): + return "" + matches = [path for path in homes if workdir == path or workdir.startswith(f"{path}/")] + return homes[max(matches, key=len)] if matches else "" + + +def grants(rows: list[dict], window: tuple[float, float], claims: list[dict], tasks: dict[str, dict]) -> list[dict]: + begin, end = window + homes = {} + for claim in claims: + workdir = tasks.get(claim["office"], {}).get("workdir") + if claim["agent"] and isinstance(workdir, str) and workdir.rstrip("/"): + homes[workdir.rstrip("/")] = claim["agent"] + found = [] + for row in rows: + start = epoch(row.get("start")) + holder = home(row.get("workdir"), homes) + if start is not None and begin <= start <= end and holder: + found.append({"ts": start, "to": holder, "kind": shown(row.get("kind")), "id": shown(row.get("id"))}) + return found[-GRANT_LIMIT:] + + +def night_mode(council: Path) -> str: + data = read_json(council / "night.json") + mode = data.get("mode") if isinstance(data, dict) else None + return mode if mode in MODES else "day" + + +def silence_of(council: Path, now: float) -> dict | None: + data = read_json(council / "radio-silence.json") + if not isinstance(data, dict): + return None + until = epoch(data.get("until")) + if until is None or until <= now: + return None + return {"by": shown(data.get("by")), "text": shown(data.get("text")), "until": until} diff --git a/hub/kvb_hub/crons.py b/hub/kvb_hub/crons.py new file mode 100644 index 0000000..5093bb3 --- /dev/null +++ b/hub/kvb_hub/crons.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from pathlib import Path + +from .council import agent +from .events import event +from .names import Directory +from .records import epoch, read_json, shown + +CRON_ACTIVE = 900 +TIMER_KEYS = ("OnCalendar", "OnActiveSec", "OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec") +CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Hintergrund", "timer": "Timer", "queue": "Queue"} + + +def cron_state(cron: dict, beats: dict[str, float], now: float) -> dict: + beat = beats.get(cron["session"]) + alive = cron["deleted"] is None and (cron["until"] is None or cron["until"] > now) + return {**cron, "active": alive and beat is not None and now - beat < CRON_ACTIVE} + + +def timer_rows(folder: Path) -> list[dict]: + try: + paths = sorted(folder.glob("*.timer")) + except OSError: + return [] + rows = [] + for path in paths: + try: + lines = [line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()] + created = path.stat().st_mtime + except OSError: + continue + pairs = [line.partition("=") for line in lines] + schedule = [value.strip() for key, _, value in pairs if key.strip() in TIMER_KEYS] + description = next((value.strip() for key, _, value in pairs if key.strip() == "Description"), "") + enabled = (folder / "timers.target.wants" / path.name).exists() + rows.append({ + "key": f"timer:{path.name}", "type": "timer", "session": "", "sub": "", "created": created, "deleted": None, "until": None, + "job": "", "expression": ", ".join(schedule) or "-", "prompt": description, "reason": path.name, "active": enabled, + }) + return rows + + +def queue_rows(council: Path) -> list[dict]: + data = read_json(council / "queue.json") + entries = data.get("entries") if isinstance(data, dict) else None + rows = [] + for entry in entries if isinstance(entries, list) else []: + created = epoch(entry.get("enqueued")) if isinstance(entry, dict) else None + if created is None: + continue + running = "läuft" if entry.get("status") == "active" else "wartet" + rows.append({ + "key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created, + "deleted": None, "until": None, "job": shown(entry.get("id")), "expression": f"{shown(entry.get('kind'))} · {running}", + "prompt": shown(entry.get("cmd")), "reason": shown(entry.get("task")), "active": True, "agent": agent(entry.get("name")), + }) + return rows + + +def cron_event(cron: dict, directory: Directory) -> dict: + text = "\n".join(part for part in (cron["prompt"], cron["reason"]) if part and part != "-") + return event({ + "id": f"cron:{cron['key']}", "ts": cron["created"], "kind": "cron", "agent": directory.at(cron["session"], cron["created"]) or cron.get("agent", ""), + "now": directory.now(cron["session"]), "session": cron["session"], "sub": cron["sub"], "title": f"{CRON_LABELS[cron['type']]}: {cron['expression']}", "text": text, + "ref": {key: cron[key] for key in ("type", "expression", "created", "deleted", "until", "active", "job")}, + }) diff --git a/hub/kvb_hub/engines.py b/hub/kvb_hub/engines.py new file mode 100644 index 0000000..c766f79 --- /dev/null +++ b/hub/kvb_hub/engines.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import io +import os +import re +import secrets +import subprocess +import threading +import wave +from dataclasses import dataclass +from pathlib import Path + +from .council import HUMAN +from .errors import EngineError, ToolError, Unavailable +from .fields import TEXT_LIMIT, bounded, decode, expect +from .phrasing import SPEECH_LIMIT, SPOKEN_LINE, phrase +from .records import digest, read_json +from .skill_scripts import SAFE_PATH + +MODEL_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") +SPEAKER_LIMIT = 10000 +TTS_TIMEOUT = 60 +TTS_KEEP = 200 +STT_RATE = 16000 +STT_SECONDS = 120 +STT_LIMIT = 44 + STT_RATE * 2 * STT_SECONDS +STT_TIMEOUT = 120 +NOISE = re.compile(r"\[[^\]\n]{0,40}\]") + + +def installed_voices(folder: Path) -> dict[str, tuple[str, int | None]]: + """Args: + folder: directory holding .onnx files with .onnx.json. + + Returns: + voice id : -> (model, speaker id or None for a model + with one speaker), in model and speaker order; a model without a + readable config or with an unsafe name is left out. + """ + try: + models = sorted(folder.glob("*.onnx")) + except OSError: + return {} + found: dict[str, tuple[str, int | None]] = {} + for model in models: + data = read_json(model.with_name(f"{model.name}.json")) + count = data.get("num_speakers") if isinstance(data, dict) else None + if not MODEL_NAME.fullmatch(model.stem) or isinstance(count, bool) or not isinstance(count, int) or not 1 <= count <= SPEAKER_LIMIT: + continue + for speaker in range(count): + found[f"{model.stem}:{speaker}"] = (model.stem, speaker if count > 1 else None) + return found + + +def voice_of(key: str, pool: list[str], operator: str) -> str: + """Args: + key: the speaker's session id or directory key; HUMAN is the operator. + pool: the installed voice ids other than the operator's, in their + fixed order. + operator: the operator's voice; empty when no voice is installed. + + Returns: + operator for HUMAN or when the pool is empty, otherwise the voice + sha256(key) picks from the pool. + """ + if key == HUMAN or not pool: + return operator + return pool[int(digest(key), 16) % len(pool)] + + +def wav_seconds(body: bytes) -> float: + try: + with wave.open(io.BytesIO(body), "rb") as reader: + shape = (reader.getnchannels(), reader.getsampwidth(), reader.getframerate(), reader.getcomptype()) + frames = reader.getnframes() + except (wave.Error, EOFError): + raise ToolError("the recording must be a WAV file") from None + if shape != (1, 2, STT_RATE, "NONE"): + raise ToolError(f"the recording must be {STT_RATE} Hz mono 16-bit PCM") + if not 0 < frames <= STT_RATE * STT_SECONDS: + raise ToolError(f"the recording must last more than 0 and at most {STT_SECONDS} seconds") + return frames / STT_RATE + + +def playable(path: Path) -> bool: + try: + with wave.open(str(path), "rb") as reader: + return reader.getnframes() > 0 + except (OSError, wave.Error, EOFError): + return False + + +def heard(output: str) -> str: + return " ".join(NOISE.sub(" ", output).split())[:TEXT_LIMIT] + + +@dataclass(frozen=True) +class Engines: + piper: Path + voices: Path + operator_voice: str + whisper: Path + whisper_model: Path + scratch: Path + + +class Speech: + def __init__(self, engines: Engines, cache: Path) -> None: + self.engines = engines + self.cache = cache + cache.mkdir(exist_ok=True) + self.voices = installed_voices(engines.voices) + if self.voices and engines.operator_voice not in self.voices: + raise ToolError(f"the operator voice {engines.operator_voice!r} is not installed in {engines.voices}") + self.operator = engines.operator_voice if self.voices else "" + self.pool = [voice for voice in self.voices if voice != self.operator] + self.speaking = threading.Lock() + self.hearing = threading.Lock() + + def env(self) -> dict[str, str]: + scratch = str(self.engines.scratch) + return {"PATH": SAFE_PATH, "HOME": scratch, "TMPDIR": scratch, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"} + + def speaks(self) -> bool: + return bool(self.voices) and os.access(self.engines.piper, os.X_OK) + + def hears(self) -> bool: + return os.access(self.engines.whisper, os.X_OK) and self.engines.whisper_model.is_file() + + def voice_of(self, key: str) -> str: + return voice_of(key, self.pool, self.operator) + + def describe(self) -> dict: + operator = self.voice_of(HUMAN) + sample = phrase("broadcast", HUMAN, [], "radio check") + return {"tts": self.speaks(), "stt": self.hears(), "voices": len(self.voices), "operator": operator, "sample": sample, "limit": SPEECH_LIMIT, "rate": STT_RATE, "seconds": STT_SECONDS} + + def synthesize(self, body: bytes) -> bytes: + if not self.speaks(): + raise Unavailable("speech output is off: no Piper voice or executable is installed") + payload = decode(body) + expect(payload, ("text", "voice")) + voice = payload["voice"] + if not isinstance(voice, str) or voice not in self.voices: + raise ToolError("voice must be one of the installed voices") + line = " ".join(bounded(payload, "text", SPEECH_LIMIT).split()) + if not SPOKEN_LINE.fullmatch(line): + raise ToolError(f"text must be 1 to {SPEECH_LIMIT} printable characters") + key = digest(f"{voice}\n{line}") + target = self.cache / f"{key}.wav" + with self.speaking: + if not target.is_file(): + self.render(voice, line, target) + self.trim() + os.utime(target) + return target.read_bytes() + + def render(self, voice: str, line: str, target: Path) -> None: + model, speaker = self.voices[voice] + onnx = self.engines.voices / f"{model}.onnx" + partial = self.cache / f".{target.stem}-{secrets.token_hex(4)}.wav" + command = [str(self.engines.piper), "--model", str(onnx), "--config", f"{onnx}.json", "--output-file", str(partial)] + command += [] if speaker is None else ["--speaker", str(speaker)] + try: + done = subprocess.run( + command, + input=f"{line}\n", + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=TTS_TIMEOUT, + env=self.env(), + cwd=self.engines.scratch, + check=False, + ) + if done.returncode != 0 or not playable(partial): + raise EngineError(f"piper failed with exit code {done.returncode}: {done.stderr.strip()[-300:]}") + os.replace(partial, target) + except subprocess.TimeoutExpired: + raise EngineError(f"piper took longer than {TTS_TIMEOUT} seconds") from None + except OSError as error: + raise EngineError(f"piper did not start: {error.strerror}") from None + finally: + partial.unlink(missing_ok=True) + + def trim(self) -> None: + kept = sorted(self.cache.glob("*.wav"), key=lambda path: path.stat().st_mtime, reverse=True) + for path in kept[TTS_KEEP:]: + path.unlink(missing_ok=True) + + def transcribe(self, body: bytes) -> dict: + if not self.hears(): + raise Unavailable("speech input is off: whisper.cpp or its model is missing") + wav_seconds(body) + command = [str(self.engines.whisper), "--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"] + with self.hearing: + try: + done = subprocess.run(command, input=body, capture_output=True, timeout=STT_TIMEOUT, env=self.env(), cwd=self.engines.scratch, check=False) + except subprocess.TimeoutExpired: + raise EngineError(f"whisper.cpp took longer than {STT_TIMEOUT} seconds") from None + except OSError as error: + raise EngineError(f"whisper.cpp did not start: {error.strerror}") from None + if done.returncode != 0: + raise EngineError(f"whisper.cpp failed with exit code {done.returncode}: {done.stderr.decode('utf-8', errors='replace').strip()[-300:]}") + return {"text": heard(done.stdout.decode("utf-8", errors="replace"))} diff --git a/hub/kvb_hub/errors.py b/hub/kvb_hub/errors.py new file mode 100644 index 0000000..e1a8cac --- /dev/null +++ b/hub/kvb_hub/errors.py @@ -0,0 +1,29 @@ +from __future__ import annotations + + +class ToolError(Exception): + pass + + +class Missing(Exception): + pass + + +class Conflict(Exception): + def __init__(self, payload: dict) -> None: + super().__init__("conflict") + self.payload = payload + + +class RpcError(Exception): + def __init__(self, code: int, message: str) -> None: + super().__init__(message) + self.code = code + + +class Unavailable(Exception): + pass + + +class EngineError(Exception): + pass diff --git a/hub/kvb_hub/events.py b/hub/kvb_hub/events.py new file mode 100644 index 0000000..2609a94 --- /dev/null +++ b/hub/kvb_hub/events.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import json +import secrets +import threading +import time +from collections import deque +from pathlib import Path + +from .council import agent +from .errors import ToolError +from .fields import single +from .records import read_jsonl, shown +from .register import status_of +from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS + +EVENT_KINDS = ("radio", "review", "ask", "decision", "operator", "register", "rename", "cron", "rule", "approval", "run", "transcript") +VIEWS = { + "fuer-dich": {"label": "Für dich", "kinds": list(EVENT_KINDS[:-1]), "waiting": True}, + "reviews": {"label": "Reviews", "kinds": ["review"], "waiting": False}, + "asks": {"label": "Asks", "kinds": ["ask"], "waiting": False}, + "entscheidungen": {"label": "Entscheidungen", "kinds": ["decision"], "waiting": False}, + "operator": {"label": "Operator", "kinds": ["operator", "run"], "waiting": False}, + "register": {"label": "Register", "kinds": ["register", "rename"], "waiting": False}, + "radio": {"label": "Radio", "kinds": ["radio"], "waiting": False}, + "crons": {"label": "Crons", "kinds": ["cron"], "waiting": False}, +} + + +def event(fields: dict) -> dict: + return {"part": "", "agent": "", "now": "", "session": "", "sub": "", "text": "", "waiting": False, "ref": {}, **fields} + + +def event_filters(query: dict[str, list[str]]) -> dict: + view = single(query, "view") or "" + if view: + if view not in VIEWS: + raise ToolError(f"view must be one of {', '.join(VIEWS)}") + kinds, waiting = set(VIEWS[view]["kinds"]), VIEWS[view]["waiting"] + else: + listed = single(query, "kinds") + kinds = set(EVENT_KINDS) if not listed else set(listed.split(",")) + if not kinds <= set(EVENT_KINDS): + raise ToolError(f"kinds must be a comma list of {', '.join(EVENT_KINDS)}") + waiting = single(query, "waiting") == "1" + parts = single(query, "parts") + chosen = set(STREAM_KINDS) if not parts else set(parts.split(",")) + if not chosen <= set(STREAM_KINDS): + raise ToolError(f"parts must be a comma list of {', '.join(STREAM_KINDS)}") + texts = {name: single(query, name) or "" for name in ("agent", "session", "sub")} + return {"kinds": kinds, "waiting": waiting, "parts": chosen, "q": (single(query, "q") or "").lower(), "aliases": set(), **texts} + + +def wanted(entry: dict, filters: dict) -> bool: + return all( + ( + entry["kind"] in filters["kinds"], + not filters["waiting"] or entry["waiting"], + not filters["agent"] or entry["agent"] == filters["agent"] or entry["session"] in filters["aliases"], + not filters["session"] or entry["session"] == filters["session"], + not filters["sub"] or entry["sub"] == filters["sub"], + entry["kind"] != "transcript" or entry["part"] in filters["parts"], + not filters["q"] or filters["q"] in f"{entry['title']}\n{entry['text']}".lower(), + ) + ) + + +def described(value: object) -> str: + if value is None or isinstance(value, str | int | float | bool): + return shown(value) + return "…" + + +def register_changes(before: dict[str, dict], after: dict[str, dict], now: float) -> list[dict]: + found = [] + for task in sorted(set(before) | set(after)): + old, new = before.get(task), after.get(task) + if old == new: + continue + if old is None or new is None: + row = new if old is None else old + change = f"neu: {shown(row.get('title'))}" if old is None else "entfernt" + else: + keys = sorted(key for key in set(old) | set(new) if old.get(key) != new.get(key)) + change = "; ".join(f"{key}: {described(old.get(key))} → {described(new.get(key))}" for key in keys) + row = new if new is not None else old + found.append(event({ + "id": f"register:{task}:{now}", "ts": now, "kind": "register", "agent": agent(row.get("worker")), + "title": task, "text": change[:STREAM_CUT], "ref": {"task": task, "status": status_of(row)}, + })) + return found + + +class Watch: + def __init__(self) -> None: + self.lock = threading.Lock() + self.last: dict[str, dict] | None = None + self.seen: dict[str, float] = {} + self.changes: deque[dict] = deque(maxlen=LOG_KEEP) + + def see(self, tasks: dict[str, dict], now: float) -> None: + with self.lock: + for task in tasks: + self.seen.setdefault(task, now) + if self.last is not None and self.last != tasks: + self.changes.extend(register_changes(self.last, tasks, now)) + self.last = json.loads(json.dumps(tasks)) + + def first(self, task: str, now: float) -> float: + with self.lock: + return self.seen.get(task, now) + + def listed(self) -> list[dict]: + with self.lock: + return list(self.changes) + + +class Log: + def __init__(self, file: Path) -> None: + self.file = file + self.lock = threading.Lock() + self.rows: deque[dict] = deque(read_jsonl(file)[-LOG_KEEP:], maxlen=LOG_KEEP) + + def add(self, row: dict) -> dict: + entry = event({**row, "id": f"{row['kind']}:{secrets.token_hex(8)}", "ts": time.time()}) + with self.lock: + self.rows.append(entry) + with self.file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry) + "\n") + return entry + + def listed(self) -> list[dict]: + with self.lock: + return list(self.rows) diff --git a/hub/kvb_hub/exchange.py b/hub/kvb_hub/exchange.py new file mode 100644 index 0000000..4d4bb1c --- /dev/null +++ b/hub/kvb_hub/exchange.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +from . import NAME +from .errors import Conflict, EngineError, Missing, ToolError, Unavailable +from .hub import Hub + +GUARD = "X-Kvb-Hub" +PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'" +LENGTH = re.compile(r"[0-9]{1,9}") +QUERY_FIELDS = 32 + + +class HubServer(ThreadingHTTPServer): + daemon_threads = True + hub: Hub + + +class Exchange(BaseHTTPRequestHandler): + server: HubServer + server_version = NAME + sys_version = "" + timeout = 60 + + def send(self, status: HTTPStatus, content_type: str, body: bytes, headers: dict[str, str]) -> None: + self.send_response(status) + common = { + "Content-Type": content_type, + "Content-Length": str(len(body)), + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + } + for name, value in {**common, **headers}.items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def send_text(self, status: HTTPStatus, message: str) -> None: + self.send(status, "text/plain; charset=utf-8", f"{message}\n".encode(), {"Content-Security-Policy": PLAIN_CSP}) + + def send_json(self, status: HTTPStatus, payload: dict) -> None: + self.send(status, "application/json", json.dumps(payload).encode(), {"Content-Security-Policy": PLAIN_CSP}) + + def not_allowed(self) -> None: + self.send_response(HTTPStatus.METHOD_NOT_ALLOWED) + self.send_header("Allow", "POST") + self.send_header("Content-Length", "0") + self.end_headers() + + def answer(self, produce: Callable[[], dict], status: HTTPStatus) -> None: + self.attempt(produce, lambda payload: self.send_json(status, payload)) + + def attempt(self, produce: Callable[[], object], deliver: Callable[[object], None]) -> None: + try: + result = produce() + except ToolError as error: + self.send_text(HTTPStatus.BAD_REQUEST, str(error)) + except Missing: + self.send_text(HTTPStatus.NOT_FOUND, "not found") + except Conflict as error: + self.send_json(HTTPStatus.CONFLICT, error.payload) + except Unavailable as error: + self.send_text(HTTPStatus.SERVICE_UNAVAILABLE, str(error)) + except EngineError as error: + self.send_text(HTTPStatus.BAD_GATEWAY, str(error)) + except OSError as error: + self.send_text(HTTPStatus.INTERNAL_SERVER_ERROR, f"storage failed: {error.strerror}") + else: + deliver(result) + + def query(self) -> dict[str, list[str]]: + try: + return parse_qs(urlsplit(self.path).query, keep_blank_values=True, max_num_fields=QUERY_FIELDS) + except ValueError as error: + raise ToolError(str(error)) from error + + def host(self) -> str | None: + hosts = self.headers.get_all("Host") + if hosts is None or len(hosts) != 1 or hosts[0] not in self.server.hub.hosts(): + return None + return hosts[0] + + def guarded(self, host: str) -> bool: + return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") == [f"http://{host}"] + + def local(self, host: str) -> bool: + origins = self.headers.get_all("Origin") + return self.headers.get_all(GUARD) == ["1"] and (origins is None or origins == [f"http://{host}"]) + + def client(self) -> bool: + return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") is None + + def body(self, limit: int) -> bytes | None: + length = self.headers.get("Content-Length") + if length is None or not LENGTH.fullmatch(length): + self.send_text(HTTPStatus.LENGTH_REQUIRED, "Content-Length required") + return None + if int(length) > limit: + self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large") + return None + return self.rfile.read(int(length)) diff --git a/hub/kvb_hub/fields.py b/hub/kvb_hub/fields.py new file mode 100644 index 0000000..dcda8a4 --- /dev/null +++ b/hub/kvb_hub/fields.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import math +import re + +from .errors import ToolError + +WAIT_CAP = 300 +TEXT_LIMIT = 20_000 +COMMAND_LIMIT = 4000 +PATH_LIMIT = 4096 +NAME_LIMIT = 120 +COUNT = re.compile(r"[0-9]{1,12}") +PATH = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}") +LINE = r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{{1,{0}}}" +ORDER_NAME = re.compile(r"(?![\s-])[^,\x00-\x1f\x7f]{1,200}") +FIELDS = { + "repo": (PATH, "an absolute path"), + "worktree": (PATH, "an absolute path"), + "page": (PATH, "an absolute path"), + "branch": (re.compile(r"[\w./@+-]{1,255}"), "a branch name"), + "tree": (re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}"), "the hash git write-tree prints"), + "title": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"), + "agent": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"), + "to": (re.compile(LINE.format(200)), "an agent name, one line of 1 to 200 characters"), + "label": (re.compile(LINE.format(NAME_LIMIT)), f"one line of 1 to {NAME_LIMIT} characters"), + "tool": (re.compile(LINE.format(200)), "one line of 1 to 200 characters"), + "id": (re.compile(r"[0-9a-f]{16}"), "an id the kvb-hub returned"), +} + + +def expect(arguments: dict, names: tuple[str, ...]) -> None: + problems = [f"missing argument {name}" for name in names if name not in arguments] + problems += [f"unknown argument {key}" for key in sorted(map(str, arguments)) if key not in names] + if problems: + raise ToolError("; ".join(problems)) + + +def field(arguments: dict, name: str) -> str: + pattern, hint = FIELDS[name] + value = arguments[name] + if not isinstance(value, str) or not pattern.fullmatch(value): + raise ToolError(f"{name} must be {hint}") + return value + + +def bounded(arguments: dict, name: str, limit: int) -> str: + value = arguments[name] + if not isinstance(value, str) or len(value) > limit: + raise ToolError(f"{name} must be text of at most {limit} characters") + return value + + +def message_text(arguments: dict, name: str) -> str: + value = bounded(arguments, name, TEXT_LIMIT) + if not value.strip(): + raise ToolError(f"{name} must not be empty") + return value.strip() + + +def seconds(value: object, cap: float) -> float: + if isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value < math.inf: + raise ToolError("timeout_seconds must be a finite number of seconds, 0 or more") + return float(min(value, cap)) + + +def decode(body: bytes) -> dict: + try: + data = json.loads(body) + except (ValueError, RecursionError): + raise ToolError("body must be a JSON object") from None + if not isinstance(data, dict): + raise ToolError("body must be a JSON object") + return data + + +def single(query: dict[str, list[str]], name: str) -> str | None: + values = query.get(name) + if values is None: + return None + if len(values) != 1: + raise ToolError(f"{name} must be given once") + return values[0] + + +def instant(query: dict[str, list[str]], name: str) -> float | None: + text = single(query, name) + if text is None or text == "": + return None + try: + value = float(text) + except ValueError: + raise ToolError(f"{name} must be seconds since the epoch") from None + if not 0 <= value < 1e11: + raise ToolError(f"{name} must be seconds since the epoch") + return value + + +def whole(query: dict[str, list[str]], name: str) -> int | None: + text = single(query, name) + if text is None or text == "": + return None + if not COUNT.fullmatch(text): + raise ToolError(f"{name} must be a whole number") + return int(text) diff --git a/hub/kvb_hub/graph.py b/hub/kvb_hub/graph.py new file mode 100644 index 0000000..efd5d11 --- /dev/null +++ b/hub/kvb_hub/graph.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from collections import Counter + +from .council import 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 parent_of(roles: dict[str, str], known: set[str], fallback: str) -> str: + for letter in "AR": + names = sorted(name for name, letters in roles.items() if letter in letters and name in known) + if names: + return names[0] + return fallback + + +def duties(name: str, task: str, row: dict, holders: dict[str, dict[str, dict]]) -> dict: + entry = {"task": task, "title": shown(row.get("title")), "status": status_of(row), "worker": False, "letters": ""} + return holders.setdefault(name, {}).setdefault(task, entry) + + +def edges(radio: list[dict], grant_rows: list[dict], chair: str) -> list[dict]: + counts: Counter[tuple[str, str, str]] = Counter() + for row in (row for row in radio if row["source"]): + for target in row["targets"] or ["*"]: + counts[(row["source"], target, row["class"])] += 1 + for grant in grant_rows: + counts[(chair, grant["target"], "grant")] += 1 + return [{"source": s, "target": t, "class": c, "count": n} for (s, t, c), n in sorted(counts.items())] + + +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) + 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} + moons: dict[str, tuple[str, str]] = {} + holders: dict[str, dict[str, dict]] = {} + for task, row in current.items(): + roles: dict[str, str] = {} + for name, letters in parties(row).items(): + key = directory.key(name, end) + roles[key] = roles.get(key, "") + letters + worker = workers[task] + holder = directory.key(worker, end) + if subagent(worker): + parent = parent_of(roles, known, chair) + 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 += [(key, "session", None, directory.label(key, end)) for key in found] + plan += [(moon, "subagent", *moons[moon]) for moon in ordered(set(moons))] + nodes = [ + { + "id": key, + "kind": node_kind, + "parent": parent, + "label": label, + "names": directory.names(key) or [label], + "timeline": directory.timeline(key) or [{"name": label, "since": end}], + "tasks": list(holders.get(key, {}).values()), + "claims": [claim for claim in claims if claim["session"] == key], + } + 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} diff --git a/hub/kvb_hub/handler.py b/hub/kvb_hub/handler.py new file mode 100644 index 0000000..90ec687 --- /dev/null +++ b/hub/kvb_hub/handler.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import re +import secrets +from collections.abc import Callable +from http import HTTPStatus +from urllib.parse import urlsplit + +from .engines import STT_LIMIT +from .exchange import PLAIN_CSP, Exchange + +BODY_LIMIT = 1 << 20 +UPLOAD_LIMIT = 50 << 20 +PAGE_CSP = "sandbox allow-scripts" +SETTINGS_ROUTE = "/api/settings" +TTS_ROUTE = "/api/tts" +WAV_TYPE = "audio/wav" +STT_ROUTE = "/api/stt" +ROUTE = re.compile(r"/reviews/([0-9a-f]{16})/(page|files|approve|reject)") +ITEM = re.compile(r"/api/(asks|decisions)/([0-9a-f]{16})(?:/(allow|deny|choose))?") +ENTRY = re.compile(r"/api/stream/([0-9]{1,12})") +BOARD = re.compile(r"/api/board/([a-z-]{1,32})") +UPLOADS = "/api/uploads" +POSTS = ("/api/asks", UPLOADS, "/api/messages") +STORED = { + "page": (".html", "text/html; charset=utf-8", PAGE_CSP), + "files": (".json", "application/json", PLAIN_CSP), +} +DECISIONS = {"approve": "approved", "reject": "rejected"} +INBOX_ROUTE = re.compile(r"/api/inbox/(approve|mark)") +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)") +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" +RULE_SIGN_ROUTE = "/api/rules/sign" +ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE) + + +class Handler(Exchange): + def speak(self, body: bytes) -> None: + headers = {"Content-Security-Policy": PLAIN_CSP} + self.attempt(lambda: self.server.hub.speech.synthesize(body), lambda wav: self.send(HTTPStatus.OK, WAV_TYPE, wav, headers)) + + def hear(self, body: bytes) -> None: + if self.headers.get_content_type() != WAV_TYPE: + self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, f"use {WAV_TYPE}") + return + self.answer(lambda: self.server.hub.speech.transcribe(body), HTTPStatus.OK) + + def do_GET(self) -> None: + path = urlsplit(self.path).path + hub = self.server.hub + route = ROUTE.fullmatch(path) + item = ITEM.fullmatch(path) + entry = ENTRY.fullmatch(path) + proof = PROOF_ROUTE.fullmatch(path) + if self.host() is None: + self.send_text(HTTPStatus.FORBIDDEN, "forbidden host") + elif path == "/": + self.index() + elif proof: + self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK) + elif path == "/api/state": + self.answer(lambda: hub.window(self.query()), HTTPStatus.OK) + elif path == "/api/stream": + self.answer(lambda: hub.stream(self.query()), HTTPStatus.OK) + elif path == "/api/events": + self.answer(lambda: hub.events(self.query()), HTTPStatus.OK) + elif path == "/api/tree": + self.answer(hub.repositories, HTTPStatus.OK) + elif path == "/api/subagents": + self.answer(lambda: hub.subagents(self.query()), HTTPStatus.OK) + elif path == "/api/runner/jobs": + if self.client(): + self.answer(hub.runner_jobs, HTTPStatus.OK) + else: + self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only") + elif entry: + self.answer(lambda: hub.entry(int(entry.group(1))), HTTPStatus.OK) + elif item and item.group(1) == "asks" and item.group(3) is None: + self.answer(lambda: hub.ask_wait(item.group(2), self.query()), HTTPStatus.OK) + elif route and route.group(2) in STORED: + self.stored(route.group(1), route.group(2)) + elif route or item or path == "/mcp" or path in POSTS or path in ACTION_POSTS or BOARD.fullmatch(path) or self.posted(path): + self.not_allowed() + else: + self.send_text(HTTPStatus.NOT_FOUND, "not found") + + def action(self, path: str, body: bytes) -> Callable[[], None] | None: + hub = self.server.hub + route = ROUTE.fullmatch(path) + item = ITEM.fullmatch(path) + board = BOARD.fullmatch(path) + if route and route.group(2) in DECISIONS: + return lambda: self.answer(lambda: hub.verdict(route.group(1), DECISIONS[route.group(2)], body), HTTPStatus.OK) + if item and item.group(3): + return lambda: self.answer(lambda: hub.settle(item.group(1), item.group(2), item.group(3), body), HTTPStatus.OK) + if path == UPLOADS: + return lambda: self.answer(lambda: hub.upload(self.query(), body), HTTPStatus.CREATED) + if path == "/api/messages": + return lambda: self.answer(lambda: hub.message(body), HTTPStatus.CREATED) + if board: + return lambda: self.answer(lambda: hub.board(board.group(1), body), HTTPStatus.OK) + return self.inbox_action(path, body) + + def inbox_action(self, path: str, body: bytes) -> Callable[[], None] | None: + hub = self.server.hub + inbox = INBOX_ROUTE.fullmatch(path) + undo = UNDO_ROUTE.fullmatch(path) + rule = RULE_ROUTE.fullmatch(path) + if inbox and inbox.group(1) == "approve": + return lambda: self.answer(lambda: hub.approve(body), HTTPStatus.ACCEPTED) + if path == SIGN_ROUTE: + return lambda: self.answer(lambda: hub.sign(body), HTTPStatus.OK) + if path == RULE_SIGN_ROUTE: + return lambda: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK) + if inbox: + return lambda: self.answer(lambda: hub.mark(body), HTTPStatus.OK) + if undo: + return lambda: self.answer(lambda: hub.undo(undo.group(1)), HTTPStatus.OK) + if path == "/api/rules": + return lambda: self.answer(lambda: hub.add_rule(body), HTTPStatus.CREATED) + if rule: + return lambda: self.answer(lambda: hub.rule_action(rule.group(1), rule.group(2)), HTTPStatus.OK) + if path == "/api/nl": + return lambda: self.answer(lambda: hub.nl(body), HTTPStatus.OK) + if path == SETTINGS_ROUTE: + return lambda: self.answer(lambda: hub.save_settings(body), HTTPStatus.OK) + if path == TTS_ROUTE: + return lambda: self.speak(body) + if path == STT_ROUTE: + return lambda: self.hear(body) + return None + + def posted(self, path: str) -> bool: + return any(pattern.fullmatch(path) for pattern in (INBOX_ROUTE, UNDO_ROUTE, RULE_ROUTE, RUNNER_ROUTE)) + + def runner(self, path: str, body: bytes) -> None: + hub = self.server.hub + route = RUNNER_ROUTE.fullmatch(path) + if not self.client(): + self.send_text(HTTPStatus.FORBIDDEN, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only") + elif route.group(2) == "claim": + self.answer(lambda: hub.claim(route.group(1)), HTTPStatus.OK) + else: + self.answer(lambda: hub.report(route.group(1), body), HTTPStatus.OK) + + def do_POST(self) -> None: + path = urlsplit(self.path).path + body = self.body({UPLOADS: UPLOAD_LIMIT, STT_ROUTE: STT_LIMIT}.get(path, BODY_LIMIT)) + if body is None: + return + host = self.host() + if host is None: + self.send_text(HTTPStatus.FORBIDDEN, "forbidden host") + return + if path == "/mcp": + self.mcp(host, body) + return + if path == "/api/asks": + if self.local(host): + self.answer(lambda: self.server.hub.ask(body), HTTPStatus.CREATED) + else: + self.send_text(HTTPStatus.FORBIDDEN, "forbidden: local POST with the X-Kvb-Hub header and no foreign origin only") + return + if RUNNER_ROUTE.fullmatch(path): + self.runner(path, body) + return + action = self.action(path, body) + if action is None: + self.send_text(HTTPStatus.NOT_FOUND, "not found") + elif not self.guarded(host): + self.send_text(HTTPStatus.FORBIDDEN, "forbidden: same-origin POST with the X-Kvb-Hub header only") + else: + action() + + def index(self) -> None: + nonce = secrets.token_urlsafe(18) + page, csp = self.server.hub.render(nonce) + headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"} + self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers) + + def stored(self, review_id: str, name: str) -> None: + suffix, content_type, csp = STORED[name] + reviews = self.server.hub.reviews + path = reviews.stored(review_id, suffix) + if reviews.get(review_id) is None or not path.is_file(): + self.send_text(HTTPStatus.NOT_FOUND, "not found") + return + self.send(HTTPStatus.OK, content_type, path.read_bytes(), {"Content-Security-Policy": csp}) + + def mcp(self, host: str, body: bytes) -> None: + origins = self.headers.get_all("Origin") + if origins is not None and origins != [f"http://{host}"]: + self.send_text(HTTPStatus.FORBIDDEN, "foreign origin") + return + if self.headers.get_content_type() != "application/json": + self.send_text(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, "use application/json") + return + status, reply = self.server.hub.rpc(body) + if reply is not None: + self.send_json(status, reply) + return + self.send_response(status) + self.send_header("Content-Length", "0") + self.end_headers() diff --git a/hub/kvb_hub/hub.py b/hub/kvb_hub/hub.py new file mode 100644 index 0000000..ab5094f --- /dev/null +++ b/hub/kvb_hub/hub.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import math +import threading +from dataclasses import dataclass +from pathlib import Path + +import jinja2 + +from .approving import Approving +from .asking import Asking +from .batches import Pending +from .engines import Engines, Speech +from .events import Log, Watch +from .inbox import Inbox +from .jobs import Jobs +from .language import Language, Model +from .ledger import Ledger, Marks, Reviews +from .mcp import Mcp +from .messaging import Messaging +from .names import Names +from .reviewing import Reviewing +from .rules import Rules +from .settings import Settings +from .signed import signing +from .skill_scripts import Runner +from .sources import Sources +from .timeline import Timeline +from .transcripts import Transcripts +from .tree import Tree +from .view import View + + +@dataclass(frozen=True) +class Places: + repos: Path + worktrees: Path + timers: Path + + +def environment(templates: Path) -> jinja2.Environment: + return jinja2.Environment( + loader=jinja2.FileSystemLoader(templates), + autoescape=True, + undefined=jinja2.StrictUndefined, + trim_blocks=True, + lstrip_blocks=True, + ) + + +@dataclass(frozen=True) +class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sources, Timeline, View, Language): + council: Path + pages: Path + public_port: int + reviews: Reviews + asks: Ledger + decisions: Ledger + messages: Ledger + uploads: Path + host_state: Path + transcripts: Transcripts + scripts: Runner + templates: jinja2.Environment + rules: Ledger + jobs: Ledger + marks: Marks + log: Log + batches: Pending + names: Names + watch: Watch + tree: Tree + model: Model + timers: Path + settings: Settings + speech: Speech + approvers: Path + + def hosts(self) -> tuple[str, str]: + return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}" + + def url(self) -> str: + return f"{signing.origin_of(self.public_port)}/" + + +def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_state: Path, transcripts: Path, scripts: Runner, templates: Path, places: Places, model: Model, engines: Engines, approvers: Path) -> Hub: + uploads = state / "uploads" + uploads.mkdir(exist_ok=True) + return Hub( + approvers=approvers, + speech=Speech(engines, state / "tts"), + rules=Ledger(state / "rules.json"), + jobs=Ledger(state / "jobs.json"), + marks=Marks(state / "marks.json"), + log=Log(state / "log.jsonl"), + batches=Pending(), + names=Names(state / "names.json"), + settings=Settings(state / "settings.json"), + watch=Watch(), + tree=Tree(repos=places.repos, worktrees=places.worktrees, home=state, lock=threading.Lock(), cached=None, scanned=-math.inf), + model=model, + timers=places.timers, + council=council, + pages=pages, + public_port=public_port, + reviews=Reviews(state), + asks=Ledger(state / "asks.json"), + decisions=Ledger(state / "decisions.json"), + messages=Ledger(state / "messages.json"), + uploads=uploads, + host_state=host_state, + transcripts=Transcripts(transcripts), + scripts=scripts, + templates=environment(templates), + ) diff --git a/hub/kvb_hub/inbox.py b/hub/kvb_hub/inbox.py new file mode 100644 index 0000000..4f679a6 --- /dev/null +++ b/hub/kvb_hub/inbox.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import time + +from .approving import INBOX_KINDS +from .errors import ToolError +from .fields import decode, expect +from .names import Directory +from .records import digest, iso_epoch +from .register import operator_rows, runnable +from .signed import signing + +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} + items = [] + for review in self.reviews.pending(): + created = iso_epoch(review["created"]) or now + gate = review.get("gate", "") + fields = {"title": review["title"], "detail": review["branch"], "green": gate == "pass", "gate": gate, "score": review.get("score", ""), "directory": review["worktree"]} + 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} + 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}) + 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"])) + return sorted(items, key=lambda item: (not item["pinned"], -item["ts"], item["id"])) + + def mark(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("kind", "id", "until", "pinned")) + kind_name, until, pinned = payload["kind"], payload["until"], payload["pinned"] + if kind_name not in INBOX_KINDS or not isinstance(payload["id"], str) or not 0 < len(payload["id"]) <= 200: + raise ToolError(f"kind must be one of {', '.join(INBOX_KINDS)} and id an item id") + now = time.time() + if isinstance(until, bool) or not isinstance(until, int | float) or not (until == 0 or now < until <= now + SNOOZE_LIMIT): + raise ToolError("until must be 0 or a moment within the next 8 days") + if not isinstance(pinned, bool): + raise ToolError("pinned must be true or false") + mark = {"until": float(until), "pinned": pinned} + self.marks.put(f"{kind_name}:{payload['id']}", mark) + return {"kind": kind_name, "id": payload["id"], **mark} diff --git a/hub/kvb_hub/jobs.py b/hub/kvb_hub/jobs.py new file mode 100644 index 0000000..d848c88 --- /dev/null +++ b/hub/kvb_hub/jobs.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import time + +from .errors import Conflict, Missing, ToolError +from .fields import bounded, decode, expect +from .register import OPERATOR, REGISTER, operator_rows, read_register, runnable +from .signed import operator_signed +from .skill_scripts import OPERATOR_SCRIPT + +JOB_OPEN = ("queued", "running") +RUN_OUTPUT = 64 << 10 + + +def runner_job(job: dict) -> dict: + return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")} + + +class Jobs: + def open_jobs(self) -> set[str]: + return {job["task"] for job in self.jobs.listed() if job["status"] in JOB_OPEN} + + 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"]: + return "verweigert: die Zeile hat sich seit der Freigabe geändert" + 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}) + return "an den Runner übergeben" + + def runner_jobs(self) -> dict: + return {"jobs": [runner_job(job) for job in self.jobs.listed() if job["status"] == "queued"]} + + def claim(self, job_id: str) -> dict: + job, changed = self.jobs.transition(job_id, "queued", {"status": "running", "started": time.time()}) + if job is None: + raise Missing(job_id) + if not changed: + raise Conflict({"id": job_id, "status": job["status"]}) + return runner_job(job) + + def report(self, job_id: str, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("output", "code", "refused")) + output, code, refused = bounded(payload, "output", RUN_OUTPUT), payload["code"], payload["refused"] + if not isinstance(refused, bool) or (code is not None and (isinstance(code, bool) or not isinstance(code, int))): + raise ToolError("code must be a whole number or null and refused true or false") + record = self.jobs.get(job_id) + if record is None: + raise Missing(job_id) + if record["status"] != "running": + raise Conflict({"id": job_id, "status": record["status"]}) + if code is None and not refused: + self.jobs.change(job_id, {"output": output}) + return {"id": job_id, "status": "running", "code": None} + status = "refused" if refused else "done" if code == 0 else "failed" + job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()}) + if not changed: + raise Conflict({"id": job_id, "status": job["status"]}) + runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if status == "done" else [] + tail = "\n".join(output.splitlines()[-20:]) + self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}}) + return {"id": job_id, "status": status, "code": code, "runs": runs} diff --git a/hub/kvb_hub/language.py b/hub/kvb_hub/language.py new file mode 100644 index 0000000..9204ba8 --- /dev/null +++ b/hub/kvb_hub/language.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import json +import time +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +from .council import NEWS, ordered, read_claims +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 + +NL_LIMIT = 2000 +NL_ACTION_LIMIT = 20 +LLM_TIMEOUT = 60 +LLM_REPLY = 1 << 20 + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect) +SCREENS = ("stream", "graph", "tree") +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": "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}}, + {"name": "view", "description": "Show a saved view of the stream.", "parameters": {"name": {"type": "string", "enum": list(VIEWS)}}}, + {"name": "open", "description": "Switch the screen.", "parameters": {"view": {"type": "string", "enum": list(SCREENS)}}}, + {"name": "message", "description": "Radio a direct message to an agent.", "parameters": {"to": {"type": "string"}, "text": {"type": "string"}}}, + {"name": "filter", "description": "Search the stream for a text.", "parameters": {"q": {"type": "string"}}}, +] + + +def nl_messages(text: str, items: list[dict], agents: list[str]) -> list[dict]: + schemas = [{"name": action["name"], "description": action["description"], "arguments": action["parameters"]} for action in NL_ACTIONS] + system = ( + "You turn one command of the kvb-hub operator into hub actions. Reply with exactly one JSON object " + '{"actions": [{"name": "", "arguments": {...}}]} and nothing else. Every action has one of these ' + f"names, and its arguments object holds exactly the listed properties: {json.dumps(schemas, ensure_ascii=False)}. " + 'Use only ids from items and names from agents. Reply {"actions": []} when nothing fits.' + ) + user = json.dumps({"command": text, "items": items, "agents": agents}, ensure_ascii=False) + return [{"role": "system", "content": system}, {"role": "user", "content": user}] + + +def nl_ids(arguments: dict, items: dict[str, dict]) -> list[dict]: + ids = arguments["ids"] + if not isinstance(ids, list) or not ids or not all(isinstance(ident, str) and ident in items for ident in ids): + raise ToolError("ids must name inbox items") + if len(set(ids)) != len(ids): + raise ToolError("ids must be distinct") + return [items[ident] for ident in ids] + + +def nl_item(item: dict, verb: str, label: str) -> dict: + return {"kind": item["kind"], "id": item["id"], "verb": verb, "label": label, "sha256": item["sha256"]} + + +def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict: + if not isinstance(raw, dict) or set(raw) != {"name", "arguments"} or not isinstance(raw["arguments"], dict): + raise ToolError("an action must be an object with name and arguments") + schema = next((action for action in NL_ACTIONS if action["name"] == raw["name"]), None) + if schema is None: + raise ToolError(f"unknown action {str(raw['name'])[:40]!r}") + name, arguments = schema["name"], raw["arguments"] + 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 == "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): + raise ToolError("that operator row holds no command to run") + chosen = [nl_item(item, "approve" if name == "approve" else "reject", item["recommended"] if item["kind"] == "decision" else "") for item in picked] + lines = [f"{item['kind']} {item['id']}: {item['command'] if item['kind'] == 'operator' else item['title']}" for item in picked] + verb = "Freigeben" if name == "approve" else "Ablehnen" + return {"name": name, "summary": f"{verb}:\n" + "\n".join(lines), "args": {"items": chosen}} + if name == "choose": + item = items.get(arguments["id"]) if isinstance(arguments["id"], str) else None + if item is None or item["kind"] != "decision" or arguments["label"] not in [option["label"] for option in item["options"]]: + raise ToolError("choose takes a decision id and one of its option labels") + return {"name": name, "summary": f"Entscheiden: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}} + if name in ("snooze", "pin"): + picked = nl_ids(arguments, items) + hours = arguments.get("hours") + if name == "snooze" and (isinstance(hours, bool) or not isinstance(hours, int | float) or not 1 <= hours <= 192): + raise ToolError("hours must be between 1 and 192") + keys = [{"kind": item["kind"], "id": item["id"]} for item in picked] + words = f"Zurückstellen ({hours} h)" if name == "snooze" else "Anheften" + return {"name": name, "summary": f"{words}: " + ", ".join(item["title"][:80] for item in picked), "args": {"keys": keys, "hours": hours}} + if name == "view": + if arguments["name"] not in VIEWS: + raise ToolError("unknown view") + return {"name": name, "summary": f"Ansicht: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}} + if name == "open": + if arguments["view"] not in SCREENS: + raise ToolError("unknown screen") + return {"name": name, "summary": f"Öffnen: {arguments['view']}", "args": {"view": arguments["view"]}} + if name == "message": + text = message_text(arguments, "text") + if arguments["to"] not in agents: + raise ToolError("to must name a known agent") + return {"name": name, "summary": f"Funk an {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}} + query = bounded(arguments, "q", 200) + return {"name": name, "summary": f"Suchen: {query}", "args": {"q": query}} + + +def nl_actions(reply: str, items: list[dict], agents: list[str]) -> dict: + text = reply.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + data = json.loads(text) + except (ValueError, RecursionError): + raise ToolError("the model answered no JSON object") from None + if not isinstance(data, dict) or not isinstance(data.get("actions"), list): + raise ToolError("the model answered no actions list") + known = {item["id"]: item for item in items} + accepted, rejected = [], [] + for raw in data["actions"][:NL_ACTION_LIMIT]: + try: + accepted.append(nl_action(raw, known, agents)) + except ToolError as error: + rejected.append({"action": json.dumps(raw, ensure_ascii=False)[:300], "reason": str(error)}) + return {"actions": accepted, "rejected": rejected} + + +@dataclass(frozen=True) +class Model: + url: str + model: str + key: str + + def enabled(self) -> bool: + return bool(self.url and self.model) + + def complete(self, messages: list[dict]) -> str: + target = self.url.rstrip("/") + target = target if target.endswith("/chat/completions") else f"{target}/chat/completions" + headers = {"Content-Type": "application/json"} + if self.key: + headers["Authorization"] = f"Bearer {self.key}" + body = json.dumps({"model": self.model, "messages": messages, "temperature": 0, "response_format": {"type": "json_object"}}).encode() + request = urllib.request.Request(target, data=body, headers=headers, method="POST") + try: + with LLM_OPENER.open(request, timeout=LLM_TIMEOUT) as response: + data = json.loads(response.read(LLM_REPLY)) + content = data["choices"][0]["message"]["content"] + except (OSError, ValueError, KeyError, IndexError, TypeError) as error: + raise ToolError(f"the model endpoint failed: {error}") from None + if not isinstance(content, str): + raise ToolError("the model endpoint answered no text") + return content + + +def read_key(path: Path) -> str: + try: + return path.read_text(encoding="utf-8").strip() + except (OSError, ValueError): + return "" + + +class Language: + def nl(self, body: bytes) -> dict: + if not self.model.enabled(): + raise ToolError("the natural-language command line is off: set KVB_HUB_LLM_URL and KVB_HUB_LLM_MODEL in hub/.env") + payload = decode(body) + expect(payload, ("text",)) + text = message_text(payload, "text") + if len(text) > NL_LIMIT: + raise ToolError(f"text must be at most {NL_LIMIT} characters") + now = time.time() + claims = read_claims(self.council) + tasks = read_register(self.council / REGISTER) + items = self.inbox(now, tasks, self.directory(claims, read_jsonl(self.council / NEWS), now)) + agents = ordered({claim["agent"] for claim in claims if claim["agent"]} | set(priority(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) diff --git a/hub/kvb_hub/ledger.py b/hub/kvb_hub/ledger.py new file mode 100644 index 0000000..fed8489 --- /dev/null +++ b/hub/kvb_hub/ledger.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import secrets +import threading +from pathlib import Path + +from .records import read_json, stamp, write_atomic + + +class Ledger: + def __init__(self, file: Path) -> None: + self.file = file + self.changed = threading.Condition() + self.items: dict[str, dict] = {} + if file.is_file(): + self.items = json.loads(file.read_text(encoding="utf-8")) + + def fresh(self) -> str: + with self.changed: + item_id = secrets.token_hex(8) + while item_id in self.items: + item_id = secrets.token_hex(8) + return item_id + + def write(self, items: dict[str, dict]) -> None: + write_atomic(self.file, json.dumps(items, indent=1, sort_keys=True).encode("utf-8")) + self.items = items + + def insert(self, item_id: str, fields: dict) -> dict: + with self.changed: + record = {**fields, "id": item_id, "created": stamp()} + self.write({**self.items, item_id: record}) + return dict(record) + + def add(self, fields: dict) -> dict: + with self.changed: + return self.insert(self.fresh(), fields) + + def get(self, item_id: str) -> dict | None: + with self.changed: + record = self.items.get(item_id) + return None if record is None else dict(record) + + def listed(self) -> list[dict]: + with self.changed: + records = [dict(record) for record in self.items.values()] + return sorted(records, key=lambda record: (record["created"], record["id"])) + + def pending(self) -> list[dict]: + return [record for record in self.listed() if record.get("status") == "pending"] + + def transition(self, item_id: str, before: str, changes: dict) -> tuple[dict | None, bool]: + with self.changed: + record = self.items.get(item_id) + if record is None: + return None, False + if record.get("status") != before: + return dict(record), False + updated = {**record, **changes} + self.write({**self.items, item_id: updated}) + self.changed.notify_all() + return dict(updated), True + + def decide(self, item_id: str, changes: dict) -> tuple[dict | None, bool]: + return self.transition(item_id, "pending", {**changes, "decided": stamp()}) + + def change(self, item_id: str, changes: dict) -> dict | None: + with self.changed: + record = self.items.get(item_id) + if record is None: + return None + updated = {**record, **changes} + self.write({**self.items, item_id: updated}) + return dict(updated) + + def remove(self, item_id: str) -> bool: + with self.changed: + if item_id not in self.items: + return False + self.write({key: value for key, value in self.items.items() if key != item_id}) + return True + + def wait(self, item_id: str, timeout: float) -> dict | None: + with self.changed: + if item_id not in self.items: + return None + self.changed.wait_for(lambda: self.items[item_id].get("status") != "pending", timeout) + return dict(self.items[item_id]) + + +class Reviews(Ledger): + def __init__(self, state: Path) -> None: + super().__init__(state / "reviews.json") + self.pages = state / "pages" + self.pages.mkdir(exist_ok=True) + + def stored(self, review_id: str, suffix: str) -> Path: + return self.pages / f"{review_id}{suffix}" + + def submit(self, fields: dict[str, str], page: bytes, files: list[dict]) -> dict: + with self.changed: + review_id = self.fresh() + write_atomic(self.stored(review_id, ".html"), page) + write_atomic(self.stored(review_id, ".json"), json.dumps({"files": files}).encode("utf-8")) + return self.insert(review_id, {**fields, "files": len(files), "status": "pending", "decided": None}) + + +class Marks: + def __init__(self, file: Path) -> None: + self.file = file + self.lock = threading.Lock() + data = read_json(file) + self.items: dict[str, dict] = data if isinstance(data, dict) else {} + + def put(self, key: str, mark: dict) -> None: + with self.lock: + self.items = {**self.items, key: mark} + write_atomic(self.file, json.dumps(self.items, sort_keys=True).encode("utf-8")) + + def get(self, key: str) -> dict: + with self.lock: + return dict(self.items.get(key, {"until": 0.0, "pinned": False})) diff --git a/hub/kvb_hub/lifetimes.py b/hub/kvb_hub/lifetimes.py new file mode 100644 index 0000000..0ec0038 --- /dev/null +++ b/hub/kvb_hub/lifetimes.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import datetime +from pathlib import Path + +from .records import iso_epoch, parse_line, read_json, shown + +EDGE_READ = 256 << 10 +GONE = 86400 + + +def lifetime_stamp(ts: float | None) -> str: + if ts is None: + return "" + return datetime.datetime.fromtimestamp(ts, datetime.UTC).strftime("%Y-%m-%dT%H:%MZ") + + +def line_ts(raw: bytes) -> float | None: + row = parse_line(raw.decode("utf-8", errors="replace")) + return None if row is None else iso_epoch(row.get("timestamp")) + + +def edge_times(path: Path) -> tuple[float | None, float | None, float | None]: + try: + info = path.stat() + with path.open("rb") as handle: + head = handle.read(EDGE_READ) + handle.seek(max(info.st_size - EDGE_READ, 0)) + tail = handle.read(EDGE_READ) + except OSError: + return None, None, None + first = next((ts for ts in map(line_ts, head.splitlines()) if ts is not None), None) + last = next((ts for ts in map(line_ts, reversed(tail.splitlines())) if ts is not None), None) + return first, last, info.st_mtime + + +def lifetime(times: tuple[float | None, float | None, float | None], gone: bool) -> dict: + """Args: + times: first entry, last entry and modification time of a transcript. + gone: whether the agent is known to have ended. + + Returns: + born and died as YYYY-MM-DDTHH:MMZ in UTC, empty when unknown; died + stays empty unless gone. since and until hold the same moments in + epoch seconds, None when unknown or not gone. + """ + first, last, _ = times + ended = last if gone else None + return {"born": lifetime_stamp(first), "died": lifetime_stamp(ended), "since": first, "until": ended} + + +def session_gone(beat: float | None, changed: float | None, now: float) -> bool: + return (beat is None or now - beat > GONE) and changed is not None and now - changed > GONE + + +def subagent_rows(root: Path, session: str) -> list[dict]: + try: + paths = sorted(root.glob(f"*/{session}/subagents/agent-*.meta.json")) + except OSError: + return [] + rows = [] + for path in paths: + data = read_json(path) + if not isinstance(data, dict): + continue + ident = path.name.removeprefix("agent-").removesuffix(".meta.json") + depth = data.get("spawnDepth") + try: + started = path.stat().st_mtime + except OSError: + continue + finished = bool(data.get("finished")) + rows.append({ + "id": ident, "type": shown(data.get("agentType")), "description": shown(data.get("description")), + "depth": depth if isinstance(depth, int) and not isinstance(depth, bool) and depth > 0 else 1, + "finished": finished, "tool_use": shown(data.get("toolUseId")), "started": started, + **lifetime(edge_times(path.with_name(f"agent-{ident}.jsonl")), finished), + }) + return sorted(rows, key=lambda row: (row["started"], row["id"])) diff --git a/hub/kvb_hub/mcp.py b/hub/kvb_hub/mcp.py new file mode 100644 index 0000000..6e42c96 --- /dev/null +++ b/hub/kvb_hub/mcp.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import json +from http import HTTPStatus + +from . import NAME +from .asking import OPTION_LIMIT +from .errors import RpcError, ToolError +from .fields import WAIT_CAP +from .reviewing import GATES + +PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") + + +def text_property(description: str) -> dict[str, str]: + return {"type": "string", "description": description} + + +def tool(name: str, description: str, properties: dict[str, dict]) -> dict: + schema = {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False} + return {"name": name, "description": description, "inputSchema": schema} + + +def rpc_error(request_id: object, code: int, message: str) -> dict: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + + +class Mcp: + def tools(self) -> list[dict]: + review_id = text_property("Review id that review_request returned.") + wait = {"type": "number", "minimum": 0, "description": f"Seconds to wait, capped at {WAIT_CAP}."} + option = { + "type": "object", + "properties": {"label": text_property("Button text, one line."), "description": text_property("What choosing it means; may be empty.")}, + "required": ["label", "description"], + "additionalProperties": False, + } + return [ + tool( + "review_request", + "Ask the operator to approve a staged commit in the kvb-hub. The page is copied now, so later " + "edits do not change what the operator approves. Returns the review id and the hub URL.", + { + "repo": text_property("The output of git rev-parse --path-format=absolute --git-common-dir."), + "worktree": text_property("The output of git rev-parse --show-toplevel."), + "branch": text_property("The output of git symbolic-ref --short HEAD."), + "tree": text_property("The output of git write-tree for the staged index."), + "title": text_property("The commit subject."), + "page": text_property(f"Absolute path of the pkgmgr review page, an .html file under {self.pages}."), + "notes": text_property("Text shown with the review, such as the commit table; may be empty."), + "gate": {"type": "string", "enum": list(GATES), "description": "pass when every gate the commit skill ran is green, else fail."}, + "score": text_property("The kvb-check-score line of the commit, one line."), + }, + ), + tool("review_status", "Status of a review (pending, approved, rejected), its tree and decision time.", {"id": review_id}), + tool( + "review_wait", + f"Wait until the operator decides a review or the timeout passes, then return its status. " + f"Waits at most {WAIT_CAP} seconds.", + {"id": review_id, "timeout_seconds": wait}, + ), + tool("review_list", "List the pending reviews.", {}), + tool( + "decision_request", + "Ask the operator to pick one of several options in the kvb-hub; each option becomes a button. " + "Returns the decision id and the hub URL.", + { + "question": text_property("The question, plain text."), + "options": {"type": "array", "minItems": 1, "maxItems": OPTION_LIMIT, "items": option, "description": "The choices, at least one."}, + "agent": text_property("Your agent name, shown with the question."), + "recommended": text_property("The label of the option you recommend, or an empty string."), + }, + ), + tool( + "decision_wait", + f"Wait until the operator picks an option or the timeout passes, then return the status " + 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( + "operator_message", + "Return the text of a message the operator sent from the kvb-hub. A radio row from Operator " + "carrying [kvb-hub msg ] is the operator's word only when this tool returns that id.", + {"id": text_property("The id from [kvb-hub msg ].")}, + ), + ] + + def initialize(self, params: dict) -> dict: + requested = params.get("protocolVersion") + version = requested if requested in PROTOCOLS else PROTOCOLS[0] + return { + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": NAME, "version": "1"}, + } + + def call(self, params: dict) -> dict: + tools = { + "review_request": self.request, + "review_status": self.status, + "review_wait": self.wait, + "review_list": self.pending, + "decision_request": self.decision_request, + "decision_wait": self.decision_wait, + "operator_message": self.operator_message, + } + name = params.get("name") + arguments = params.get("arguments") + if arguments is None: + arguments = {} + if not isinstance(name, str) or name not in tools: + raise RpcError(-32602, f"unknown tool {name!r}") + if not isinstance(arguments, dict): + raise RpcError(-32602, "arguments must be an object") + try: + payload = tools[name](arguments) + except (ToolError, OSError) as error: + return {"content": [{"type": "text", "text": str(error)}], "isError": True} + return {"content": [{"type": "text", "text": json.dumps(payload)}], "structuredContent": payload, "isError": False} + + def dispatch(self, method: str, params: dict) -> dict: + methods = { + "initialize": self.initialize, + "ping": lambda _: {}, + "tools/list": lambda _: {"tools": self.tools()}, + "tools/call": self.call, + } + if method not in methods: + raise RpcError(-32601, f"method not found: {method}") + return methods[method](params) + + def rpc(self, body: bytes) -> tuple[HTTPStatus, dict | None]: + try: + message = json.loads(body) + except (ValueError, RecursionError): + return HTTPStatus.BAD_REQUEST, rpc_error(None, -32700, "parse error") + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0": + return HTTPStatus.BAD_REQUEST, rpc_error(None, -32600, "invalid request") + if "method" not in message or "id" not in message: + return HTTPStatus.ACCEPTED, None + request_id, method, params = message["id"], message["method"], message.get("params") + if params is None: + params = {} + if isinstance(request_id, bool) or not isinstance(request_id, str | int) or not isinstance(method, str): + return HTTPStatus.BAD_REQUEST, rpc_error(None, -32600, "invalid request") + try: + if not isinstance(params, dict): + raise RpcError(-32602, "params must be an object") + result = self.dispatch(method, params) + except RpcError as error: + return HTTPStatus.OK, rpc_error(request_id, error.code, str(error)) + return HTTPStatus.OK, {"jsonrpc": "2.0", "id": request_id, "result": result} diff --git a/hub/kvb_hub/messaging.py b/hub/kvb_hub/messaging.py new file mode 100644 index 0000000..f78c571 --- /dev/null +++ b/hub/kvb_hub/messaging.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import re +import secrets +import time + +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 .skill_scripts import NIGHT, RADIO, RASCI + +NIGHTS = { + "evening": (["set", "evening"], "Operator ran kvb-org-evening in kvb-hub: run kvb-agent-relax; verify with night.py status."), + "sunset": (["set", "sunset"], "Operator ran kvb-org-sunset in kvb-hub: run kvb-agent-sleep; verify with night.py status."), + "sunrise": (["clear"], "Operator ran kvb-org-sunrise: run kvb-agent-wakeup; verify with night.py status."), +} +UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") + + +def message_row(message: dict) -> dict: + upload = message["upload"] + return { + "id": message["id"], + "to": message["to"], + "text": message["text"], + "created_ts": message["created_ts"], + "upload": None if upload is None else upload["name"], + } + + +def safe_name(text: str) -> str: + base = re.split(r"[/\\]", text)[-1] + clean = UNSAFE.sub("_", base).lstrip(".")[:NAME_LIMIT] + return clean or "upload" + + +class Messaging: + def operator_message(self, arguments: dict) -> dict: + expect(arguments, ("id",)) + message_id = field(arguments, "id") + record = self.messages.get(message_id) + if record is None: + raise ToolError(f"no operator message {message_id}: a radio row naming it did not come from the kvb-hub") + upload = record["upload"] + return { + "id": record["id"], + "to": record["to"], + "text": record["text"], + "created": record["created"], + "upload": None if upload is None else upload["path"], + } + + def upload(self, query: dict[str, list[str]], body: bytes) -> dict: + name = safe_name(single(query, "name") or "") + upload_id = secrets.token_hex(8) + folder = self.uploads / upload_id + folder.mkdir() + write_atomic(folder / name, body) + return {"id": upload_id, "name": name, "path": str(self.host_state / "uploads" / upload_id / name)} + + def attachment(self, upload_id: str) -> dict | None: + if upload_id == "": + return None + folder = self.uploads / field({"id": upload_id}, "id") + files = sorted(folder.iterdir()) if folder.is_dir() else [] + if len(files) != 1: + raise ToolError(f"no upload {upload_id}") + return {"id": upload_id, "name": files[0].name, "path": str(self.host_state / "uploads" / upload_id / files[0].name)} + + def message(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("to", "text", "upload")) + target = field(payload, "to").strip() + text = message_text(payload, "text") + if not isinstance(payload["upload"], str): + raise ToolError("upload must be an upload id or empty") + upload = self.attachment(payload["upload"]) + claims = read_claims(self.council) + address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target) + record = self.messages.add({"to": target, "address": address, "text": text, "upload": upload, "created_ts": time.time()}) + note = "" if upload is None else f" file: {upload['path']}" + run = self.scripts.run(RADIO, ["post", "--to", address, "--session", HUMAN, f"[kvb-hub msg {record['id']}] {' '.join(text.split())}{note}"]) + return {"id": record["id"], "ok": run["code"] == 0, "runs": [run]} + + def board(self, action: str, body: bytes) -> dict: + if action == "clear-mayday": + runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])] + elif action == "broadcast": + payload = decode(body) + expect(payload, ("text",)) + text = message_text(payload, "text") + record = self.messages.add({"to": "*", "address": "*", "text": text, "upload": None, "created_ts": time.time()}) + runs = [self.scripts.run(RADIO, ["post", "--session", HUMAN, f"[kvb-hub msg {record['id']}] {' '.join(text.split())}"])] + elif action == "order": + payload = decode(body) + expect(payload, ("agents",)) + names = payload["agents"] + if not isinstance(names, list) or not names or not all(isinstance(n, str) and ORDER_NAME.fullmatch(n) for n in names): + 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)])] + elif action in NIGHTS: + command, text = NIGHTS[action] + runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])] + peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN}) + if peers and runs[0]["code"] == 0: + targets = [part for peer in peers for part in ("--to", peer)] + runs.append(self.scripts.run(RADIO, ["send", "direct", "--wake", *targets, "--session", HUMAN, text])) + else: + raise Missing(action) + return {"ok": all(run["code"] == 0 for run in runs), "runs": runs} diff --git a/hub/kvb_hub/names.py b/hub/kvb_hub/names.py new file mode 100644 index 0000000..a2bf2a1 --- /dev/null +++ b/hub/kvb_hub/names.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import itertools +import json +import math +import re +import threading +from pathlib import Path + +from .council import HUMAN, agent +from .events import event +from .records import read_json, write_atomic +from .transcripts import STREAM_AGE + +PSEUDO = "name:" +TITLE_SCAN = 10.0 +SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}") + + +class Directory: + def __init__(self, entries: list[dict]) -> None: + self.by_session: dict[str, list[dict]] = {} + self.by_name: dict[str, list[str]] = {} + for entry in entries: + self.by_session.setdefault(entry["session"], []).append(entry) + holders = self.by_name.setdefault(entry["name"], []) + if entry["session"] in holders: + holders.remove(entry["session"]) + holders.append(entry["session"]) + + def at(self, session: str, ts: float) -> str: + own = self.by_session.get(session) + if not own: + return "" + valid = [entry for entry in own if entry["first_seen"] <= ts] + return (valid[-1] if valid else own[0])["name"] + + def now(self, session: str) -> str: + own = self.by_session.get(session) + return own[-1]["name"] if own else "" + + def find(self, name: str, ts: float) -> str: + holders = self.by_name.get(name, []) + real = [session for session in holders if not session.startswith(PSEUDO)] + valid = [session for session in real if self.at(session, ts) == name] + chosen = valid or real or holders + return chosen[-1] if chosen else "" + + def key(self, name: str, ts: float) -> str: + if not name or name == HUMAN: + return name + return self.find(name, ts) or f"{PSEUDO}{name}" + + def label(self, key: str, ts: float) -> str: + return self.at(key, ts) or key.removeprefix(PSEUDO) + + def names(self, key: str) -> list[str]: + return list(dict.fromkeys(entry["name"] for entry in self.by_session.get(key, []))) + + def timeline(self, key: str) -> list[dict]: + return [{"name": entry["name"], "since": entry["first_seen"]} for entry in self.by_session.get(key, [])] + + def aliases(self, name: str) -> set[str]: + return set(self.by_name.get(name, [])) + + def renames(self, begin: float, end: float) -> list[dict]: + found = [] + for session, own in self.by_session.items(): + for before, after in itertools.pairwise(own): + if before["name"] != after["name"] and begin <= after["first_seen"] <= end: + found.append(event({ + "id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"], + "now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}", + "text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]}, + })) + return found + + +class Names: + def __init__(self, file: Path) -> None: + self.file = file + self.lock = threading.Lock() + self.titled = -math.inf + data = read_json(file) + data = data if isinstance(data, dict) else {} + entries, observed = data.get("entries"), data.get("observed") + keys = {"session", "name", "first_seen", "source"} + self.entries = [entry for entry in entries if isinstance(entry, dict) and set(entry) == keys] if isinstance(entries, list) else [] + self.observed: dict[str, str] = observed if isinstance(observed, dict) else {} + + def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None: + with self.lock: + changed = False + for session, name, seen, source in sightings: + key = f"{session}\x00{source}" + if not session or not name or self.observed.get(key) == name: + continue + self.observed[key] = name + changed = True + history = [entry for entry in self.entries if entry["session"] == session] + if history and history[-1]["name"] == name: + continue + floor = history[-1]["first_seen"] if history else -math.inf + first = seen if seen is not None and seen > floor else max(now, floor) + self.entries.append({"session": session, "name": name, "first_seen": first, "source": source}) + if changed: + write_atomic(self.file, json.dumps({"entries": self.entries, "observed": self.observed}, sort_keys=True).encode("utf-8")) + + def listed(self) -> list[dict]: + with self.lock: + return [dict(entry) for entry in self.entries] + + def due(self, now: float) -> bool: + with self.lock: + if now - self.titled < TITLE_SCAN: + return False + self.titled = now + return True + + +def title_sightings(root: Path, claimed: set[str], now: float) -> list[tuple[str, str, float | None, str]]: + try: + paths = sorted(root.glob("*/*/custom-title.json")) + except OSError: + return [] + found = [] + for path in paths: + session = path.parent.name + try: + changed = path.stat().st_mtime + except OSError: + continue + if not SESSION_ID.fullmatch(session) or (session not in claimed and changed < now - STREAM_AGE): + continue + data = read_json(path) + name = agent(data.get("customTitle") if isinstance(data, dict) else None) + if name: + found.append((session, name, changed, "title")) + return found diff --git a/hub/kvb_hub/orbits.py b/hub/kvb_hub/orbits.py new file mode 100644 index 0000000..a6fb86f --- /dev/null +++ b/hub/kvb_hub/orbits.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import bisect +import functools +import itertools +import math +from collections.abc import Callable + +from .council import agent +from .names import Directory +from .register import ENDED, parties, status_of + +HALF_LIFE = 1800.0 +INNER = 150.0 +OUTER = 300.0 +TRACK_STEPS = 12 + + +def pair_weight(weights: dict[tuple[str, str], float], first: str, second: str) -> float: + return weights.get((min(first, second), max(first, second)), 0.0) + + +def chain(names: list[str], weights: dict[tuple[str, str], float], activity: dict[str, float]) -> list[str]: + left = sorted(names, key=lambda name: (name.lower(), name)) + if not left: + return [] + current = max(left, key=lambda name: activity[name]) + order = [current] + left.remove(current) + while left: + last = current + current = max(left, key=lambda name: (pair_weight(weights, last, name), activity[name])) + order.append(current) + left.remove(current) + return order + + +def affinity(nodes: list[dict], radio: list[dict], tasks: dict[str, dict], claims: list[dict], end: float, key_of: Callable[[str], str]) -> dict: + """Args: + nodes: graph nodes keyed by session; chair and session nodes take part. + radio: rows with ts, source and targets as node keys. + tasks: register rows; workers and role holders are names. + claims: claims; their session is a node key. + end: the moment the decay counts back from. + key_of: maps a name to its node key. + + Returns: + orbit: {key: {angle, radius}}, closer and inner for agents that talk + or work together recently; order: the keys around the circle. + """ + names = [node["id"] for node in nodes if node["kind"] in ("chair", "session")] + activity = dict.fromkeys(names, 0.0) + weights: dict[tuple[str, str], float] = {} + + def link(first: str, second: str, amount: float) -> None: + if first != second and first in activity and second in activity: + pair = (min(first, second), max(first, second)) + weights[pair] = weights.get(pair, 0.0) + amount + activity[first] += amount + activity[second] += amount + + for row in radio: + fade = 0.5 ** (max(end - row["ts"], 0.0) / HALF_LIFE) + if row["source"] in activity and not row["targets"]: + activity[row["source"]] += fade + for target in row["targets"]: + link(row["source"], target, fade) + places: dict[str, set[str]] = {} + for task, row in sorted(tasks.items()): + if status_of(row) in ENDED: + continue + members = {key_of(agent(row.get("worker"))), *map(key_of, parties(row))} | {claim["session"] for claim in claims if claim["office"] == task} + members.discard("") + for first, second in itertools.combinations(sorted(members), 2): + link(first, second, 1.0) + for place in (row.get("workdir"), row.get("branch")): + if isinstance(place, str) and place.strip(): + places.setdefault(place.strip(), set()).update(members) + for members in places.values(): + for first, second in itertools.combinations(sorted(members), 2): + link(first, second, 1.0) + for node in nodes: + if node["kind"] == "subagent" and node["parent"] in activity: + activity[node["parent"]] += 1.0 + order = chain(names, weights, activity) + gaps = [1.0 / (1.0 + pair_weight(weights, first, second)) for first, second in zip(order, order[1:] + order[:1], strict=True)] + total = sum(gaps) or 1.0 + top = max(activity.values(), default=0.0) + angle = -math.pi / 2 + orbit = {} + for name, gap in zip(order, gaps, strict=True): + share = activity[name] / top if top > 0 else 0.0 + orbit[name] = {"angle": round(angle, 4), "radius": round(OUTER - (OUTER - INNER) * share, 2)} + angle += 2 * math.pi * gap / total + return {"orbit": orbit, "order": order} + + +def track(nodes: list[dict], radio: list[dict], tasks: dict[str, dict], claims: list[dict], span: tuple[float, float], directory: Directory, steps: int) -> list[dict]: + """Args: + nodes: graph nodes as affinity takes them. + radio: rows with ts, source and targets as node keys, sorted by ts. + tasks: register rows. + claims: claims. + span: the first and the last moment to sample. + directory: resolves a name to its node key at a moment. + steps: the intervals between the samples; 0 samples nothing. + + Returns: + steps + 1 samples {ts, orbit}, evenly spaced over span, each orbit the + affinity layout of the radio heard up to its moment. + """ + if steps <= 0: + return [] + start, end = span + stamps = [row["ts"] for row in radio] + samples = [] + for moment in [start + (end - start) * index / steps for index in range(steps)] + [end]: + heard = radio[: bisect.bisect_right(stamps, moment)] + layout = affinity(nodes, heard, tasks, claims, moment, functools.partial(directory.key, ts=moment)) + samples.append({"ts": moment, "orbit": layout["orbit"]}) + return samples diff --git a/hub/kvb_hub/phrasing.py b/hub/kvb_hub/phrasing.py new file mode 100644 index 0000000..f025f29 --- /dev/null +++ b/hub/kvb_hub/phrasing.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import re + +from .fields import LINE + +SPEECH_LIMIT = 1000 +SPOKEN_TEXT = 500 +SPOKEN_NAME = 60 +SPOKEN_GROUP = 120 +ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"} +CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE) +HUB_MARK = re.compile(r"\[kvb-hub msg [0-9a-f]{16}\]") +HUB_FILE = re.compile(r"\s+file: /\S*$") +SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT)) + + +def clip(text: str, limit: int) -> str: + words = " ".join(text.split()) + if len(words) <= limit: + return words + return words[:limit].rsplit(" ", 1)[0] or words[:limit] + + +def spoken_text(cls: str, text: str) -> str: + body = HUB_MARK.sub("", text) + if body != text: + body = HUB_FILE.sub("", body) + if cls in ("mayday", *ALARM_WORDS): + body = CALLS.sub("", body.strip()) + body = clip(body, SPOKEN_TEXT) + return "" if body == "-" else body + + +def phrase(cls: str, sender: str, called: list[str], text: str) -> str: + """Args: + cls: the radio class of the row: broadcast, direct, security, pan-pan + or mayday. + sender: the sender's name valid at the row's time. + called: the names of the called stations valid at the row's time. + text: the radio text. + + Returns: + The row in maritime radio phraseology (SMCP style), one line of at + most SPEECH_LIMIT characters. + """ + station = clip(sender, SPOKEN_NAME) or "unknown station" + if cls == "mayday": + head, end = f"MAYDAY MAYDAY MAYDAY, this is {station} {station} {station}", "over" + elif cls in ALARM_WORDS: + word = ALARM_WORDS[cls] + head, end = f"{word} {word} {word}, all stations, this is {station}", "over" + elif cls == "direct" and called: + group = clip(" and ".join(clip(name, SPOKEN_NAME) for name in called), SPOKEN_GROUP) + head, end = f"{group}, {group}, this is {station}", "over" + else: + head, end = f"all stations, this is {station}", "out" + return ", ".join(part for part in (head, spoken_text(cls, text), end) if part) diff --git a/hub/kvb_hub/records.py b/hub/kvb_hub/records.py new file mode 100644 index 0000000..d38371c --- /dev/null +++ b/hub/kvb_hub/records.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import datetime +import hashlib +import json +import os +import tempfile +from pathlib import Path + + +def stamp() -> str: + return datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds") + + +def write_atomic(path: Path, data: bytes) -> None: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix=f".{path.name}-", delete=False) as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(handle.name, path) + + +def read_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, RecursionError): + return None + + +def parse_line(line: str) -> dict | None: + try: + row = json.loads(line) + except (ValueError, RecursionError): + return None + return row if isinstance(row, dict) else None + + +def read_jsonl(path: Path) -> list[dict]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, ValueError): + return [] + return [row for row in map(parse_line, lines) if row is not None] + + +def epoch(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value < 1e11: + return None + return float(value) + + +def iso_epoch(value: object) -> float | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=datetime.UTC) + return parsed.timestamp() + + +def shown(value: object) -> str: + if value is None or value == "": + return "-" + return str(value) + + +def digest(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() diff --git a/hub/kvb_hub/register.py b/hub/kvb_hub/register.py new file mode 100644 index 0000000..0ac7088 --- /dev/null +++ b/hub/kvb_hub/register.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +from .council import agent +from .fields import ORDER_NAME +from .records import read_json, shown + +REGISTER = "rasci.json" +OPERATOR = "operator/" +ENDED = ("done", "stopped") + + +def read_register(path: Path) -> dict[str, dict]: + data = read_json(path) + if not isinstance(data, dict): + return {} + return {task: row for task, row in data.items() if isinstance(row, dict)} + + +def status_of(row: dict) -> str: + if "status" not in row: + return "queued" + return str(row["status"]) + + +def holders(row: dict, letter: str) -> str: + roles = row.get("roles") + if not isinstance(roles, dict): + return "-" + names = sorted((str(p) for p, v in roles.items() if isinstance(v, str) and letter in v), key=str.lower) + return shown(", ".join(names)) + + +def parties(row: dict) -> dict[str, str]: + roles = row.get("roles") + if not isinstance(roles, dict): + return {} + found: dict[str, str] = {} + for party, letters in roles.items(): + name = agent(party) + if name and isinstance(letters, str): + found[name] = found.get(name, "") + letters + return found + + +def is_open(task: str, row: dict, operator: bool) -> bool: + return task.startswith(OPERATOR) == operator and status_of(row) not in ENDED + + +def operator_rows(tasks: dict[str, dict]) -> list[dict]: + return [ + { + "task": task, + "command": shown(row.get("title")), + "directory": shown(row.get("workdir")), + "why": shown(row.get("description")), + "check": shown(row.get("check")), + "status": status_of(row), + } + for task, row in sorted(tasks.items()) + if is_open(task, row, True) + ] + + +def runnable(row: dict) -> bool: + return row["command"] != "-" and row["directory"].startswith("/") and row["check"] != "decision" + + +def pinned(item: tuple[str, dict]) -> tuple[bool, int, str]: + task, row = item + prio = row.get("prio") + if isinstance(prio, int) and not isinstance(prio, bool): + return False, prio, task + return True, 0, task + + +def register_row(task: str, row: dict) -> dict: + results = row.get("key_results") + if not isinstance(results, list): + results = [] + return { + "task": task, + "title": shown(row.get("title")), + "status": status_of(row), + "worker": shown(row.get("worker")), + "responsible": holders(row, "R"), + "accountable": holders(row, "A"), + "objective": shown(row.get("objective")), + "branch": shown(row.get("branch")), + "workdir": shown(row.get("workdir")), + "met": sum(1 for kr in results if isinstance(kr, dict) and kr.get("met") is True), + "total": len(results), + } + + +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]: + 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: + names.append(worker) + return names diff --git a/hub/kvb_hub/review_page.py b/hub/kvb_hub/review_page.py new file mode 100644 index 0000000..2b9e5eb --- /dev/null +++ b/hub/kvb_hub/review_page.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import re +from html.parser import HTMLParser + +DIGITS = re.compile(r"[0-9]+") +TEXTS = ("path", "old", "add", "del", "tag", "placeholder") + + +class ReviewPage(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.files: list[dict] = [] + self.section = "" + self.details: list[str] = [] + self.spans: list[str] = [] + self.file: dict | None = None + self.texts: dict[str, list[str]] = {} + self.row = "" + self.cells: list[str] | None = None + self.cell: list[str] | None = None + self.detail = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + classes = (values.get("class") or "").split() + first = classes[0] if classes else "" + if tag == "section": + self.section = values.get("id") or "" + elif tag == "details": + if first == "file" and "commit" not in self.details: + self.file = {"section": self.section, "status": "", "tags": [], "lines": []} + self.texts = {name: [] for name in TEXTS} + self.spans = [] + self.details.append(first) + elif self.file is None: + return + elif tag == "span": + self.spans.append(first) + if first == "badge": + self.file["status"] = values.get("title") or "" + elif tag == "p" and first == "placeholder": + self.detail = True + elif tag == "tr": + self.row, self.cells = first, [] + elif tag == "td" and self.cells is not None: + self.cell = [] + + def handle_data(self, data: str) -> None: + if self.file is None: + return + if self.cell is not None: + self.cell.append(data) + elif self.detail: + self.texts["placeholder"].append(data) + elif self.spans and self.spans[-1] in self.texts: + self.texts[self.spans[-1]].append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "section": + self.section = "" + elif tag == "details" and self.details: + if self.details.pop() == "file" and self.file is not None: + self.files.append(self.finish(self.file)) + self.file = None + elif self.file is None: + return + elif tag == "span" and self.spans: + if self.spans.pop() == "tag": + self.file["tags"].append("".join(self.texts["tag"]).strip()) + self.texts["tag"] = [] + elif tag == "p": + self.detail = False + elif tag == "td" and self.cell is not None and self.cells is not None: + self.cells.append("".join(self.cell)) + self.cell = None + elif tag == "tr" and self.cells is not None: + self.file["lines"].append(diff_line(self.row, self.cells)) + self.cells = None + + def finish(self, file: dict) -> dict: + text = {name: "".join(parts).strip() for name, parts in self.texts.items()} + counts = {name: DIGITS.search(text[name]) for name in ("add", "del")} + numbers = {name: int(match.group()) if match else 0 for name, match in counts.items()} + return {**file, "path": text["path"], "old": text["old"], "added": numbers["add"], "removed": numbers["del"], "detail": text["placeholder"]} + + +def diff_line(kind_name: str, cells: list[str]) -> dict: + if len(cells) >= 4: + return {"kind": kind_name, "old": cells[0].strip(), "new": cells[1].strip(), "text": cells[3]} + return {"kind": kind_name, "old": "", "new": "", "text": cells[-1].strip() if cells else ""} + + +def review_files(page: bytes) -> list[dict]: + parser = ReviewPage() + parser.feed(page.decode("utf-8", errors="replace")) + parser.close() + return parser.files diff --git a/hub/kvb_hub/reviewing.py b/hub/kvb_hub/reviewing.py new file mode 100644 index 0000000..f06a2bb --- /dev/null +++ b/hub/kvb_hub/reviewing.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import datetime +import os +import stat +import time +from pathlib import Path + +from .errors import Conflict, Missing, ToolError +from .fields import WAIT_CAP, bounded, decode, expect, field, seconds +from .review_page import review_files +from .signed import review_signed + +PAGE_LIMIT = 32 << 20 +NOTES_LIMIT = 100_000 +SCORE_LIMIT = 500 +GATES = ("pass", "fail") +REQUEST = ("repo", "worktree", "branch", "tree", "title", "page", "notes", "gate", "score") +LISTED = ("id", "created", "worktree", "branch", "tree", "title") +STATE = ("id", "status", "tree", "decided") + + +def found(review: dict | None, review_id: str) -> dict: + if review is None: + raise ToolError(f"no review {review_id}") + return review + + +def state_of(review: dict) -> dict: + return {key: review[key] for key in STATE} + + +def review_row(review: dict) -> dict: + created = datetime.datetime.fromisoformat(review["created"]).timestamp() + return {**{key: review[key] for key in LISTED}, "notes": review["notes"], "created_ts": created, "files": review.get("files")} + + +class Reviewing: + def read_page(self, page: str) -> bytes: + path = Path(page) + resolved = path.resolve() + inside = ".." not in path.parts and path.is_relative_to(self.pages) and resolved.is_relative_to(self.pages.resolve()) + if not inside or path.suffix != ".html" or resolved.suffix != ".html": + raise ToolError(f"page must be an .html file under {self.pages}") + try: + descriptor = os.open(resolved, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except OSError as error: + raise ToolError(f"page {page}: {error.strerror}") from error + with os.fdopen(descriptor, "rb") as handle: + if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode): + raise ToolError(f"page {page} is not a regular file") + data = handle.read(PAGE_LIMIT + 1) + if len(data) > PAGE_LIMIT: + raise ToolError(f"page {page} exceeds {PAGE_LIMIT} bytes") + return data + + def request(self, arguments: dict) -> dict: + expect(arguments, REQUEST) + fields = {name: field(arguments, name) for name in ("repo", "worktree", "branch", "tree", "title")} + notes = arguments["notes"] + if not isinstance(notes, str) or len(notes) > NOTES_LIMIT: + raise ToolError(f"notes must be text of at most {NOTES_LIMIT} characters") + if arguments["gate"] not in GATES: + raise ToolError("gate must be pass or fail") + score = " ".join(bounded(arguments, "score", SCORE_LIMIT).split()) + 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"]}) + return {"id": review["id"], "status": review["status"], "url": self.url()} + + def review_proof(self, review_id: str) -> dict: + review = self.reviews.get(review_id) + if review is None: + raise Missing(review_id) + return {**state_of(review), "proof": review.get("proof")} + + def verdict(self, review_id: str, status: str, body: bytes) -> dict: + review = self.reviews.get(review_id) + if review is None: + raise Missing(review_id) + proof = None + if status == "approved": + payload = decode(body) + expect(payload, ("proof",)) + proof = self.covered(payload["proof"], [review_signed(review)], time.time()) + decided, changed = self.reviews.decide(review_id, {"status": status, "proof": proof}) + if not changed: + raise Conflict(state_of(decided)) + return state_of(decided) + + def status(self, arguments: dict) -> dict: + expect(arguments, ("id",)) + review_id = field(arguments, "id") + return state_of(found(self.reviews.get(review_id), review_id)) + + def wait(self, arguments: dict) -> dict: + expect(arguments, ("id", "timeout_seconds")) + review_id = field(arguments, "id") + timeout = seconds(arguments["timeout_seconds"], WAIT_CAP) + return state_of(found(self.reviews.wait(review_id, timeout), review_id)) + + def pending(self, arguments: dict) -> dict: + expect(arguments, ()) + return {"reviews": [{key: review[key] for key in LISTED} for review in self.reviews.pending()]} diff --git a/hub/kvb_hub/rules.py b/hub/kvb_hub/rules.py new file mode 100644 index 0000000..f7fafd6 --- /dev/null +++ b/hub/kvb_hub/rules.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import fnmatch +import time + +from .errors import Conflict, Missing, ToolError +from .fields import COMMAND_LIMIT, PATH_LIMIT, bounded, decode, expect +from .signed import signing + +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: + 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) + + +def rule_fields(raw: dict, now: float) -> dict: + kind_name, expires = raw["kind"], raw["expires"] + if kind_name not in RULE_KINDS: + raise ToolError(f"kind must be one of {', '.join(RULE_KINDS)}") + tool_name = bounded(raw, "tool", 200).strip() + if (kind_name == "ask") != bool(tool_name): + raise ToolError("an ask rule names a tool; review and decision rules leave tool empty") + pattern, scope = bounded(raw, "pattern", COMMAND_LIMIT), bounded(raw, "scope", PATH_LIMIT) + if not pattern.strip() or not scope.strip() or "\n" in pattern or "\n" in scope: + raise ToolError("pattern and scope must be one non-empty line each") + if isinstance(expires, bool) or not isinstance(expires, int | float) or not now + 1 < expires <= now + signing.RULE_LIFETIME: + raise ToolError("expires must be a moment within the next 366 days") + return {"kind": kind_name, "tool": tool_name, "pattern": pattern, "scope": scope, "expires": int(expires)} + + +class Rules: + 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} + + def add_rule(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("rule", "assertion", "enabled")) + rule, assertion = payload["rule"], payload["assertion"] + 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") + 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}) + + def rule_action(self, rule_id: str, verb: str) -> dict: + if verb == "delete": + if not self.rules.remove(rule_id): + raise Missing(rule_id) + return {"id": rule_id, "deleted": True} + record = self.rules.change(rule_id, {"enabled": verb == "enable"}) + if record is None: + raise Missing(rule_id) + return record diff --git a/hub/kvb_hub/settings.py b/hub/kvb_hub/settings.py new file mode 100644 index 0000000..1f525e6 --- /dev/null +++ b/hub/kvb_hub/settings.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import copy +import json +import re +import threading +from collections.abc import Callable +from pathlib import Path +from urllib.parse import urlsplit + +from .council import CLASSES +from .errors import ToolError +from .fields import FIELDS, NAME_LIMIT, expect +from .records import read_json, write_atomic + +THEMES = ("sky", "light", "dark", "custom") +TONES = ("mayday", "review", "direct", "radio") +COLOR = re.compile(r"#[0-9a-f]{6}") +CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]") +MIDNIGHT = "24:00" +STREAMS_LIMIT = 20 +STREAM_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&()*+,;=%-]{1,2040}") +HOSTNAME = re.compile(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*") +DEFAULT_PORTS = {"http": 80, "https": 443} +SETTINGS = { + "latitude": 0.0, + "longitude": 0.0, + "theme": "sky", + "colors": {"top": "#0b1020", "bottom": "#2b3a67", "accent": "#8fadff"}, + "music": {"on": False, "volume": 0.4}, + "sounds": { + "mayday": {"on": True, "volume": 0.9}, + "review": {"on": True, "volume": 0.6}, + "direct": {"on": True, "volume": 0.5}, + "radio": {"on": False, "volume": 0.25}, + }, + "notify": True, + "streams": [], + "speech": {"on": False, "classes": ["security", "pan-pan", "mayday"], "muted": []}, + "space": { + "graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0}, + "sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0}, + }, +} +SPACE_AXES = ("none", "time", "spawn") +SPACE_LIMITS = { + "graph": {"yaw": (-180.0, 180.0), "pitch": (-90.0, 90.0), "zoom": (0.25, 4.0), "pan_x": (-2000.0, 2000.0), "pan_y": (-2000.0, 2000.0)}, + "sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)}, +} +MUTED_LIMIT = 200 + + +def section(value: object, name: str, keys: tuple[str, ...]) -> dict: + if not isinstance(value, dict): + raise ToolError(f"{name} must be an object") + try: + expect(value, keys) + except ToolError as error: + raise ToolError(f"{name}: {error}") from None + return value + + +def number_in(value: object, name: str, low: float, high: float) -> float: + if isinstance(value, bool) or not isinstance(value, int | float) or not low <= value <= high: + raise ToolError(f"{name} must be a number from {low} to {high}") + return float(value) + + +def flag(value: object, name: str) -> bool: + if not isinstance(value, bool): + raise ToolError(f"{name} must be true or false") + return value + + +def color(value: object, name: str) -> str: + if not isinstance(value, str) or not COLOR.fullmatch(value): + raise ToolError(f"{name} must be a colour like #1a2b3c") + return value + + +def stream_origin(url: object) -> str: + if not isinstance(url, str) or not STREAM_URL.fullmatch(url): + raise ToolError("a stream url must be an http or https URL without spaces or quotes") + parts = urlsplit(url) + try: + port = parts.port + except ValueError: + raise ToolError("a stream url needs a valid port") from None + host = parts.hostname or "" + if parts.username is not None or parts.password is not None or not HOSTNAME.fullmatch(host): + raise ToolError("a stream url needs a plain host name and no user info") + return f"{parts.scheme}://{host}" if port in (None, DEFAULT_PORTS[parts.scheme]) else f"{parts.scheme}://{host}:{port}" + + +def clock_minutes(value: object, name: str, end: bool) -> int: + if end and value == MIDNIGHT: + return 24 * 60 + if not isinstance(value, str) or not CLOCK.fullmatch(value): + raise ToolError(f"{name} must be a time like 07:30") + hours, minutes = value.split(":") + return int(hours) * 60 + int(minutes) + + +def stream_entry(raw: object, index: int) -> dict: + name = f"streams[{index}]" + entry = section(raw, name, ("name", "url", "from", "to", "days", "on")) + label, days = entry["name"], entry["days"] + if not isinstance(label, str) or not FIELDS["label"][0].fullmatch(label): + raise ToolError(f"{name}.name must be one line of 1 to {NAME_LIMIT} characters") + stream_origin(entry["url"]) + if clock_minutes(entry["from"], f"{name}.from", False) == clock_minutes(entry["to"], f"{name}.to", True): + raise ToolError(f"{name} must end at another time than it starts") + if not isinstance(days, list) or not days or len(set(map(str, days))) != len(days) or any(isinstance(day, bool) or not isinstance(day, int) or not 0 <= day <= 6 for day in days): + raise ToolError(f"{name}.days must list distinct weekdays from 0 (Monday) to 6 (Sunday)") + return {"name": label, "url": entry["url"], "from": entry["from"], "to": entry["to"], "days": sorted(days), "on": flag(entry["on"], f"{name}.on")} + + +def tone_setting(raw: object, kind_name: str) -> dict: + entry = section(raw, f"sounds.{kind_name}", ("on", "volume")) + return {"on": flag(entry["on"], f"sounds.{kind_name}.on"), "volume": number_in(entry["volume"], f"sounds.{kind_name}.volume", 0.0, 1.0)} + + +def distinct_texts(value: object, valid: Callable[[str], bool], limit: int) -> bool: + if not isinstance(value, list) or len(value) > limit or not all(isinstance(item, str) and valid(item) for item in value): + return False + return len(set(value)) == len(value) + + +def speech_setting(raw: object) -> dict: + entry = section(raw, "speech", ("on", "classes", "muted")) + classes, muted = entry["classes"], entry["muted"] + if not distinct_texts(classes, lambda name: name in CLASSES, len(CLASSES)): + raise ToolError(f"speech.classes must list distinct classes of {', '.join(CLASSES)}") + if "mayday" not in classes: + raise ToolError("speech.classes must include mayday: a MAYDAY is never quieter than anything else") + if not distinct_texts(muted, FIELDS["agent"][0].fullmatch, MUTED_LIMIT): + raise ToolError(f"speech.muted must list at most {MUTED_LIMIT} distinct agent keys, each one line of 1 to 200 characters") + return {"on": flag(entry["on"], "speech.on"), "classes": [name for name in CLASSES if name in classes], "muted": sorted(muted)} + + +def space_setting(raw: object) -> dict: + entry = section(raw, "space", tuple(SETTINGS["space"])) + graph = section(entry["graph"], "space.graph", tuple(SETTINGS["space"]["graph"])) + sky = section(entry["sky"], "space.sky", tuple(SETTINGS["space"]["sky"])) + if not isinstance(graph["axis"], str) or graph["axis"] not in SPACE_AXES: + raise ToolError(f"space.graph.axis must be one of {', '.join(SPACE_AXES)}") + views = {"graph": graph, "sky": sky} + numbers = {view: {key: number_in(views[view][key], f"space.{view}.{key}", low, high) for key, (low, high) in limits.items()} for view, limits in SPACE_LIMITS.items()} + return {"graph": {"axis": graph["axis"], **numbers["graph"]}, "sky": numbers["sky"]} + + +def valid_settings(raw: object) -> dict: + data = section(raw, "settings", tuple(SETTINGS)) + colors = section(data["colors"], "colors", tuple(SETTINGS["colors"])) + music = section(data["music"], "music", ("on", "volume")) + sounds = section(data["sounds"], "sounds", TONES) + if not isinstance(data["theme"], str) or data["theme"] not in THEMES: + raise ToolError(f"theme must be one of {', '.join(THEMES)}") + if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT: + raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries") + return { + "latitude": number_in(data["latitude"], "latitude", -90.0, 90.0), + "longitude": number_in(data["longitude"], "longitude", -180.0, 180.0), + "theme": data["theme"], + "colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]}, + "music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)}, + "sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES}, + "notify": flag(data["notify"], "notify"), + "streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])], + "speech": speech_setting(data["speech"]), + "space": space_setting(data["space"]), + } + + +def media_origins(settings: dict) -> list[str]: + return sorted({stream_origin(stream["url"]) for stream in settings["streams"]}) + + +class Settings: + def __init__(self, file: Path) -> None: + self.file = file + self.lock = threading.Lock() + stored = read_json(file) + try: + self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored) + except ToolError: + self.current = valid_settings(SETTINGS) + + def get(self) -> dict: + with self.lock: + return copy.deepcopy(self.current) + + def put(self, raw: object) -> dict: + settings = valid_settings(raw) + with self.lock: + write_atomic(self.file, json.dumps(settings, indent=1, sort_keys=True).encode("utf-8")) + self.current = settings + return copy.deepcopy(settings) diff --git a/hub/kvb_hub/signed.py b/hub/kvb_hub/signed.py new file mode 100644 index 0000000..c82b73b --- /dev/null +++ b/hub/kvb_hub/signed.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +SIGNING_SPEC = importlib.util.spec_from_file_location("kvb_hub_signing", Path(__file__).resolve().parents[1] / "client" / "signing.py") +signing = importlib.util.module_from_spec(SIGNING_SPEC) +SIGNING_SPEC.loader.exec_module(signing) + + +def ask_signed(ask: dict) -> dict: + return signing.ask_item(ask["id"], ask["tool"], ask["command"], ask["cwd"], ask["session"]) + + +def review_signed(review: dict) -> dict: + return signing.review_item(review["id"], review.get("repo", ""), review["worktree"], review["branch"], review["tree"]) + + +def operator_signed(row: dict) -> dict: + return signing.operator_item(row["task"], row["command"], row["directory"]) diff --git a/hub/kvb_hub/skill_scripts.py b/hub/kvb_hub/skill_scripts.py new file mode 100644 index 0000000..9e0c0e7 --- /dev/null +++ b/hub/kvb_hub/skill_scripts.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +SCRIPT_TIMEOUT = 60 +OUTPUT_LIMIT = 4000 +SAFE_PATH = "/usr/local/bin:/usr/bin:/bin" +RADIO = "kvb-org-radio/scripts/radio.py" +RASCI = "kvb-org-rasci/scripts/rasci.py" +NIGHT = "kvb-org-sunset/scripts/night.py" +OPERATOR_SCRIPT = "kvb-org-operator/scripts/operator.py" + + +class Runner(Protocol): + def run(self, script: str, args: list[str]) -> dict: ... + + +@dataclass(frozen=True) +class Scripts: + skills: Path + council: Path + home: Path + + def run(self, script: str, args: list[str]) -> dict: + command = " ".join([Path(script).name, *args]) + env = {"PATH": SAFE_PATH, "HOME": str(self.home), "AGENTS_COUNCIL_ROOT": str(self.council), "PYTHONDONTWRITEBYTECODE": "1"} + try: + done = subprocess.run( + [sys.executable, str(self.skills / script), *args], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=SCRIPT_TIMEOUT, + env=env, + stdin=subprocess.DEVNULL, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return {"command": command, "code": -1, "stdout": "", "stderr": str(error)} + return {"command": command, "code": done.returncode, "stdout": done.stdout[-OUTPUT_LIMIT:], "stderr": done.stderr[-OUTPUT_LIMIT:]} diff --git a/hub/kvb_hub/sources.py b/hub/kvb_hub/sources.py new file mode 100644 index 0000000..bda8259 --- /dev/null +++ b/hub/kvb_hub/sources.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import time + +from .council import HUMAN, agent, read_claims +from .crons import cron_state, queue_rows, timer_rows +from .errors import ToolError +from .fields import single +from .lifetimes import edge_times, lifetime, session_gone, subagent_rows +from .names import PSEUDO, SESSION_ID, Directory, title_sightings +from .records import epoch, iso_epoch +from .register import REGISTER, read_register +from .tree import assign + + +class Sources: + def subagents(self, query: dict[str, list[str]]) -> dict: + session = single(query, "session") or "" + if not SESSION_ID.fullmatch(session): + raise ToolError("session must be a session id") + now = time.time() + beats = [claim["beat"] for claim in read_claims(self.council) if claim["session"] == session and claim["beat"] is not None] + try: + path = next(iter(sorted(self.transcripts.root.glob(f"*/{session}.jsonl"))), None) + except OSError: + path = None + times = edge_times(path) if path is not None else (None, None, None) + life = lifetime(times, session_gone(max(beats, default=None), times[2], now)) + return {"session": session, **life, "subagents": subagent_rows(self.transcripts.root, session)} + + def directory(self, claims: list[dict], news: list[dict], now: float) -> Directory: + claimed = {claim["session"] for claim in claims} + sightings = title_sightings(self.transcripts.root, claimed, now) if self.names.due(now) else [] + sightings += [(claim["session"], claim["agent"], iso_epoch(claim["since"]), "claim") for claim in claims if claim["agent"] and claim["session"] != "-"] + self.names.observe(sorted(sightings, key=lambda sighting: (sighting[2] is None, sighting[2] or 0.0)), now) + known = {entry["name"] for entry in self.names.listed()} + radio = [] + for row in news: + name, ts = agent(row.get("from")), epoch(row.get("ts")) + if name and name != HUMAN and name not in known and ts is not None: + radio.append((f"{PSEUDO}{name}", name, ts, "radio")) + known.add(name) + self.names.observe(radio, now) + return Directory(self.names.listed()) + + def repositories(self) -> dict: + tasks = read_register(self.council / REGISTER) + return assign(self.tree.get(), tasks, read_claims(self.council), self.reviews.listed()) + + def schedules(self, claims: list[dict], now: float) -> list[dict]: + beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None} + rows = [cron_state(cron, beats, now) for cron in self.transcripts.schedules()] + return rows + timer_rows(self.timers) + queue_rows(self.council) diff --git a/hub/kvb_hub/timeline.py b/hub/kvb_hub/timeline.py new file mode 100644 index 0000000..acc1e85 --- /dev/null +++ b/hub/kvb_hub/timeline.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import time + +from .asking import ask_state +from .council import MAYDAY_ACTIVE, NEWS, radio_rows, read_claims, sessions +from .crons import cron_event +from .errors import Missing, ToolError +from .events import event, event_filters, wanted +from .fields import instant, single, whole +from .names import Directory +from .records import iso_epoch, read_jsonl +from .register import REGISTER, operator_rows, read_register +from .signed import signing +from .transcripts import STREAM_CUT, STREAM_KINDS, matches + +STREAM_LIMIT = 2000 +EVENT_LIMIT = 3000 + + +class Timeline: + def stream(self, query: dict[str, list[str]]) -> dict: + self.transcripts.refresh() + names = sessions(read_claims(self.council)) + wanted = single(query, "kind") + kinds = None if not wanted else set(wanted.split(",")) + if kinds is not None and not kinds <= set(STREAM_KINDS): + raise ToolError(f"kind must be a comma list of {', '.join(STREAM_KINDS)}") + filters = { + "agent": single(query, "agent") or "", + "session": single(query, "session") or "", + "kinds": kinds, + "q": (single(query, "q") or "").lower(), + "begin": instant(query, "from"), + "end": instant(query, "to"), + } + limit = min(whole(query, "limit") or STREAM_LIMIT, STREAM_LIMIT) + picked = [ + {**entry, "agent": names.get(entry["session"], ""), "text": entry["text"][:STREAM_CUT], "truncated": len(entry["text"]) > STREAM_CUT} + for entry in self.transcripts.since(whole(query, "after") or 0) + if matches(entry, names.get(entry["session"], ""), filters) + ] + picked.sort(key=lambda entry: (entry["ts"], entry["id"])) + return {"entries": picked[-limit:], "last": self.transcripts.last(), "sessions": names} + + def entry(self, entry_id: int) -> dict: + record = self.transcripts.entry(entry_id) + if record is None: + raise Missing(str(entry_id)) + return {"id": record["id"], "text": record["text"]} + + def timeline(self, begin: float, end: float, now: float, tasks: dict[str, dict], claims: list[dict], radio: list[dict], directory: Directory) -> list[dict]: + rows = [] + for index, row in enumerate(radio_rows(radio, begin, end)): + alarm = row["class"] in ("mayday", "pan-pan") and row["ts"] >= end - MAYDAY_ACTIVE + title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'alle'}" + source = directory.key(row["from"], row["ts"]) + who = {"agent": directory.at(source, row["ts"]) or row["from"], "now": directory.now(source), "session": source} + ref = {"to": row["to"], "class": row["class"], "targets": [directory.key(name, row["ts"]) for name in row["to"]]} + rows.append(event({"id": f"radio:{row['ts']}:{index}", "ts": row["ts"], "kind": "radio", "part": row["class"], **who, "title": title, "text": row["text"], "waiting": alarm, "ref": ref})) + for review in self.reviews.listed(): + created = iso_epoch(review["created"]) or now + if created <= end and (review["status"] == "pending" or created >= begin): + ref = {key: review.get(key, "") for key in ("id", "status", "gate", "score", "branch", "worktree", "tree", "files")} + rows.append(event({"id": f"review:{review['id']}", "ts": created, "kind": "review", "title": review["title"], "text": review["notes"][:STREAM_CUT], "waiting": review["status"] == "pending", "ref": ref})) + for ask in self.asks.listed(): + state = ask_state(ask, now) + if ask["created_ts"] <= end and (state["status"] == "pending" or ask["created_ts"] >= begin): + ref = {"id": ask["id"], "status": state["status"], "cwd": ask["cwd"], "tool": ask["tool"], "warnings": signing.warnings(ask["command"])} + who = {"agent": directory.at(ask["session"], ask["created_ts"]), "now": directory.now(ask["session"]), "session": ask["session"]} + rows.append(event({"id": f"ask:{ask['id']}", "ts": ask["created_ts"], "kind": "ask", **who, "title": ask["tool"], "text": ask["command"], "waiting": state["status"] == "pending", "ref": ref})) + for decision in self.decisions.listed(): + if decision["created_ts"] <= end and (decision["status"] == "pending" or decision["created_ts"] >= begin): + ref = {key: decision.get(key, "") for key in ("id", "status", "choice", "recommended", "options")} + text = " · ".join(option["label"] for option in decision["options"]) + source = directory.key(decision["agent"], decision["created_ts"]) + who = {"agent": directory.at(source, decision["created_ts"]) or decision["agent"], "now": directory.now(source), "session": source} + rows.append(event({"id": f"decision:{decision['id']}", "ts": decision["created_ts"], "kind": "decision", **who, "title": decision["question"][:300], "text": text, "waiting": decision["status"] == "pending", "ref": ref})) + held = self.open_jobs() + for row in operator_rows(tasks): + first = self.watch.first(row["task"], now) + if first <= end: + ref = {key: row[key] for key in ("task", "directory", "check", "status")} + rows.append(event({"id": f"operator:{row['task']}", "ts": first, "kind": "operator", "title": row["command"], "text": row["why"], "waiting": row["task"] not in held, "ref": ref})) + rows += [change for change in self.watch.listed() if begin <= change["ts"] <= end] + rows += [cron_event(cron, directory) for cron in self.schedules(claims, now) if cron["created"] <= end and (cron["active"] or cron["created"] >= begin)] + rows += [row for row in self.log.listed() if begin <= row["ts"] <= end] + rows += directory.renames(begin, end) + return sorted(rows, key=lambda row: (row["ts"], row["id"])) + + def events(self, query: dict[str, list[str]]) -> dict: + now = time.time() + begin, end = self.span(query, now) + filters = event_filters(query) + limit = min(whole(query, "limit") or EVENT_LIMIT, EVENT_LIMIT) + after = whole(query, "after") or 0 + self.transcripts.refresh() + claims = read_claims(self.council) + news = read_jsonl(self.council / NEWS) + directory = self.directory(claims, news, now) + filters["aliases"] = directory.aliases(filters["agent"]) if filters["agent"] else set() + tasks = read_register(self.council / REGISTER) + self.watch.see(tasks, now) + rows = [row for row in self.timeline(begin, end, now, tasks, claims, news, directory) if wanted(row, filters)] + live = instant(query, "to") is None + entries = [] + for entry in self.transcripts.since(after) if "transcript" in filters["kinds"] else []: + shaped = event({ + "id": f"transcript:{entry['id']}", "ts": entry["ts"], "kind": "transcript", "part": entry["kind"], "agent": directory.at(entry["session"], entry["ts"]), + "now": directory.now(entry["session"]), "session": entry["session"], "sub": entry["sub"], "title": entry["kind"], "text": entry["text"][:STREAM_CUT], + "ref": {"entry": entry["id"], "truncated": len(entry["text"]) > STREAM_CUT}, + }) + if entry["ts"] >= begin and (live or entry["ts"] <= end) and wanted(shaped, filters): + entries.append(shaped) + return {"events": rows[-limit:], "entries": entries[-limit:], "last": self.transcripts.last(), "sessions": sessions(claims)} diff --git a/hub/kvb_hub/transcripts.py b/hub/kvb_hub/transcripts.py new file mode 100644 index 0000000..edeabc0 --- /dev/null +++ b/hub/kvb_hub/transcripts.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import json +import math +import os +import re +import threading +import time +from collections import deque +from pathlib import Path + +from .records import epoch, iso_epoch, parse_line, shown + +STREAM_KEEP = 20_000 +STREAM_TEXT = 20_000 +STREAM_CUT = 2000 +STREAM_FIRST = 512 << 10 +STREAM_AGE = 7 * 86400 +STREAM_SCAN = 1.0 +STREAM_KINDS = ("user", "assistant", "tool_call", "tool_result", "system") +SCHEDULED = re.compile(r"Scheduled (?:recurring job|one-shot task) ([0-9a-f]{6,32})") +CRON_LIFETIME = 7 * 86400 +LOG_KEEP = 5000 + + +def block_text(content: object) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + parts = [] + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + parts.append(block["text"]) + elif isinstance(block, dict) and block.get("type") == "image": + parts.append("[image]") + return "\n".join(parts) + + +def transcript_parts(row: dict) -> list[tuple[str, str]]: + role = row.get("type") + if role == "system": + text = block_text(row.get("content")) + return [("system", text)] if text else [] + message = row.get("message") + if role not in ("user", "assistant") or not isinstance(message, dict): + return [] + content = message.get("content") + if isinstance(content, str): + return [("system" if row.get("isMeta") else str(role), content)] if content else [] + found = [] + for block in content if isinstance(content, list) else []: + part = block.get("type") if isinstance(block, dict) else None + if part == "text": + found.append((str(role), block_text([block]))) + elif part == "tool_use": + found.append(("tool_call", f"{shown(block.get('name'))} {json.dumps(block.get('input'), ensure_ascii=False)}")) + elif part == "tool_result": + found.append(("tool_result", block_text(block.get("content")))) + return [(name, text) for name, text in found if text] + + +def matches(entry: dict, name: str, filters: dict) -> bool: + begin, end = filters["begin"], filters["end"] + return all( + ( + not filters["agent"] or name == filters["agent"], + not filters["session"] or entry["session"] == filters["session"], + filters["kinds"] is None or entry["kind"] in filters["kinds"], + not filters["q"] or filters["q"] in entry["text"].lower(), + begin is None or entry["ts"] >= begin, + end is None or entry["ts"] <= end, + ) + ) + + +def text_of(value: object) -> str: + return value[:STREAM_CUT] if isinstance(value, str) else "" + + +class Transcripts: + def __init__(self, root: Path) -> None: + self.root = root + self.lock = threading.Lock() + self.offsets: dict[Path, int] = {} + self.entries: deque[dict] = deque(maxlen=STREAM_KEEP) + self.crons: dict[str, dict] = {} + self.count = 0 + self.scanned = -math.inf + + def refresh(self) -> None: + with self.lock: + if time.monotonic() - self.scanned < STREAM_SCAN: + return + self.scanned = time.monotonic() + try: + paths = sorted(self.root.rglob("*.jsonl")) + except OSError: + paths = [] + for path in paths: + self.read(path) + + def start(self, path: Path, info: os.stat_result) -> int: + offset = self.offsets.get(path) + if offset is None: + recent = info.st_mtime >= time.time() - STREAM_AGE + return max(info.st_size - STREAM_FIRST, 0) if recent else info.st_size + return 0 if info.st_size < offset else offset + + def read(self, path: Path) -> None: + try: + info = path.stat() + begin = self.start(path, info) + with path.open("rb") as handle: + handle.seek(begin) + data = handle.read(max(info.st_size - begin, 0)) + except OSError: + return + end = data.rfind(b"\n") + 1 + self.offsets[path] = begin + end + for raw in data[:end].splitlines(): + row = parse_line(raw.decode("utf-8", errors="replace")) + if row is not None: + self.add(path, row) + + def add(self, path: Path, row: dict) -> None: + ts = iso_epoch(row.get("timestamp")) + if ts is None: + return + session = row.get("sessionId") if isinstance(row.get("sessionId"), str) else path.stem + sub = row.get("agentId") if isinstance(row.get("agentId"), str) else "" + message = row.get("message") + content = message.get("content") if isinstance(message, dict) else None + for block in content if isinstance(content, list) else []: + if isinstance(block, dict) and block.get("type") == "tool_use": + self.schedule(block, ts, session, sub) + elif isinstance(block, dict) and block.get("type") == "tool_result": + self.scheduled(block) + for name, text in transcript_parts(row): + self.count += 1 + self.entries.append({"id": self.count, "ts": ts, "session": session, "sub": sub, "kind": name, "text": text[:STREAM_TEXT]}) + + def end(self, kind_name: str, session: str, sub: str, ts: float) -> None: + for cron in self.crons.values(): + if cron["type"] == kind_name and cron["session"] == session and cron["sub"] == sub and cron["deleted"] is None: + cron["deleted"] = ts + + def schedule(self, block: dict, ts: float, session: str, sub: str) -> None: + name, data, key = block.get("name"), block.get("input"), block.get("id") + if not isinstance(data, dict) or not isinstance(key, str): + return + base = {"key": key, "session": session, "sub": sub, "created": ts, "deleted": None, "until": None, "job": "", "expression": "", "prompt": "", "reason": ""} + delay = data.get("delaySeconds") + if name == "CronCreate": + self.crons[key] = {**base, "type": "cron", "expression": text_of(data.get("cron")), "prompt": text_of(data.get("prompt")), "until": ts + CRON_LIFETIME} + elif name == "CronDelete": + for cron in self.crons.values(): + if cron["type"] == "cron" and cron["job"] and cron["job"] == data.get("id") and cron["deleted"] is None: + cron["deleted"] = ts + elif name == "ScheduleWakeup": + self.end("wakeup", session, sub, ts) + if data.get("stop") is not True and epoch(delay) is not None: + wakeup = {"type": "wakeup", "expression": f"in {int(delay)} s", "prompt": text_of(data.get("prompt")), "reason": text_of(data.get("reason")), "until": ts + float(delay)} + self.crons[key] = {**base, **wakeup} + elif name == "Bash" and data.get("run_in_background") is True: + self.crons[key] = {**base, "type": "background", "expression": text_of(data.get("description"))[:200] or "Bash", "prompt": text_of(data.get("command"))} + while len(self.crons) > LOG_KEEP: + del self.crons[min(self.crons, key=lambda name: self.crons[name]["created"])] + + def scheduled(self, block: dict) -> None: + cron = self.crons.get(block.get("tool_use_id")) + found = SCHEDULED.search(block_text(block.get("content"))) + if cron is not None and cron["type"] == "cron" and found: + cron["job"] = found.group(1) + + def schedules(self) -> list[dict]: + with self.lock: + return [dict(cron) for cron in self.crons.values()] + + def since(self, after: int) -> list[dict]: + with self.lock: + return [entry for entry in self.entries if entry["id"] > after] + + def entry(self, entry_id: int) -> dict | None: + with self.lock: + return next((dict(entry) for entry in self.entries if entry["id"] == entry_id), None) + + def first(self) -> float | None: + with self.lock: + return min((entry["ts"] for entry in self.entries), default=None) + + def last(self) -> int: + with self.lock: + return self.count diff --git a/hub/kvb_hub/tree.py b/hub/kvb_hub/tree.py new file mode 100644 index 0000000..bcfa6b6 --- /dev/null +++ b/hub/kvb_hub/tree.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os +import re +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +from .council import agent +from .register import ENDED, status_of +from .skill_scripts import SAFE_PATH + +TREE_TTL = 60.0 +TREE_DEPTH = 6 +GIT_TIMEOUT = 10 + + +def git(cwd: Path, args: list[str], home: Path) -> str | None: + env = {"PATH": SAFE_PATH, "HOME": str(home), "GIT_CONFIG_NOSYSTEM": "1", "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"} + try: + done = subprocess.run( + ["git", "-c", "safe.directory=*", "-c", "core.fsmonitor=false", "-C", str(cwd), *args], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=GIT_TIMEOUT, + env=env, + stdin=subprocess.DEVNULL, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return None + return done.stdout if done.returncode == 0 else None + + +def parse_worktrees(text: str) -> list[dict]: + found = [] + for block in text.strip().split("\n\n"): + lines = dict(line.partition(" ")[::2] for line in block.splitlines() if line) + if "worktree" not in lines or "bare" in lines: + continue + found.append({ + "path": lines["worktree"], "head": lines.get("HEAD", ""), "branch": lines.get("branch", "").removeprefix("refs/heads/"), + "prunable": "prunable" in lines, + }) + return found + + +def tracking(text: str) -> tuple[int, int, bool]: + counts = {name: int(number) for name, number in re.findall(r"(ahead|behind) ([0-9]+)", text)} + return counts.get("ahead", 0), counts.get("behind", 0), text.strip() == "gone" + + +def parse_refs(text: str) -> list[dict]: + found = [] + for line in text.splitlines(): + parts = line.split("\x00") + if len(parts) != 4: + continue + ahead, behind, gone = tracking(parts[3]) + found.append({"name": parts[0], "head": parts[1], "upstream": parts[2], "ahead": ahead, "behind": behind, "gone": gone}) + return found + + +def linked(root: Path) -> list[Path]: + found = [] + for top, dirs, names in os.walk(root): + here = Path(top) + if ".git" in names or ".git" in dirs: + found.append(here) + dirs[:] = [] + elif len(here.relative_to(root).parts) >= TREE_DEPTH: + dirs[:] = [] + else: + dirs.sort() + return found + + +def dirty(path: Path, home: Path) -> bool | None: + # agents write these repos, and status runs every filter.*.clean command the config names + if git(path, ["config", "--get-regexp", r"^filter\."], home) is not None: + return None + status = git(path, ["status", "--porcelain=v1", "--untracked-files=normal", "--ignore-submodules=all"], home) + return None if status is None else bool(status.strip()) + + +def repo_entry(path: Path, label: str, home: Path) -> dict: + trees = parse_worktrees(git(path, ["worktree", "list", "--porcelain"], home) or "") + refs = git(path, ["for-each-ref", "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(upstream:track,nobracket)", "refs/heads"], home) + branches = parse_refs(refs or "") + for tree in trees: + tree["dirty"] = None if tree["prunable"] else dirty(Path(tree["path"]), home) + known = {branch["name"] for branch in branches} + branches += [{"name": tree["branch"], "head": tree["head"], "upstream": "", "ahead": 0, "behind": 0, "gone": False} for tree in trees if tree["branch"] and tree["branch"] not in known] + for branch in branches: + branch["worktrees"] = [tree for tree in trees if tree["branch"] == branch["name"]] + return {"path": str(path), "name": label, "branches": branches, "detached": [tree for tree in trees if not tree["branch"]]} + + +def scan_tree(repos: Path, worktrees: Path, home: Path) -> dict: + root = repos.resolve() + try: + mains = sorted(path.resolve() for path in root.glob("*/*/*") if (path / ".git").is_dir()) + except OSError: + mains = [] + entries = {str(path): repo_entry(path, str(path.relative_to(root)), home) for path in mains if path.is_relative_to(root)} + seen = {tree["path"] for entry in entries.values() for branch in entry["branches"] for tree in branch["worktrees"]} + seen |= {tree["path"] for entry in entries.values() for tree in entry["detached"]} + for path in linked(worktrees.resolve()): + if str(path) in seen: + continue + common = git(path, ["rev-parse", "--path-format=absolute", "--git-common-dir"], home) + main = Path(common.strip()).parent if common else None + if main is not None and str(main) not in entries and (main / ".git").is_dir(): + entries[str(main)] = repo_entry(main, str(main), home) + return {"repos": sorted(entries.values(), key=lambda entry: entry["name"]), "scanned": time.time()} + + +def inside(path: str, folder: str) -> bool: + return path == folder or path.startswith(f"{folder.rstrip('/')}/") + + +def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict: + current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED} + holders: dict[str, set[str]] = {} + for task, row in current.items(): + names = {agent(row.get("worker"))} | {claim["agent"] for claim in claims if claim["office"] == task} + holders[task] = {name for name in names if name} + latest: dict[str, dict] = {} + for review in reviews: + latest[review["worktree"]] = {"status": review["status"], "gate": review.get("gate", ""), "title": review["title"]} + repos = [] + for entry in scan["repos"]: + branches = [] + for branch in entry["branches"]: + trees = [] + for tree in branch["worktrees"]: + workers = set() + for task, row in current.items(): + workdir, name = row.get("workdir"), row.get("branch") + by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"]) + by_branch = not isinstance(workdir, str) and name == branch["name"] + if by_dir or by_branch: + workers |= holders[task] + trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])}) + branches.append({**branch, "worktrees": trees}) + repos.append({**entry, "branches": branches}) + return {"repos": repos, "scanned": scan["scanned"]} + + +@dataclass +class Tree: + repos: Path + worktrees: Path + home: Path + lock: threading.Lock + cached: dict | None + scanned: float + + def get(self) -> dict: + with self.lock: + if self.cached is None or time.monotonic() - self.scanned >= TREE_TTL: + self.cached = scan_tree(self.repos, self.worktrees, self.home) + self.scanned = time.monotonic() + return self.cached diff --git a/hub/kvb_hub/view.py b/hub/kvb_hub/view.py new file mode 100644 index 0000000..0062a63 --- /dev/null +++ b/hub/kvb_hub/view.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import time + +from .asking import ask_row, decision_row +from .council import ( + MAYDAY_ACTIVE, + NEWS, + grants, + night_mode, + radio_rows, + read_claims, + sessions, + silence_of, +) +from .errors import ToolError +from .events import VIEWS +from .fields import decode, instant, single +from .graph import graph +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 .reviewing import review_row +from .settings import media_origins +from .skill_scripts import OUTPUT_LIMIT + +RADIO_WINDOW = 86400 +JOBS_SHOWN = 20 +INDEX_CSP = ( + "default-src 'none'; script-src 'nonce-{0}'; style-src 'nonce-{0}'; connect-src 'self'; media-src {1}; " + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'" +) + + +class View: + def span(self, query: dict[str, list[str]], now: float) -> tuple[float, float]: + end = instant(query, "to") + end = now if end is None else min(end, now) + begin = instant(query, "from") + begin = end - RADIO_WINDOW if begin is None else begin + if begin > end: + raise ToolError("from must not lie after to") + return begin, end + + def window(self, query: dict[str, list[str]]) -> dict: + now = time.time() + begin, end = self.span(query, now) + sampled = single(query, "track") + if sampled not in (None, "1"): + raise ToolError("track must be 1 or left out") + return self.state(begin, end, now, TRACK_STEPS if sampled else 0) + + def first(self, news: list[dict], history: list[dict], claims: list[dict], now: float) -> float: + moments = [epoch(row.get("ts")) for row in news] + [epoch(row.get("start")) for row in history] + moments += [iso_epoch(claim["since"]) for claim in claims] + [self.transcripts.first()] + moments += [iso_epoch(record["created"]) for ledger in (self.reviews, self.asks, self.decisions, self.messages) for record in ledger.listed()] + return min([now, *(moment for moment in moments if moment is not None)]) + + def state(self, begin: float, end: float, now: float, steps: int) -> dict: + tasks = read_register(self.council / REGISTER) + news = read_jsonl(self.council / NEWS) + history = read_jsonl(self.council / "queue-history.jsonl") + claims = read_claims(self.council) + names = sessions(claims) + directory = self.directory(claims, news, now) + radio = [] + for row in radio_rows(news, begin, end): + source = directory.key(row["from"], row["ts"]) + targets = [directory.key(name, row["ts"]) for name in row["to"]] + called = [directory.at(key, row["ts"]) or name for key, name in zip(targets, row["to"], strict=True)] + spoken = {"speech": phrase(row["class"], directory.at(source, row["ts"]) or row["from"], called, row["text"]), "voice": self.speech.voice_of(source)} + radio.append({**row, "source": source, "targets": targets, "now": directory.now(source), **spoken}) + grant_rows = [{**grant, "target": directory.key(grant["to"], grant["ts"])} for grant in grants(history, (begin, end), claims, tasks)] + self.watch.see(tasks, now) + jobs = [{**{key: value for key, value in job.items() if key != "proof"}, "output": job["output"][-OUTPUT_LIMIT:]} for job in self.jobs.listed()[-JOBS_SHOWN:]] + drawn = graph(radio, grant_rows, tasks, claims, end, directory) + first = self.first(news, history, claims, now) + drawn["track"] = track(drawn["nodes"], radio, tasks, claims, (min(max(begin, first), end), end), directory, steps) + people = {node["id"]: {"name": node["label"], "now": directory.now(node["id"]) or node["label"], "names": node["names"]} for node in drawn["nodes"] if node["kind"] != "subagent"} + settings = self.settings.get() + return { + "settings": settings, + "media": media_origins(settings), + "speech": self.speech.describe(), + "people": people, + "inbox": self.inbox(now, tasks, directory), + "pending": self.batches.listed(), + "rules": [{**{key: value for key, value in rule.items() if key != "proof"}, "signed": rule.get("proof") is not None} for rule in self.rules.listed()], + "approvers": self.keys(), + "jobs": jobs, + "views": VIEWS, + "llm": self.model.enabled(), + "now": now, + "since": begin, + "until": end, + "first": first, + "alarm_since": end - MAYDAY_ACTIVE, + "reviews": [row for row in map(review_row, self.reviews.pending()) if row["created_ts"] <= end], + "asks": [ask_row(ask, names) for ask in self.asks.pending() if ask["expires"] > now and ask["created_ts"] <= end], + "decisions": [decision_row(decision) for decision in self.decisions.pending() if decision["created_ts"] <= end], + "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), + "radio": radio, + "claims": claims, + "grants": grant_rows, + "night": night_mode(self.council), + "silence": silence_of(self.council, now), + "graph": drawn, + } + + def save_settings(self, body: bytes) -> dict: + return self.settings.put(decode(body)) + + def render(self, nonce: str) -> tuple[str, str]: + now = time.time() + state = self.state(now - RADIO_WINDOW, now, now, 0) + page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state) + return page, INDEX_CSP.format(nonce, " ".join(["'self'", *state["media"]])) diff --git a/hub/requirements-piper.txt b/hub/requirements-piper.txt new file mode 100644 index 0000000..d27162a --- /dev/null +++ b/hub/requirements-piper.txt @@ -0,0 +1,19 @@ +piper-tts==1.8.0 \ + --hash=sha256:25b4d3f31ff70c8fa7151908e00aaa5650cbdf16bca8fcf21299f3941b89a7d3 \ + --hash=sha256:3f60c1917de6d8e8033f395878ad3f88f6dfee88a8b05f98971a275f76a38484 +onnxruntime==1.31.1 \ + --hash=sha256:1f3dea47f2f0e5117c2404cea7d21fd253ae7836a3bf167a501cdfbb2600d536 \ + --hash=sha256:0b9a492176886992e0e875f06bc0367e333f5fff11711a2d642e18b02f3009cc +pathvalidate==3.3.1 \ + --hash=sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f +numpy==2.5.4 \ + --hash=sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a \ + --hash=sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2 +protobuf==7.36.2 \ + --hash=sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2 \ + --hash=sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e \ + --hash=sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e +flatbuffers==25.12.19 \ + --hash=sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4 +packaging==26.3 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c diff --git a/hub/requirements.txt b/hub/requirements.txt new file mode 100644 index 0000000..6bbf6aa --- /dev/null +++ b/hub/requirements.txt @@ -0,0 +1 @@ +jinja2==3.1.6 diff --git a/hub/server.py b/hub/server.py new file mode 100755 index 0000000..3441602 --- /dev/null +++ b/hub/server.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Args: + --bind: address the server listens on. + --listen-port: port the server listens on. + --public-port: port browsers and agents reach the hub on; the Host and + Origin headers must name it. + --council: directory holding rasci.json, news.jsonl, queue-history.jsonl, + night.json, radio-silence.json and the claims under cluster/*/ and + work/*/; a missing file leaves its part of the state empty. The board + actions change it through the skill scripts. + --pages: directory every review page must lie in. + --state: writable directory for reviews.json, asks.json, decisions.json, + messages.json, rules.json, jobs.json, marks.json, settings.json, + log.jsonl, the page copies and uploads//. + --host-state: the state directory's path on the host, named in the radio + rows that carry an upload. + --skills: installed skills directory holding the kvb-org-radio, + kvb-org-rasci and kvb-org-sunset scripts. + --transcripts: directory the Claude Code transcripts (*.jsonl) lie under. + --templates: directory holding index.html.j2 and the *.js modules it + includes. + --repos: directory holding the repositories as //, + mounted read-only at its host path. + --worktrees: directory holding linked worktrees, mounted read-only at its + host path. + --timers: directory holding the systemd user units (*.timer). + --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. + --llm-key-file: file holding the endpoint's bearer key; empty file or + unreadable means no key. + --piper: the Piper executable, run as a separate process; missing turns + speech output off. + --voices: directory holding the Piper voices as .onnx with + .onnx.json; every speaker of a model is a voice :. + --operator-voice: the voice of the operator and the hub; it must be one of + the installed voices when any is installed. + --whisper: the whisper.cpp command line executable; missing turns speech + 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. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from kvb_hub import NAME +from kvb_hub.engines import Engines +from kvb_hub.errors import ToolError +from kvb_hub.exchange import HubServer +from kvb_hub.handler import Handler +from kvb_hub.hub import Places, open_hub +from kvb_hub.language import Model, read_key +from kvb_hub.skill_scripts import Scripts + + +def main() -> int: + parser = argparse.ArgumentParser(prog=NAME) + 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", "--repos", "--worktrees", "--timers", "--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"): + parser.add_argument(name, required=True) + args = parser.parse_args() + try: + hub = open_hub( + council=args.council, + pages=args.pages, + public_port=args.public_port, + state=args.state, + host_state=args.host_state, + transcripts=args.transcripts, + scripts=Scripts(skills=args.skills, council=args.council, home=args.state), + templates=args.templates, + places=Places(repos=args.repos, worktrees=args.worktrees, timers=args.timers), + 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, + ) + except ToolError as error: + parser.error(str(error)) + server = HubServer((args.bind, args.listen_port), Handler) + server.hub = hub + print(f"{NAME}: listening on {args.bind}:{args.listen_port}, public port {args.public_port}", flush=True) + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hub/systemd/kvb-hub-runner.service b/hub/systemd/kvb-hub-runner.service new file mode 100644 index 0000000..0a01f27 --- /dev/null +++ b/hub/systemd/kvb-hub-runner.service @@ -0,0 +1,12 @@ +[Unit] +Description=kvb-hub runner: runs the operator commands a pinned passkey signed in the kvb-hub + +[Service] +Type=simple +EnvironmentFile=%h/.local/share/kvb-hub/.env +ExecStart=/usr/bin/env python3 %h/.local/share/kvb-hub/client/runner.py --port ${KVB_HUB_PORT} --interval 2 --approvers %h/.config/kvb-hub/approvers.json --replay %h/.local/state/kvb-hub-approvals/runner.json +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/hub/templates/ambience.js b/hub/templates/ambience.js new file mode 100644 index 0000000..6f3e6d9 --- /dev/null +++ b/hub/templates/ambience.js @@ -0,0 +1,216 @@ +const Ambience = (() => { + "use strict"; + const RAD = Math.PI / 180; + const DAY_MS = 86400000; + const J2000 = 2451545; + const AU = 149597870.7; + const EARTH = 6378.14; + const GLASS = 0.8; + const CARD = 0.92; + const LIGHT_FROM = 4; + const SKY = [ + [-90, "#03050b", "#070a14"], + [-18, "#04060e", "#0a0f1f"], + [-12, "#080d1f", "#18203d"], + [-6, "#111c3d", "#3b3763"], + [-3, "#1c2b5a", "#8a5b6c"], + [0, "#2b4377", "#de8a5e"], + [3, "#44679f", "#efab6d"], + [6, "#5884bf", "#f1c690"], + [12, "#5d92d0", "#c8dbee"], + [30, "#4e89d5", "#b4d2f1"], + [90, "#3f7fd8", "#a9cbef"], + ]; + const STAGES = [[-18, "night"], [-12, "astronomical"], [-6, "nautical"], [0, "civil"], [6, "golden"], [Infinity, "day"]]; + const THEMES = { + light: { + bg: "#eef2f7", panel: "#ffffff", sunken: "#f2f4f8", hover: "#eaeef5", text: "#121821", muted: "#434e5f", faint: "#6b7586", + line: "#dde3eb", "line-strong": "#c7cfda", accent: "#2f55d4", "accent-soft": "#e6ecfd", "on-accent": "#ffffff", + ok: "#16804a", "ok-soft": "#e3f3ea", bad: "#c2352a", "bad-soft": "#fcebea", warn: "#8a5200", "warn-soft": "#fff1d9", + info: "#2a5bd0", "info-soft": "#e7eefc", violet: "#6f3fc4", "violet-soft": "#f0e9fb", teal: "#0b7f78", "teal-soft": "#def2f0", + "neutral-soft": "#eceff4", direct: "#3a6ff7", broadcast: "#12a39a", alarm: "#e0453a", security: "#8a4bd8", grant: "#2f9e58", + "add-bg": "#e7f6ec", "add-text": "#14632f", "del-bg": "#fdeeed", "del-text": "#9b2219", "hunk-bg": "#edf1fd", + shadow: "0 1px 2px rgb(15 23 42 / 6%), 0 4px 14px rgb(15 23 42 / 6%)", "shadow-lift": "0 12px 36px rgb(15 23 42 / 16%)", + }, + dark: { + bg: "#0b0f15", panel: "#121821", sunken: "#0e131a", hover: "#1a212b", text: "#e7ebf2", muted: "#a3adbb", faint: "#6f7988", + line: "#252d38", "line-strong": "#343e4b", accent: "#8fadff", "accent-soft": "#1b2440", "on-accent": "#0b1020", + ok: "#4cc47e", "ok-soft": "#12291c", bad: "#ff7b72", "bad-soft": "#341614", warn: "#f2b84b", "warn-soft": "#33260f", + info: "#7da5ff", "info-soft": "#18233f", violet: "#b994ff", "violet-soft": "#251a3b", teal: "#43c9bf", "teal-soft": "#0f2a28", + "neutral-soft": "#1d242e", direct: "#6f9bff", broadcast: "#3cc7bd", alarm: "#ff6b61", security: "#b58cff", grant: "#4cc97a", + "add-bg": "#0f2a1a", "add-text": "#8fe0aa", "del-bg": "#33151a", "del-text": "#ffaaa3", "hunk-bg": "#18213a", + shadow: "0 1px 2px rgb(0 0 0 / 40%), 0 6px 18px rgb(0 0 0 / 28%)", "shadow-lift": "0 16px 44px rgb(0 0 0 / 55%)", + }, + }; + const sin = (deg) => Math.sin(deg * RAD); + const cos = (deg) => Math.cos(deg * RAD); + const tan = (deg) => Math.tan(deg * RAD); + const clamp = (value, low, high) => Math.min(Math.max(value, low), high); + const asin = (value) => Math.asin(clamp(value, -1, 1)) / RAD; + const acos = (value) => Math.acos(clamp(value, -1, 1)) / RAD; + const atan2 = (y, x) => Math.atan2(y, x) / RAD; + const wrap = (deg) => ((deg % 360) + 360) % 360; + + function julian(ms) { + return ms / DAY_MS + 2440587.5; + } + + function centuries(ms) { + return (julian(ms) - J2000) / 36525; + } + + function sidereal(ms) { + const days = julian(ms) - J2000; + const t = days / 36525; + return wrap(280.46061837 + 360.98564736629 * days + 0.000387933 * t * t - (t * t * t) / 38710000); + } + + function obliquity(t) { + const omega = 125.04 - 1934.136 * t; + return 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 + 0.00256 * cos(omega); + } + + function equatorial(lambda, beta, eps) { + return { + ra: wrap(atan2(sin(lambda) * cos(eps) - tan(beta) * sin(eps), cos(lambda))), + dec: asin(sin(beta) * cos(eps) + cos(beta) * sin(eps) * sin(lambda)), + }; + } + + function horizontal(ha, dec, lat) { + return { + alt: asin(sin(lat) * sin(dec) + cos(lat) * cos(dec) * cos(ha)), + az: wrap(atan2(sin(ha), cos(ha) * sin(lat) - tan(dec) * cos(lat)) + 180), + }; + } + + function sun(ms, lat, lon) { + const t = centuries(ms); + const l0 = wrap(280.46646 + t * (36000.76983 + t * 0.0003032)); + const m = 357.52911 + t * (35999.05029 - 0.0001537 * t); + const e = 0.016708634 - t * (0.000042037 + 0.0000001267 * t); + const center = sin(m) * (1.914602 - t * (0.004817 + 0.000014 * t)) + sin(2 * m) * (0.019993 - 0.000101 * t) + sin(3 * m) * 0.000289; + const lambda = wrap(l0 + center - 0.00569 - 0.00478 * sin(125.04 - 1934.136 * t)); + const eps = obliquity(t); + const place = equatorial(lambda, 0, eps); + const y = tan(eps / 2) ** 2; + const eot = (4 * (y * sin(2 * l0) - 2 * e * sin(m) + 4 * e * y * sin(m) * cos(2 * l0) - 0.5 * y * y * sin(4 * l0) - 1.25 * e * e * sin(2 * m))) / RAD; + const distance = (1.000001018 * (1 - e * e)) / (1 + e * cos(m + center)); + const minutes = (((ms % DAY_MS) + DAY_MS) % DAY_MS) / 60000; + const ha = (minutes + eot + 4 * lon) / 4 - 180; + return {...place, lambda, eot, distance, ha, ...horizontal(ha, place.dec, lat)}; + } + + function moon(ms, lat, lon) { + const t = centuries(ms); + const anomaly = 134.9 + 477198.85 * t; + const lambda = wrap(218.32 + 481267.881 * t + 6.29 * sin(anomaly) - 1.27 * sin(259.2 - 413335.38 * t) + 0.66 * sin(235.7 + 890534.23 * t) + + 0.21 * sin(269.9 + 954397.7 * t) - 0.19 * sin(357.5 + 35999.05 * t) - 0.11 * sin(186.6 + 966404.05 * t)); + const beta = 5.13 * sin(93.3 + 483202.03 * t) + 0.28 * sin(228.2 + 960400.87 * t) - 0.28 * sin(318.3 + 6003.18 * t) - 0.17 * sin(217.6 - 407332.2 * t); + const parallax = 0.9508 + 0.0518 * cos(anomaly) + 0.0095 * cos(259.2 - 413335.38 * t) + 0.0078 * cos(235.7 + 890534.23 * t) + 0.0028 * cos(269.9 + 954397.7 * t); + const place = equatorial(lambda, beta, obliquity(t)); + const distance = EARTH / sin(parallax); + const ha = sidereal(ms) + lon - place.ra; + const seen = horizontal(ha, place.dec, lat); + const light = sun(ms, lat, lon); + const dra = light.ra - place.ra; + const elongation = acos(sin(light.dec) * sin(place.dec) + cos(light.dec) * cos(place.dec) * cos(dra)); + const phase = atan2(light.distance * AU * sin(elongation), distance - light.distance * AU * cos(elongation)); + const limb = wrap(atan2(cos(light.dec) * sin(dra), sin(light.dec) * cos(place.dec) - cos(light.dec) * sin(place.dec) * cos(dra))); + const parallactic = atan2(sin(ha), tan(lat) * cos(place.dec) - sin(place.dec) * cos(ha)); + return { + ...place, lambda, beta, distance, phase, limb, + alt: seen.alt - parallax * cos(seen.alt), + az: seen.az, + lit: (1 + cos(phase)) / 2, + tilt: wrap(limb - parallactic), + waxing: wrap(lambda - light.lambda) < 180, + }; + } + + function hex(text) { + return [1, 3, 5].map((index) => parseInt(text.slice(index, index + 2), 16)); + } + + function mix(from, to, share) { + return from.map((value, index) => value + (to[index] - value) * share); + } + + function css(rgb) { + return `rgb(${rgb.map((value) => Math.round(value)).join(" ")})`; + } + + function luminance(rgb) { + const [r, g, b] = rgb.map((value) => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + function contrast(first, second) { + const [high, low] = [luminance(first), luminance(second)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); + } + + function skyAt(alt) { + const level = clamp(alt, SKY[0][0], SKY[SKY.length - 1][0]); + const index = Math.max(SKY.findIndex(([edge]) => edge >= level), 1); + const [low, lowTop, lowBottom] = SKY[index - 1]; + const [high, highTop, highBottom] = SKY[index]; + const share = (level - low) / (high - low); + return {top: mix(hex(lowTop), hex(highTop), share), bottom: mix(hex(lowBottom), hex(highBottom), share)}; + } + + function stage(alt) { + return STAGES.find(([edge]) => alt < edge)[1]; + } + + function starlight(alt) { + return clamp((-alt - 6) / 10, 0, 1); + } + + function modeAt(alt) { + return alt >= LIGHT_FROM ? "light" : "dark"; + } + + function modeOf(top, bottom) { + return luminance(mix(hex(top), hex(bottom), 0.5)) > 0.2 ? "light" : "dark"; + } + + function stars(count, seed) { + let state = seed >>> 0; + const next = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 4294967296; + }; + return Array.from({length: count}, () => ({ra: next() * 360, dec: asin(next() * 2 - 1), glow: next() ** 2})); + } + + function minutesOf(text) { + const [hours, minutes] = text.split(":").map(Number); + return hours * 60 + minutes; + } + + function slotOf(date) { + return {day: (date.getDay() + 6) % 7, minute: date.getHours() * 60 + date.getMinutes()}; + } + + function covers(stream, slot) { + const start = minutesOf(stream.from); + const end = minutesOf(stream.to); + if (start < end) { + return stream.days.includes(slot.day) && slot.minute >= start && slot.minute < end; + } + return (stream.days.includes(slot.day) && slot.minute >= start) || (stream.days.includes((slot.day + 6) % 7) && slot.minute < end); + } + + function activeStream(streams, slot) { + return streams.find((stream) => stream.on && covers(stream, slot)) || null; + } + + return {GLASS, CARD, SKY, THEMES, julian, sidereal, horizontal, sun, moon, hex, mix, css, luminance, contrast, skyAt, stage, starlight, modeAt, modeOf, stars, slotOf, activeStream}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = Ambience; +} diff --git a/hub/templates/index.html.j2 b/hub/templates/index.html.j2 new file mode 100644 index 0000000..9cbb5c0 --- /dev/null +++ b/hub/templates/index.html.j2 @@ -0,0 +1,482 @@ + + + + + + +{% if state.inbox %}({{ state.inbox | length }}) {% endif %}kvb-hub + + + + + +
+
+ +
+ + + +
+ + + + + + + + + diff --git a/hub/templates/page-agent.js b/hub/templates/page-agent.js new file mode 100644 index 0000000..fec5848 --- /dev/null +++ b/hub/templates/page-agent.js @@ -0,0 +1,136 @@ + function history(key) { + const known = (people()[key] || {names: [nameOf(key)]}).names; + const radio = state.radio.filter((row) => row.source === key || row.targets.includes(key)).map((row) => ({ts: row.ts, mine: row.from === HUMAN, who: row.from, text: row.text})); + const sent = state.messages.filter((message) => known.includes(message.to) || message.to === "*").map((message) => ({ts: message.created_ts, mine: true, who: HUMAN, text: message.upload ? `${message.text}\n📎 ${message.upload}` : message.text, id: message.id})); + const hubIds = new Set(sent.map((entry) => entry.id)); + const relayed = radio.filter((entry) => !(entry.who === HUMAN && [...hubIds].some((id) => entry.text.includes(`[kvb-hub msg ${id}]`)))); + return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80); + } + + function drawDrawer() { + if (!drawerDom) { + return; + } + const node = state.graph.nodes.find((entry) => entry.id === ui.agent); + const tasks = node ? node.tasks : []; + const claims = node ? node.claims : []; + drawerDom.info.replaceChildren( + h("h3", {}, "Aufgaben", counter(tasks.length)), + tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {}, + h("div", {class: "row"}, h("code", {}, task.task), status(task.status)), + h("span", {}, dash(task.title)), + h("span", {class: "roles"}, [task.worker ? "Worker" : "", task.letters ? `Rollen ${task.letters}` : ""].filter(Boolean).join(" · "))))) : h("p", {class: "muted"}, "Keine offenen Aufgaben."), + h("h3", {}, "Claims", counter(claims.length)), + claims.length ? h("ul", {class: "mini"}, claims.map((claim) => h("li", {}, + h("div", {class: "row"}, h("code", {}, claim.office), claim.beat === null ? null : h("span", {class: "muted"}, "Puls ", when(claim.beat))), + h("span", {}, dash(claim.work)), + h("span", {class: "muted"}, `${dash(claim.runtime)} · seit ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "Keine Claims.")); + const rows = history(ui.agent); + drawerDom.history.replaceChildren(rows.length + ? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text))) + : h("p", {class: "muted"}, "Kein Verlauf im gewählten Zeitraum.")); + } + + async function loadSubagents(key, holder, life) { + if (!SESSION_ID.test(key)) { + holder.replaceChildren(h("p", {class: "muted"}, "Keine Session bekannt.")); + return; + } + let data = null; + try { + const response = await fetch(`/api/subagents?session=${encodeURIComponent(key)}`, {cache: "no-store"}); + data = response.ok ? await response.json() : null; + } catch { + data = null; + } + if (!data) { + holder.replaceChildren(h("p", {class: "muted"}, "Subagenten nicht lesbar.")); + return; + } + life.textContent = stamp(data); + const rows = data.subagents.map((row) => ({...row, session: key})).sort((a, b) => a.started - b.started); + holder.replaceChildren(rows.length ? h("ul", {class: "mini"}, rows.map((row) => { + const item = h("li", {}, + h("div", {class: "row"}, h("button", {type: "button", class: "linkish", title: "Transkript im Stream zeigen", on: {click: () => focusSub(row.session, row.id)}}, `${row.type}: ${row.description}`), row.finished ? pill("fertig", "neutral") : pill("läuft", "ok")), + h("span", {class: "muted"}, `Tiefe ${row.depth} · `, when(row.started), stamp(row) ? ` · ${stamp(row)}` : "")); + item.style.marginLeft = `${(row.depth - 1) * 18}px`; + return item; + })) : h("p", {class: "muted"}, "Keine Subagenten.")); + } + + async function deliver(text, file) { + let upload = ""; + if (file) { + if (file.size > UPLOAD_LIMIT) { + toast("Datei zu groß", [`${file.name}: höchstens 50 MiB`], false); + return false; + } + const stored = await send(`/api/uploads?name=${encodeURIComponent(file.name)}`, file, "application/octet-stream"); + if (!stored.ok || !stored.data) { + toast("Upload fehlgeschlagen", [`${stored.status} ${stored.text}`.trim()], false); + return false; + } + upload = stored.data.id; + } + return message(nameOf(ui.agent), text, upload); + } + + async function message(to, text, upload) { + const result = await sendJson("/api/messages", {to, text, upload}); + if (!result.ok || !result.data) { + toast(`Nachricht an ${to} fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false); + return false; + } + toast(`Nachricht an ${to}: ${result.data.ok ? "gefunkt" : "gespeichert, Funk fehlgeschlagen"}`, [`kvb-hub msg ${result.data.id}`, ...runLines(result.data)], result.data.ok); + await load(); + return true; + } + + function openAgent(who) { + const drawer = document.getElementById("drawer"); + const key = agentKey(who); + const name = nameOf(key); + ui.agent = key; + const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`}); + const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"}); + const button = h("button", {type: "submit", class: "btn primary"}, "Senden"); + const form = h("form", {class: "drawer-form"}, text, file, h("div", {class: "actions"}, h("span", {class: "muted"}, "Funk direkt von Operator, prüfbar mit operator_message"), h("span", {class: "spacer"}), micButton(text), button)); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!text.value.trim()) { + return; + } + button.disabled = true; + if (await deliver(text.value, file.files[0] || null)) { + text.value = ""; + file.value = ""; + } + button.disabled = false; + }); + const info = h("div", {}); + const subs = h("div", {}, h("p", {class: "muted"}, "Lade Subagenten …")); + const log = h("div", {"aria-live": "polite"}); + const life = h("span", {class: "muted"}); + const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name); + drawer.replaceChildren( + h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}), + h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")), + h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log), + form); + drawer.hidden = false; + drawerDom = {info, history: log}; + drawDrawer(); + loadSubagents(key, subs, life); + text.focus(); + } + + function closeAgent() { + document.getElementById("drawer").hidden = true; + drawerDom = null; + ui.agent = null; + } + + function agentNames() { + const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]); + return [...names].sort((a, b) => a.localeCompare(b)); + } diff --git a/hub/templates/page-alerts.js b/hub/templates/page-alerts.js new file mode 100644 index 0000000..7a131aa --- /dev/null +++ b/hub/templates/page-alerts.js @@ -0,0 +1,75 @@ + function play(kind, volume) { + const sound = context(); + if (!sound) { + return; + } + const level = Math.max(volume * 0.5, 0.0002); + duck(Math.max(...TONES[kind].map(([, at, length]) => at + length))); + for (const [frequency, at, length] of TONES[kind]) { + const oscillator = sound.createOscillator(); + const gain = sound.createGain(); + const start = sound.currentTime + at; + oscillator.type = kind === "mayday" ? "square" : "sine"; + oscillator.frequency.value = frequency; + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(level, start + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, start + length); + oscillator.connect(gain).connect(sound.destination); + oscillator.start(start); + oscillator.stop(start + length + 0.05); + } + } + + const notifyButton = document.getElementById("notify"); + function notifyLabel() { + if (!("Notification" in window)) { + return; + } + const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren"}; + notifyButton.hidden = Notification.permission === "granted"; + notifyButton.textContent = labels[Notification.permission] || labels.default; + notifyButton.disabled = Notification.permission !== "default"; + } + notifyButton.addEventListener("click", async () => { + await Notification.requestPermission(); + notifyLabel(); + }); + + function announce() { + const alarms = new Map(alarmRows().map((row) => [`${row.ts}|${row.from}`, row])); + const radio = new Set(state.radio.map((row) => `${row.ts}|${row.from}|${row.text.slice(0, 40)}`)); + if (known.inbox !== null && live()) { + const fresh = state.inbox.filter((item) => !known.inbox.has(keyOf(item))); + const newAlarms = [...alarms].filter(([key]) => !known.alarms.has(key)).map(([, row]) => row); + const newRadio = state.radio.filter((row) => !known.radio.has(`${row.ts}|${row.from}|${row.text.slice(0, 40)}`)); + const direct = newRadio.filter((row) => row.to.includes(HUMAN)); + const present = {mayday: newAlarms.length > 0, review: fresh.some((item) => item.kind !== "operator"), direct: direct.length > 0, radio: newRadio.length > 0}; + const kind = SOUND_KINDS.map(([id]) => id).find((id) => present[id] && settings.sounds[id].on); + if (kind) { + play(kind, loudness(kind)); + } + const lead = kind ? Math.max(...TONES[kind].map(([, at, length]) => at + length)) : 0; + window.setTimeout(() => speakRows(newRadio), lead * 1000); + if (settings.notify && "Notification" in window && Notification.permission === "granted") { + for (const row of newAlarms) { + new Notification(`${row.class === "mayday" ? "Mayday" : "Pan-Pan"} von ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `alarm-${row.ts}`, requireInteraction: row.class === "mayday"}); + } + for (const item of fresh) { + new Notification(`${ITEM_LABELS[item.kind]} wartet`, {body: item.title.slice(0, 200), tag: `item-${keyOf(item)}`, requireInteraction: item.kind === "ask"}); + } + for (const row of direct) { + new Notification(`Funk von ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `direct-${row.ts}`}); + } + } + } + known.inbox = new Set(state.inbox.map(keyOf)); + known.alarms = new Set(alarms.keys()); + known.radio = radio; + for (const batch of state.pending) { + if (!known.batches.has(batch.id)) { + known.batches.add(batch.id); + const source = batch.source.startsWith("rule:") ? "Regel" : "Freigabe"; + undoToast(batch, `${source}: ${batch.items.map((item) => short(item.title)).join(", ")}`); + } + } + } diff --git a/hub/templates/page-api.js b/hub/templates/page-api.js new file mode 100644 index 0000000..c060ddc --- /dev/null +++ b/hub/templates/page-api.js @@ -0,0 +1,17 @@ + async function send(path, body, type) { + try { + const response = await fetch(path, {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": type}, body, cache: "no-store"}); + const text = await response.text(); + let data = null; + try { + data = JSON.parse(text); + } catch { + data = null; + } + return {ok: response.ok, status: response.status, text, data}; + } catch (error) { + return {ok: false, status: 0, text: String(error), data: null}; + } + } + const sendJson = (path, payload) => send(path, JSON.stringify(payload), "application/json"); + const fail = (result) => [`${result.status} ${result.text}`.trim()]; diff --git a/hub/templates/page-board.js b/hub/templates/page-board.js new file mode 100644 index 0000000..6e24ea1 --- /dev/null +++ b/hub/templates/page-board.js @@ -0,0 +1,96 @@ + async function act(action, label, payload) { + ui.busy = true; + const result = await sendJson(`/api/board/${action}`, payload); + ui.busy = false; + if (result.data && result.data.runs) { + toast(`${label}: ${result.data.ok ? "erledigt" : "fehlgeschlagen"}`, runLines(result.data), result.data.ok); + } else { + toast(`${label}: fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false); + } + await load(); + return result.ok && result.data && result.data.ok; + } + + function confirmAct(action) { + const [, label, question] = ACTIONS.find(([id]) => id === action); + if (window.confirm(question)) { + act(action, label, {}); + } + } + + function boardContent() { + if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) { + ui.order = [...state.priority]; + } + const [mode, modeTone] = NIGHT[state.night] || [state.night, "neutral"]; + const broadcast = h("textarea", {"aria-label": "Rundruf", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => { + ui.draft = event.target.value; + }}}); + broadcast.value = ui.draft; + 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; + boardSheet(); + }); + list.append(item); + }); + const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority); + return h("div", {class: "board"}, + h("article", {class: `card${state.silence ? " alarm" : ""}`}, + h("div", {class: "card-head"}, h("h3", {}, "Mayday"), state.silence ? pill("Funkstille", "bad") : pill("ruhig", "ok")), + state.silence ? h("p", {class: "text"}, `${state.silence.by}: ${state.silence.text}`) : h("p", {class: "muted"}, "Keine Funkstille aktiv."), + h("div", {class: "actions"}, h("button", {type: "button", class: "btn danger", disabled: !state.silence, on: {click: () => confirmAct("clear-mayday")}}, "Mayday aufheben"))), + h("article", {class: "card"}, + h("div", {class: "card-head"}, h("h3", {}, "Rundruf an alle")), + broadcast, + h("div", {class: "actions"}, h("span", {class: "spacer"}), h("button", {type: "button", class: "btn soft", on: {click: async () => { + if (!ui.draft.trim()) { + return; + } + if (await act("broadcast", "Rundruf", {text: ui.draft})) { + ui.draft = ""; + broadcast.value = ""; + } + }}}, "Senden"))), + h("article", {class: "card"}, + h("div", {class: "card-head"}, h("h3", {}, "Tag und Nacht"), pill(mode, modeTone)), + 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", () => confirmAct(id))))), + h("article", {class: "card"}, + h("div", {class: "card-head"}, h("h3", {}, "Priorität der Agenten"), counter(ui.order.length)), + 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]; + boardSheet(); + }}}, "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; + boardSheet(); + } + }}}, "Reihenfolge speichern")))); + } + + function boardSheet() { + openSheet("Steuerung", [boardContent()], true); + } diff --git a/hub/templates/page-boot.js b/hub/templates/page-boot.js new file mode 100644 index 0000000..a879e20 --- /dev/null +++ b/hub/templates/page-boot.js @@ -0,0 +1,63 @@ + document.getElementById("palette").addEventListener("click", (event) => { + if (event.target === event.currentTarget) { + closePalette(); + } + }); + document.getElementById("sheet").addEventListener("click", (event) => { + if (event.target === event.currentTarget) { + closeSheet(); + } + }); + document.getElementById("palette-open").addEventListener("click", openPalette); + document.getElementById("board-open").addEventListener("click", boardSheet); + document.getElementById("rules-open").addEventListener("click", rulesSheet); + document.getElementById("settings-open").addEventListener("click", settingsSheet); + document.getElementById("music-toggle").addEventListener("click", toggleMusic); + document.getElementById("leitstand").addEventListener("click", toggleLeitstand); + document.getElementById("inbox-bar").addEventListener("click", () => document.body.classList.toggle("inbox-open")); + document.addEventListener("fullscreenchange", () => { + if (!document.fullscreenElement) { + document.body.classList.remove("leitstand"); + } + window.setTimeout(measure, 50); + }); + document.addEventListener("pointerdown", context, {once: true}); + document.addEventListener("keydown", context, {once: true}); + document.addEventListener("pointerdown", unlock); + document.addEventListener("keydown", keyboard); + document.addEventListener("visibilitychange", refresh); + window.addEventListener("hashchange", apply); + window.addEventListener("resize", measure); + window.addEventListener("resize", redrawSky); + window.addEventListener("resize", requestSpace); + skyPanel(); + radio.element = h("audio", {preload: "none"}); + radio.element.addEventListener("error", () => { + if (radio.element.getAttribute("src")) { + radio.playing = false; + radio.url = ""; + tuneMusic(FADE / 1000); + } + }); + drawSky(); + musicButton(); + syncMusic(); + syncRadio(); + timeBar(); + notifyLabel(); + announce(); + measure(); + apply(); + drawTime(); + motion.asOf = ui.asOf; + relayout(0); + window.setInterval(refresh, 10000); + window.setInterval(follow, 3000); + window.setInterval(() => relayout(MOVE), RELAYOUT); + window.setInterval(() => live() && drawSky(), 30000); + window.setInterval(syncRadio, 20000); + window.setInterval(() => { + if (ui.screen === "tree" && !document.hidden) { + fetchTree(); + } + }, 60000); diff --git a/hub/templates/page-constants.js b/hub/templates/page-constants.js new file mode 100644 index 0000000..cfb027a --- /dev/null +++ b/hub/templates/page-constants.js @@ -0,0 +1,71 @@ + const NS = "http://www.w3.org/2000/svg"; + const DAY = 86400; + const CHAIR = "Maintainer"; + const HUMAN = "Operator"; + const SCREENS = [["stream", "Stream"], ["graph", "Graph"], ["tree", "Tree"]]; + const GRAPHS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"]]; + const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]]; + const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"]; + const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]]; + const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"}; + const KINDS = [["radio", "Funk"], ["review", "Reviews"], ["ask", "Asks"], ["decision", "Entscheidungen"], ["operator", "Operator"], ["register", "Register"], ["rename", "Namen"], ["cron", "Crons"], ["rule", "Regeln"], ["approval", "Freigaben"], ["run", "Runner"], ["transcript", "Transkripte"]]; + 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 ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"}; + 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; + const STREAM_KEEP = 5000; + const CLASSES = {direct: "Direkt", broadcast: "Rundruf", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sicherheit", grant: "Freigabe"}; + const PILLS = {direct: "info", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", grant: "ok"}; + const LEGEND = [["direct", "Direkt"], ["broadcast", "Rundruf"], ["alarm", "Mayday / Pan-Pan"], ["security", "Sicherheit"], ["grant", "Queue-Freigabe"]]; + const STATUS = { + running: ["läuft", "ok"], queued: ["wartet", "info"], blocked: ["blockiert", "bad"], stale: ["veraltet", "warn"], pending: ["offen", "warn"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], + approved: ["freigegeben", "ok"], rejected: ["abgelehnt", "bad"], allowed: ["erlaubt", "ok"], denied: ["abgelehnt", "bad"], expired: ["abgelaufen", "neutral"], decided: ["entschieden", "ok"], failed: ["fehlgeschlagen", "bad"], refused: ["verweigert", "bad"], + }; + const SECTIONS = {staged: "Gestaged", unstaged: "Nicht gestaged", untracked: "Unversioniert"}; + const FILE_STATUS = {added: ["A", "ok"], deleted: ["D", "bad"], modified: ["M", "info"], renamed: ["R", "violet"], copied: ["C", "violet"], untracked: ["U", "warn"]}; + const SIGNS = {add: "+", del: "-", ctx: " ", meta: " "}; + const NODE_KINDS = {operator: "Operator", chair: "Chair", session: "Session", subagent: "Subagent"}; + const RADIUS = {operator: 30, chair: 22, session: 15, subagent: 6}; + const RINGS = [150, 300]; + const FEED_LIMIT = 300; + const MOVE = 2000; + const RELAYOUT = 60000; + const ACTIVE = 900; + const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/; + const STORE = {views: "kvb-hub.views"}; + const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Direktnachrichten an dich"], ["radio", "Aller Funk"]]; + const SPEECH_CLASSES = [["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"], ["direct", "Direktruf"], ["broadcast", "Rundruf"]]; + const SPEECH_QUEUE = 20; + const SPEECH_QUIET = 60000; + const SPEECH_FRESH = 300; + const THEME_CHOICES = [["sky", "Himmel (folgt dem Licht draußen)"], ["light", "Immer hell"], ["dark", "Immer dunkel"], ["custom", "Eigene Farben"]]; + const COLOR_FIELDS = [["top", "Himmel oben"], ["bottom", "Himmel am Horizont"], ["accent", "Akzent"]]; + const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"}; + const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]]; + const AXES = [["none", "Flach", "Die heutige 2D-Ansicht"], ["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]]; + const SUBS_TTL = 60000; + const TURN = 10; + const NUDGE = 40; + const ZOOM = 1.2; + const GLIDE = 320; + const PLANE = 450; + const SAVE_DELAY = 700; + const TRACK_RETRY = 5000; + const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2]; + const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5]; + const BASE = 110; + const FADE = 3000; + const DUCK = 0.2; + const TONES = { + mayday: [[880, 0, 0.18], [660, 0.22, 0.18], [880, 0.44, 0.18], [660, 0.66, 0.18], [880, 0.88, 0.32]], + review: [[660, 0, 0.14], [990, 0.16, 0.24]], + direct: [[740, 0, 0.2]], + radio: [[520, 0, 0.07]], + }; + const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Graph · Tree"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]]; + const calm = window.matchMedia("(prefers-reduced-motion: reduce)"); + const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"}); + const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"}); + const clock = new Intl.DateTimeFormat("de-DE", {hour: "2-digit", minute: "2-digit"}); diff --git a/hub/templates/page-dom.js b/hub/templates/page-dom.js new file mode 100644 index 0000000..7dff5a6 --- /dev/null +++ b/hub/templates/page-dom.js @@ -0,0 +1,124 @@ + function build(node, attrs, children) { + for (const [key, value] of Object.entries(attrs)) { + if (key === "on") { + for (const [name, handler] of Object.entries(value)) { + node.addEventListener(name, handler); + } + } else if (value !== null && value !== undefined && value !== false) { + node.setAttribute(key, value === true ? "" : String(value)); + } + } + for (const child of children.flat(Infinity)) { + if (child !== null && child !== undefined && child !== false) { + node.append(child instanceof Node ? child : String(child)); + } + } + return node; + } + const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children); + const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children); + const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value); + const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle"); + const fixed = (value) => value.toFixed(1); + const short = (text) => (text.length > 80 ? `${text.slice(0, 80)}…` : text); + const btn = (label, cls, run) => h("button", {type: "button", class: cls, on: {click: run}}, label); + const nowSeconds = () => Date.now() / 1000; + + function ago(ts) { + const diff = ts - nowSeconds(); + const size = Math.abs(diff); + if (size < 45) { + return "gerade eben"; + } + if (size < 3600) { + return relative.format(Math.round(diff / 60), "minute"); + } + if (size < DAY) { + return relative.format(Math.round(diff / 3600), "hour"); + } + return relative.format(Math.round(diff / DAY), "day"); + } + const stampOf = (ts) => new Date(ts * 1000); + const when = (ts) => h("time", {"data-ts": ts, datetime: stampOf(ts).toISOString(), title: absolute.format(stampOf(ts))}, ago(ts)); + const pill = (text, tone) => h("span", {class: "pill", "data-tone": tone}, text); + const status = (value) => pill((STATUS[value] || [value])[0], (STATUS[value] || [value, "neutral"])[1]); + const klass = (value) => pill(CLASSES[value] || value, PILLS[value] || "neutral"); + const counter = (value) => h("span", {class: "count"}, String(value)); + const meta = (pairs) => h("dl", {class: "meta"}, pairs.map(([label, value, title]) => h("div", {}, h("dt", {}, label), h("dd", {}, h("code", {title: title || null}, dash(value)))))); + const emptyState = (title, text) => h("div", {class: "empty"}, h("div", {class: "empty-mark", "aria-hidden": "true"}), h("h2", {}, title), h("p", {}, text)); + const live = () => ui.preset !== -1 && ui.asOf === null; + const alarmRows = () => state.radio.filter((row) => (row.class === "mayday" || row.class === "pan-pan") && row.ts >= state.alarm_since).reverse(); + const keyOf = (item) => `${item.kind}:${item.id}`; + 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 entryOf = (item, verb, label) => ({kind: item.kind, id: item.id, verb, label, sha256: item.sha256 || ""}); + + function tick() { + for (const node of document.querySelectorAll("time[data-ts]")) { + node.textContent = ago(Number(node.dataset.ts)); + } + const updated = document.getElementById("updated"); + updated.classList.toggle("offline", ui.offline); + updated.textContent = `${ui.offline ? "Keine Verbindung · " : ""}${live() ? "Live" : "Stand"} ${absolute.format(stampOf(live() ? state.now : state.until))}`; + } + + function avatar(name) { + let hash = 2166136261; + for (const char of String(name)) { + hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0; + } + return EMOJI[hash % EMOJI.length]; + } + const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name)); + const person = (name) => (name && name !== "-" && name !== "alle" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `${name} öffnen`, on: {click: () => openAgent(name)}}, name) : dash(name)); + const people = () => state.people || {}; + 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 stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · "); + + function toast(title, lines, good) { + const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Schließen"}, h("strong", {}, title), lines.length ? h("pre", {}, lines.join("\n")) : null); + box.addEventListener("click", () => box.remove()); + document.getElementById("toasts").append(box); + window.setTimeout(() => box.remove(), good ? 9000 : 20000); + } + + function runLines(data) { + return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean)); + } + + function copyButton(text) { + const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren"); + button.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(text); + button.textContent = "Kopiert"; + } catch { + button.textContent = "Nicht möglich"; + } + window.setTimeout(() => { + button.textContent = "Kopieren"; + }, 1600); + }); + return button; + } + + function openSheet(title, body, wide) { + const sheet = document.getElementById("sheet"); + sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title}, + h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")), + h("div", {class: "sheet-body"}, body))); + sheet.hidden = false; + } + + function closeSheet() { + const sheet = document.getElementById("sheet"); + sheet.hidden = true; + sheet.replaceChildren(); + reviewDom = null; + ui.review = null; + } diff --git a/hub/templates/page-filters.js b/hub/templates/page-filters.js new file mode 100644 index 0000000..6edb7b6 --- /dev/null +++ b/hub/templates/page-filters.js @@ -0,0 +1,151 @@ + const activeKinds = () => (filter.view ? new Set(state.views[filter.view].kinds) : filter.kinds); + const activeWaiting = () => (filter.view ? state.views[filter.view].waiting : filter.waiting); + + function filterQuery() { + const params = new URLSearchParams(); + if (filter.view) { + params.set("view", filter.view); + } else { + params.set("kinds", [...filter.kinds].join(",")); + if (filter.waiting) { + params.set("waiting", "1"); + } + } + for (const name of ["q", "agent", "session", "sub"]) { + if (filter[name]) { + params.set(name, filter[name]); + } + } + if (filter.parts.size !== PARTS.length) { + params.set("parts", [...filter.parts].join(",")); + } + return params.toString(); + } + + function windowKey() { + if (ui.preset === -1) { + return `custom:${ui.from}:${ui.to}`; + } + return `${ui.preset}:${ui.asOf === null ? "live" : ui.asOf}`; + } + + function customize() { + if (filter.view) { + filter.kinds = new Set(state.views[filter.view].kinds); + filter.waiting = state.views[filter.view].waiting; + filter.view = ""; + } + filter.saved = ""; + } + + function refilter() { + drawFilters(); + drawStream(); + fetchEvents(); + } + + function useView(id) { + filter.view = id; + filter.saved = ""; + filter.session = ""; + filter.sub = ""; + filter.agent = ""; + filter.q = ""; + if (ui.screen === "stream" && stream.dom) { + stream.dom.search.value = ""; + refilter(); + } else { + go("stream", null); + } + } + + function useSaved(view) { + filter.view = ""; + filter.saved = view.name; + filter.kinds = new Set(view.kinds); + filter.waiting = view.waiting; + filter.q = view.q; + filter.agent = view.agent; + filter.session = ""; + filter.sub = ""; + if (ui.screen === "stream" && stream.dom) { + stream.dom.search.value = filter.q; + refilter(); + } else { + go("stream", null); + } + } + + function saveView() { + const name = (window.prompt("Name der Ansicht", filter.saved || "") || "").trim(); + if (!name) { + return; + } + const entry = {name: name.slice(0, 60), kinds: [...activeKinds()], waiting: activeWaiting(), q: filter.q, agent: filter.agent}; + userViews = [...userViews.filter((view) => view.name !== entry.name), entry]; + if (!keep(STORE.views, userViews)) { + toast("Ansicht nur bis zum Neuladen gespeichert", ["Der Browser erlaubt kein localStorage."], false); + } + useSaved(entry); + } + + function dropView(name) { + userViews = userViews.filter((view) => view.name !== name); + keep(STORE.views, userViews); + if (filter.saved === name) { + filter.saved = ""; + } + drawFilters(); + } + + function drawFilters() { + if (!stream.dom) { + return; + } + const kinds = activeKinds(); + const presets = Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label)); + const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: { + click: () => useSaved(view), + keydown: (event) => { + if (event.key === "Enter") { + useSaved(view); + } + }, + }}, view.name, h("button", {type: "button", class: "x", "aria-label": `${view.name} löschen`, on: {click: (event) => { + event.stopPropagation(); + dropView(view.name); + }}}, "×"))); + stream.dom.presets.replaceChildren(...presets, ...mine, btn("+ Ansicht speichern", "btn small ghost", saveView)); + const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => { + customize(); + if (filter.kinds.has(id) && filter.kinds.size > 1) { + filter.kinds.delete(id); + } else { + filter.kinds.add(id); + } + refilter(); + }}}, label)); + const waiting = h("button", {type: "button", class: "chip", "aria-pressed": String(activeWaiting()), on: {click: () => { + customize(); + filter.waiting = !filter.waiting; + refilter(); + }}}, "nur Wartendes"); + stream.dom.kinds.replaceChildren(waiting, ...kindChips); + const parts = kinds.has("transcript") ? PARTS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(filter.parts.has(id)), on: {click: () => { + if (filter.parts.has(id) && filter.parts.size > 1) { + filter.parts.delete(id); + } else { + filter.parts.add(id); + } + refilter(); + }}}, label)) : []; + const focus = filter.sub || filter.session ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : `Session ${filter.session.slice(0, 8)}`, + h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => { + filter.sub = ""; + filter.session = ""; + refilter(); + }}}, "×")) : null; + stream.dom.parts.replaceChildren(...parts, focus || ""); + const names = [...new Set([...Object.values(stream.sessions), ...Object.values(people()).flatMap((entry) => entry.names), ...state.claims.map((claim) => claim.agent), ...state.priority].filter(Boolean))].sort((a, b) => a.localeCompare(b)); + stream.dom.agents.replaceChildren(h("option", {value: ""}, "Alle Agenten"), names.map((name) => h("option", {value: name, selected: name === filter.agent}, name))); + } diff --git a/hub/templates/page-graph-charts.js b/hub/templates/page-graph-charts.js new file mode 100644 index 0000000..96242b1 --- /dev/null +++ b/hub/templates/page-graph-charts.js @@ -0,0 +1,199 @@ + function orbit() { + const nodes = state.graph.nodes; + positions = layout(nodes); + const svg = chartFrame(400, "Orbit: Operator in der Mitte; wer zusammen arbeitet oder funkt, rückt zusammen, Stille driften nach außen"); + svg.append(...RINGS.map((r) => s("circle", {class: "ring", r}))); + const edgeLayer = s("g", {}); + const halos = new Map(); + for (const link of aggregate()) { + const from = positions.get(link.source); + const shade = tone(link.cls); + const words = `${link.count}× ${CLASSES[link.cls] || link.cls}`; + if (!from) { + continue; + } + if (link.target === "*") { + const index = halos.get(link.source) || 0; + halos.set(link.source, index + 1); + edgeLayer.append(s("circle", {class: `halo t-${shade}`, cx: fixed(from.x), cy: fixed(from.y), r: radiusOf(link.source) + 7 + index * 7, "stroke-width": width(link.count)}, + s("title", {}, `${link.source} an alle: ${words}`))); + continue; + } + const to = positions.get(link.target); + if (!to || to === from) { + continue; + } + const control = Space.bend(from, to); + const start = toward(from, control, radiusOf(link.source) + 2); + const end = toward(to, control, radiusOf(link.target) + 4); + edgeLayer.append(s("path", {class: `edge t-${shade}`, d: `M${fixed(start.x)},${fixed(start.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(end.x)},${fixed(end.y)}`, "stroke-width": fixed(width(link.count)), "marker-end": `url(#tip-${shade})`}, + s("title", {}, `${link.source} → ${link.target}: ${words}`))); + } + const nodeLayer = s("g", {}, nodes.map((node) => nodeMark(node, positions.get(node.id), node.kind === "operator" || node.kind === "subagent" ? null : RADIUS[node.kind] + 9))); + const pulseLayer = s("g", {}); + svg.append(edgeLayer, nodeLayer, pulseLayer); + graphDom.pulses = pulseLayer; + return svg; + } + + function chord() { + const nodes = state.graph.nodes.filter((node) => node.kind !== "subagent"); + const fresh = targets(); + const radius = 250; + const step = (2 * Math.PI) / Math.max(nodes.length, 1); + const spots = new Map(nodes.map((node) => { + const angle = spotOf(node.id, fresh).chord; + return [node.id, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}]; + })); + const svg = chartFrame(450, "Chord: Nachrichten zwischen Agenten, Reihenfolge nach Nähe"); + const arcs = s("g", {}); + const point = (angle, r) => `${fixed(r * Math.cos(angle))},${fixed(r * Math.sin(angle))}`; + for (const node of nodes) { + const at = spots.get(node.id); + const half = Math.min(step * 0.36, 0.5); + arcs.append(s("path", {class: `group-arc k-${node.kind}`, d: `M${point(at.angle - half, radius + 14)} A${radius + 14},${radius + 14} 0 0 1 ${point(at.angle + half, radius + 14)}`})); + } + const pairs = new Map(); + const fans = new Map(); + for (const link of aggregate()) { + const shade = tone(link.cls); + if (!spots.has(link.source)) { + continue; + } + if (link.target === "*") { + const key = `${link.source}\u0000${shade}`; + fans.set(key, {source: link.source, shade, count: (fans.has(key) ? fans.get(key).count : 0) + link.count}); + continue; + } + if (!spots.has(link.target) || link.target === link.source) { + continue; + } + const [a, b] = [link.source, link.target].sort(); + const key = `${a}\u0000${b}\u0000${shade}`; + pairs.set(key, {a, b, shade, count: (pairs.has(key) ? pairs.get(key).count : 0) + link.count}); + } + const lanes = LEGEND.map(([name]) => name); + for (const pair of pairs.values()) { + const from = spots.get(pair.a); + const to = spots.get(pair.b); + const pull = 0.06 + 0.12 * lanes.indexOf(pair.shade); + const control = {x: ((from.x + to.x) / 2) * pull, y: ((from.y + to.y) / 2) * pull}; + const start = toward(from, control, RADIUS.session + 2); + const end = toward(to, control, RADIUS.session + 2); + arcs.append(s("path", {class: `edge t-${pair.shade}`, d: `M${fixed(start.x)},${fixed(start.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(end.x)},${fixed(end.y)}`, "stroke-width": fixed(width(pair.count))}, + s("title", {}, `${pair.a} ↔ ${pair.b}: ${pair.count}× ${LEGEND.find(([name]) => name === pair.shade)[1]}`))); + } + const offsets = new Map(); + for (const fan of fans.values()) { + const at = spots.get(fan.source); + const index = offsets.get(fan.source) || 0; + offsets.set(fan.source, index + 1); + const angle = at.angle + (index - 1) * 0.05; + const length = Math.min(8 + 6 * Math.log2(1 + fan.count), 40); + arcs.append(s("line", {class: `tick t-${fan.shade}`, x1: fixed((radius + 22) * Math.cos(angle)), y1: fixed((radius + 22) * Math.sin(angle)), x2: fixed((radius + 22 + length) * Math.cos(angle)), y2: fixed((radius + 22 + length) * Math.sin(angle)), "stroke-width": 4}, + s("title", {}, `${fan.source} an alle: ${fan.count}×`))); + } + svg.append(arcs, s("g", {}, nodes.map((node) => nodeMark(node, spots.get(node.id), 74)))); + return svg; + } + + function swimlane() { + const rank = new Map([HUMAN, ...state.graph.layout.order].map((id, index) => [id, index])); + const lanes = state.graph.nodes.filter((node) => node.kind !== "subagent").sort((a, b) => (rank.has(a.id) ? rank.get(a.id) : 1e6) - (rank.has(b.id) ? rank.get(b.id) : 1e6)); + const begin = state.since; + const end = Math.max(state.until, state.since + 1); + const left = 160; + const right = 24; + const top = 40; + const lane = 36; + const full = 1120; + const height = top + lanes.length * lane + 12; + const x = (ts) => left + ((ts - begin) / (end - begin)) * (full - left - right); + const y = new Map(lanes.map((node, index) => [node.id, top + index * lane + lane / 2])); + const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: Nachrichten über die Zeit"}, defs()); + lanes.forEach((node, index) => { + svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane})); + svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", on: {click: () => select(node.id)}}, node.label)); + }); + for (let index = 0; index <= 6; index += 1) { + const ts = begin + ((end - begin) * index) / 6; + svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8})); + svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts)))); + } + if (live()) { + svg.append(s("line", {class: "now", x1: fixed(x(end)), x2: fixed(x(end)), y1: top - 6, y2: height - 8})); + } + const marks = s("g", {}); + const arrows = (cls, ts, from, to, words) => { + const x0 = fixed(x(ts)); + const y0 = y.get(from); + if (y0 === undefined) { + return; + } + const shade = tone(cls); + const hint = s("title", {}, `${clock.format(stampOf(ts))} · ${dash(labelOf(from))} → ${to.length ? to.map(labelOf).join(", ") : "alle"}\n${words}`); + if (!to.length) { + const rays = [[-6, -13], [0, -15], [6, -13], [-6, 13], [0, 15], [6, 13]].map(([dx, dy]) => `M${x0},${y0} l${dx},${dy}`).join(" "); + marks.append(s("g", {class: `mark t-${shade}`}, hint, s("path", {d: rays}), s("circle", {cx: x0, cy: y0, r: 3.5}))); + return; + } + const bundle = s("g", {class: `mark t-${shade}`}, hint, s("circle", {cx: x0, cy: y0, r: 3})); + for (const target of to) { + const y1 = y.get(target); + if (y1 !== undefined && y1 !== y0) { + bundle.append(s("line", {x1: x0, y1: y0, x2: x0, y2: y1 > y0 ? y1 - 6 : y1 + 6, "marker-end": `url(#tip-${shade})`})); + } + } + marks.append(bundle); + }; + for (const row of state.radio) { + arrows(row.class, row.ts, row.source, row.targets, row.text.length > 120 ? `${row.text.slice(0, 120)}…` : row.text); + } + for (const grant of state.grants) { + arrows("grant", grant.ts, chairKey(), [grant.target], `Queue-Freigabe ${grant.kind} (${grant.id})`); + } + svg.append(marks); + return svg; + } + + function legend() { + const axis = graph3d() ? space.views.graph.axis : "none"; + const words = { + none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit", + time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe", + spawn: "Ebenen = Spawn-Tiefe · Bogen = Nachrichten im Zeitraum, Zahl im Stream", + }; + return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)), + axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"), + h("span", {class: "muted"}, words[axis])); + } + + function side() { + const node = state.graph.nodes.find((entry) => entry.id === ui.node); + if (!node) { + const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten"; + return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)]; + } + const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse(); + return [ + h("div", {class: "panel-head"}, + h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)), + h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")), + h("h3", {}, "Letzte Nachrichten", counter(messages.length)), + messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {}, + h("div", {class: "row"}, klass(row.class), when(row.ts)), + h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`), + h("span", {}, row.text)))) : h("p", {class: "muted"}, "Keine Nachrichten im gewählten Zeitraum."), + ]; + } + + function graphScreen() { + const stageBox = h("div", {class: "stage"}); + const aside = h("aside", {class: "card panel", "aria-live": "polite"}); + graphDom = {stage: stageBox, aside, pulses: null, space: null}; + return h("div", {class: "graph"}, + h("div", {class: "card controls"}, + h("div", {class: "segmented", role: "tablist", "aria-label": "Darstellung"}, GRAPHS.map(([id, name]) => h("button", {type: "button", role: "tab", "aria-selected": String(ui.graph === id), on: {click: () => go("graph", id)}}, name))), + ui.graph === "orbit" ? spaceTools() : null), + h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside)); + } diff --git a/hub/templates/page-graph-layout.js b/hub/templates/page-graph-layout.js new file mode 100644 index 0000000..7b077b7 --- /dev/null +++ b/hub/templates/page-graph-layout.js @@ -0,0 +1,144 @@ + function aggregate() { + const links = new Map(); + const add = (source, target, cls) => { + const key = `${source}\u0000${target}\u0000${cls}`; + const link = links.get(key) || {source, target, cls, count: 0}; + link.count += 1; + links.set(key, link); + }; + for (const row of state.radio) { + for (const target of row.source ? (row.targets.length ? row.targets : ["*"]) : []) { + add(row.source, target, row.class); + } + } + for (const grant of state.grants) { + add(chairKey(), grant.target, "grant"); + } + const order = (link) => (tone(link.cls) === "alarm" ? 1e6 : 0) + link.count; + return [...links.values()].sort((a, b) => order(a) - order(b)); + } + + const width = (count) => Math.min(1.25 + Math.log2(1 + count) * 1.6, 9); + const tone = (cls) => Space.TONES[cls] || "direct"; + + function defs() { + return s("defs", {}, LEGEND.map(([name]) => s("marker", {id: `tip-${name}`, viewBox: "0 0 10 10", refX: 8, refY: 5, markerWidth: 9, markerHeight: 9, markerUnits: "userSpaceOnUse", orient: "auto-start-reverse", class: `t-${name}`}, + s("path", {class: "tip", d: "M0,0 L10,5 L0,10 z"})))); + } + + function toward(point, aim, gap) { + const dx = aim.x - point.x; + const dy = aim.y - point.y; + const length = Math.hypot(dx, dy) || 1; + return {x: point.x + (dx / length) * gap, y: point.y + (dy / length) * gap}; + } + + function radiusOf(id) { + const node = state.graph.nodes.find((entry) => entry.id === id); + return node ? RADIUS[node.kind] : RADIUS.session; + } + + function targets() { + const plan = state.graph.layout; + const order = [HUMAN, ...plan.order]; + return new Map(order.map((id, index) => { + const spot = plan.orbit[id] || {angle: -Math.PI / 2, radius: 0}; + return [id, {angle: spot.angle, radius: spot.radius, chord: -Math.PI / 2 + (2 * Math.PI * index) / order.length}]; + })); + } + + function step(now) { + const progress = motion.duration ? Math.min((now - motion.start) / motion.duration, 1) : 1; + const eased = Space.ease(progress); + motion.current = new Map([...motion.target].map(([id, to]) => { + const from = motion.from.get(id) || to; + return [id, {angle: Space.turn(from.angle, to.angle, eased), radius: from.radius + (to.radius - from.radius) * eased, chord: Space.turn(from.chord, to.chord, eased)}]; + })); + if (ui.graph !== "swimlane") { + stage(); + } + motion.frame = progress < 1 ? window.requestAnimationFrame(step) : 0; + } + + function relayout(duration) { + const next = targets(); + motion.from = new Map([...next.keys()].map((id) => [id, motion.current.get(id) || next.get(id)])); + motion.target = next; + motion.start = performance.now(); + motion.duration = calm.matches ? 0 : duration; + if (!motion.frame) { + motion.frame = window.requestAnimationFrame(step); + } + } + + function spotOf(id, fresh) { + return motion.current.get(id) || fresh.get(id) || {angle: -Math.PI / 2, radius: RINGS[1], chord: -Math.PI / 2}; + } + + function layout(nodes) { + const fresh = targets(); + const spots = new Map(); + for (const node of nodes.filter((entry) => entry.kind !== "subagent")) { + const at = spotOf(node.id, fresh); + const radius = node.kind === "operator" ? 0 : at.radius; + spots.set(node.id, {x: radius * Math.cos(at.angle), y: radius * Math.sin(at.angle), angle: at.angle}); + } + const moons = new Map(); + for (const node of nodes.filter((entry) => entry.kind === "subagent")) { + moons.set(node.parent, [...(moons.get(node.parent) || []), node]); + } + for (const [parent, list] of moons) { + const base = spots.get(parent) || spots.get(chairKey()) || {x: 0, y: 0, angle: -Math.PI / 2}; + list.forEach((node, index) => spots.set(node.id, Space.moon(base, index, list.length))); + } + return spots; + } + + function caption(node, at, r, gap) { + if (node.kind === "subagent") { + return null; + } + if (gap === null) { + return s("text", {class: "label", y: r + 17, "text-anchor": "middle"}, node.label); + } + const cos = Math.cos(at.angle); + const sin = Math.sin(at.angle); + const anchor = cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"; + return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label); + } + + function select(id) { + ui.node = ui.node === id ? null : id; + stage(); + drawSide(); + const node = state.graph.nodes.find((entry) => entry.id === id); + if (ui.node !== null && node && node.kind !== "operator") { + openAgent(node.kind === "subagent" ? node.parent : id); + } + } + + function nodeMark(node, at, gap) { + const r = RADIUS[node.kind]; + const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : ""].filter(Boolean).join(" "); + const tasks = node.tasks.length ? ` · ${node.tasks.length} Aufgaben` : ""; + return s("g", { + class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`, + on: { + click: () => select(node.id), + keydown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + select(node.id); + } + }, + }, + }, + s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}${tasks}`), + s("circle", {r}), + node.kind === "subagent" ? null : s("text", {class: "initial", "text-anchor": "middle", dy: "0.35em"}, node.label.slice(0, 1).toUpperCase()), + caption(node, at, r, gap)); + } + + function chartFrame(size, label) { + return s("svg", {viewBox: `${-size} ${-size} ${2 * size} ${2 * size}`, class: "chart", role: "img", "aria-label": label}, defs()); + } diff --git a/hub/templates/page-inbox.js b/hub/templates/page-inbox.js new file mode 100644 index 0000000..6ad0c98 --- /dev/null +++ b/hub/templates/page-inbox.js @@ -0,0 +1,222 @@ + async function submit(entries, label) { + ui.busy = true; + const signed = await proofFor(entries, label); + if (signed === null) { + ui.busy = false; + await load(); + return false; + } + const result = await sendJson("/api/inbox/approve", {items: entries, proof: signed.proof}); + ui.busy = false; + if (!result.ok || !result.data) { + toast(`${label}: nicht möglich`, fail(result), false); + await load(); + return false; + } + known.batches.add(result.data.id); + ui.selected.clear(); + undoToast(result.data, label); + await load(); + return true; + } + + function undoToast(batch, label) { + const left = h("span", {class: "muted"}); + const button = h("button", {type: "button", class: "btn small"}, "Rückgängig"); + const box = h("div", {class: "toast wait", role: "status"}, h("strong", {}, label), h("div", {class: "undo-row"}, left, h("span", {class: "spacer"}), button)); + let timer = 0; + const count = () => { + const rest = Math.ceil(batch.due - nowSeconds()); + if (rest <= 0) { + window.clearInterval(timer); + box.remove(); + load(); + return; + } + left.textContent = `wird in ${rest} s wirksam`; + }; + timer = window.setInterval(count, 250); + button.addEventListener("click", async () => { + window.clearInterval(timer); + button.disabled = true; + const result = await send(`/api/inbox/undo/${encodeURIComponent(batch.id)}`, "", "application/json"); + box.remove(); + toast(result.ok ? `Rückgängig: ${label}` : "Zu spät: schon wirksam", result.ok ? [] : [`${result.status} ${result.text}`.trim()], result.ok); + await load(); + }); + document.getElementById("toasts").append(box); + count(); + } + + function approveOne(item) { + if (item.kind === "decision") { + if (!item.recommended) { + openItem(item); + return null; + } + return submit([entryOf(item, "approve", item.recommended)], `Entscheidung: ${item.recommended}`); + } + return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Ausführen" : "Freigabe"}: ${short(item.title)}`); + } + + const rejectOne = (item) => submit([entryOf(item, "reject", "")], `Ablehnung: ${short(item.title)}`); + const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Entscheidung: ${label}`); + + function bulk(items, label) { + const usable = items.filter(selectable); + if (!usable.length) { + toast(`${label}: nichts freizugeben`, [], false); + return; + } + const rows = usable.map((item) => h("li", {}, + h("div", {class: "row"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.kind === "decision" ? h("span", {class: "muted"}, `→ ${item.recommended}`) : null), + h("strong", {}, item.title), + item.kind === "operator" ? h("div", {class: "command"}, h("code", {}, item.command)) : null, + item.kind === "operator" ? h("span", {class: "muted"}, `in ${item.directory}`) : null, + item.kind === "ask" ? h("div", {class: "command"}, h("code", {}, item.command)) : null)); + const confirm = h("button", {type: "button", class: "btn primary"}, `${usable.length} freigeben`); + confirm.addEventListener("click", () => { + closeSheet(); + submit(usable.map((item) => entryOf(item, "approve", item.kind === "decision" ? item.recommended : "")), `${label}: ${usable.length} Einträge`); + }); + openSheet(label, [h("p", {class: "muted"}, "Jeder Eintrag und jeder volle Befehl, der freigegeben wird. Nach dem Klick signiert der Passkey die ganze Auswahl einmal; danach bleiben 3 s zum Rückgängigmachen."), h("ul", {class: "mini"}, rows), h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false); + confirm.focus(); + } + + async function mark(item, until, pinned) { + const result = await sendJson("/api/inbox/mark", {kind: item.kind, id: item.id, until, pinned}); + if (!result.ok) { + toast("Merken fehlgeschlagen", [`${result.status} ${result.text}`.trim()], false); + } + await load(); + } + + function tomorrow() { + const date = new Date(); + date.setDate(date.getDate() + 1); + date.setHours(8, 0, 0, 0); + return date.getTime() / 1000; + } + + function toggle(item) { + if (!item || !selectable(item)) { + return; + } + const key = keyOf(item); + if (ui.selected.has(key)) { + ui.selected.delete(key); + } else { + ui.selected.add(key); + } + drawInbox(); + } + + function actionsFor(item) { + if (item.held) { + return [h("span", {class: "muted"}, "wird wirksam …")]; + } + const buttons = []; + if (item.kind === "review" || item.kind === "ask") { + buttons.push(btn("Freigeben", "btn small primary", () => approveOne(item)), btn("Ablehnen", "btn small danger", () => rejectOne(item))); + } else if (item.kind === "decision" && item.recommended) { + buttons.push(btn(`Empfehlung: ${item.recommended}`, "btn small primary", () => approveOne(item))); + } else if (item.kind === "operator" && item.runnable) { + buttons.push(btn("Ausführen", "btn small primary", () => approveOne(item))); + } + return buttons; + } + + function inboxItem(item, index) { + const red = item.kind === "review" && item.gate !== "pass"; + const classes = ["item", isGreen(item) ? "green" : "", red ? "red" : "", item.held ? "held" : "", index === ui.cursor ? "cursor" : ""].filter(Boolean).join(" "); + const box = h("input", {type: "checkbox", "aria-label": `${item.title} auswählen`, checked: ui.selected.has(keyOf(item)), disabled: !selectable(item), on: {change: () => toggle(item)}}); + return h("li", {class: classes, "data-index": index}, + box, + h("div", {}, + h("div", {class: "head-row"}, + pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), + item.kind === "review" ? pill(item.gate === "pass" ? "Gate grün" : "Gate rot", item.gate === "pass" ? "ok" : "bad") : null, + item.warnings.map((warning) => pill(warning, "bad")), + item.pinned ? pill("angeheftet", "violet") : null, + item.until ? pill(`bis ${absolute.format(stampOf(item.until))}`, "neutral") : null, + h("span", {class: "spacer"}), when(item.ts)), + h("button", {type: "button", class: "linkish", title: "Öffnen", on: {click: () => openItem(item)}}, item.title), + 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 === "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)}, + }, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null), + h("div", {class: "item-actions"}, + actionsFor(item), + item.kind === "operator" ? copyButton(item.command) : null, + h("span", {class: "spacer"}), + btn("1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned)), + btn("morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned)), + item.until ? btn("wecken", "btn small ghost", () => mark(item, 0, item.pinned)) : null, + btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned)))); + } + + function drawInbox() { + const items = visibleInbox(); + ui.cursor = Math.min(ui.cursor, Math.max(items.length - 1, 0)); + const present = new Set(items.map(keyOf)); + for (const key of [...ui.selected]) { + if (!present.has(key)) { + ui.selected.delete(key); + } + } + const sig = JSON.stringify([items, [...ui.selected], ui.cursor, ui.snoozed, ui.busy, Math.floor(nowSeconds() / 60)]); + if (sig === inboxSig) { + return; + } + inboxSig = sig; + const green = items.filter(isGreen); + const chosen = items.filter((item) => ui.selected.has(keyOf(item))); + const hidden = state.inbox.length - waitingCount(); + const snoozed = h("input", {type: "checkbox", checked: ui.snoozed, on: {change: (event) => { + ui.snoozed = event.target.checked; + drawInbox(); + }}}); + const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`); + const all = h("button", {type: "button", class: "btn small primary", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.length})`); + document.getElementById("rail").replaceChildren( + 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()), + items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich.")); + const current = document.querySelector(`#rail .item[data-index="${ui.cursor}"]`); + if (current && document.activeElement === document.body) { + current.scrollIntoView({block: "nearest"}); + } + } + + function openItem(item) { + if (!item) { + return; + } + if (item.kind === "review") { + reviewSheet(item.id); + return; + } + const body = [ + 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, + 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: () => { + closeSheet(); + chooseOne(item, option.label); + }}, + }, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null, + h("div", {class: "actions"}, h("span", {class: "spacer"}), actionsFor(item).map((node) => { + node.addEventListener("click", closeSheet); + return node; + })), + ]; + openSheet(ITEM_LABELS[item.kind], body, false); + } diff --git a/hub/templates/page-keyboard.js b/hub/templates/page-keyboard.js new file mode 100644 index 0000000..a132305 --- /dev/null +++ b/hub/templates/page-keyboard.js @@ -0,0 +1,86 @@ + function helpSheet() { + openSheet("Tastenkürzel", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])), + h("p", {class: "muted"}, "Leitstand: Vollbild mit großem Status für den zweiten Bildschirm. Auf schmalen Bildschirmen öffnet die Leiste unten die Inbox.")], false); + } + + function overlayOpen() { + return !document.getElementById("palette").hidden || !document.getElementById("sheet").hidden; + } + + function moveCursor(delta) { + const items = visibleInbox(); + if (!items.length) { + return; + } + ui.cursor = Math.min(Math.max(ui.cursor + delta, 0), items.length - 1); + drawInbox(); + const current = document.querySelector(`#rail .item[data-index="${ui.cursor}"]`); + if (current) { + current.scrollIntoView({block: "nearest"}); + } + } + + function keyboard(event) { + const target = event.target; + if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "k") { + event.preventDefault(); + if (document.getElementById("palette").hidden) { + openPalette(); + } else { + closePalette(); + } + return; + } + if (event.key === "Escape") { + if (!document.getElementById("palette").hidden) { + closePalette(); + } else if (!document.getElementById("sheet").hidden) { + closeSheet(); + } else if (space.look) { + skyLook(false); + } else if (ui.agent !== null) { + closeAgent(); + } else { + document.body.classList.remove("inbox-open"); + } + return; + } + if (event.altKey || event.ctrlKey || event.metaKey || event.isComposing || overlayOpen()) { + return; + } + if (target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))) { + return; + } + const current = visibleInbox()[ui.cursor]; + const index = Number(event.key) - 1; + if (/^[1-3]$/.test(event.key)) { + event.preventDefault(); + go(SCREENS[index][0], null); + } else if (event.key === "j" || event.key === "k") { + event.preventDefault(); + moveCursor(event.key === "j" ? 1 : -1); + } else if (event.key === "x") { + event.preventDefault(); + toggle(current); + } else if (event.key === "a") { + event.preventDefault(); + if (current && !current.held) { + approveOne(current); + } + } else if (event.key === "Enter" && current && target === document.body) { + event.preventDefault(); + openItem(current); + } else if (event.key === "/") { + event.preventDefault(); + if (ui.screen !== "stream") { + go("stream", null); + } + window.setTimeout(() => stream.dom && stream.dom.search.focus(), 0); + } else if (event.key === "?") { + event.preventDefault(); + helpSheet(); + } else if (event.key === "h") { + event.preventDefault(); + skyLook(!space.look); + } + } diff --git a/hub/templates/page-load.js b/hub/templates/page-load.js new file mode 100644 index 0000000..d440179 --- /dev/null +++ b/hub/templates/page-load.js @@ -0,0 +1,43 @@ + async function load() { + try { + const response = await fetch(`/api/state?${windowQuery()}${wantsTrack() ? "&track=1" : ""}`, {cache: "no-store"}); + if (!response.ok) { + throw new Error(String(response.status)); + } + const next = await response.json(); + const previous = state.now; + state = next; + ui.offline = false; + if (JSON.stringify(state.settings) !== settingsSig) { + adoptSettings(state.settings); + } + tuneMusic(FADE / 1000); + drawSky(); + announce(); + render(false); + drawTime(); + drawDrawer(); + drawStream(); + if (motion.current.size === 0 || ui.asOf !== motion.asOf) { + const first = motion.current.size === 0; + motion.asOf = ui.asOf; + relayout(first ? 0 : ui.playing ? 450 : MOVE); + } + pulses(state.radio.filter((row) => row.ts > previous)); + } catch { + ui.offline = true; + tick(); + } + } + + function refresh() { + if (!ui.busy && !ui.playing && !document.hidden && String(window.getSelection()).length === 0) { + load(); + } + } + + function follow() { + if (ui.screen === "stream" && live() && !document.hidden) { + fetchEvents(); + } + } diff --git a/hub/templates/page-music.js b/hub/templates/page-music.js new file mode 100644 index 0000000..d7a4281 --- /dev/null +++ b/hub/templates/page-music.js @@ -0,0 +1,148 @@ + function context() { + if (!audio) { + try { + audio = new window.AudioContext(); + } catch { + audio = null; + } + } + if (audio && audio.state === "suspended") { + audio.resume().catch(() => null); + } + return audio; + } + + function glide(param, target, seconds) { + const now = audio.currentTime; + param.cancelScheduledValues(now); + param.setValueAtTime(param.value, now); + param.linearRampToValueAtTime(target, now + seconds); + } + + function impulse(sound) { + const length = sound.sampleRate * 4; + const buffer = sound.createBuffer(2, length, sound.sampleRate); + for (let channel = 0; channel < 2; channel += 1) { + const data = buffer.getChannelData(channel); + for (let index = 0; index < length; index += 1) { + data[index] = (Math.random() * 2 - 1) * (1 - index / length) ** 3; + } + } + return buffer; + } + + function musicLevel() { + return settings.music.on && !radio.playing ? settings.music.volume * 0.3 * (music.ducked ? DUCK : 1) : 0; + } + + function note() { + if (!music.gain) { + return; + } + if (audio.state === "running") { + const now = audio.currentTime; + const scale = Math.random() < (music.tension ? 0.3 + 0.5 * music.tension : 0) ? TENSE : CALM; + const frequency = BASE * 2 * scale[Math.floor(Math.random() * scale.length)] * (Math.random() < 0.3 ? 2 : 1); + const level = audio.createGain(); + level.gain.setValueAtTime(0, now); + level.gain.linearRampToValueAtTime(0.07, now + 2.5); + level.gain.linearRampToValueAtTime(0, now + 8); + level.connect(music.filter); + for (const cents of [0, 5]) { + const oscillator = audio.createOscillator(); + oscillator.type = "triangle"; + oscillator.frequency.value = frequency; + oscillator.detune.value = cents; + oscillator.connect(level); + oscillator.start(now); + oscillator.stop(now + 8.5); + } + } + const slow = 4500 + Math.random() * 4000; + const fast = 2400 + Math.random() * 2600; + music.timer = window.setTimeout(note, slow + (fast - slow) * music.tension); + } + + function startMusic() { + const sound = context(); + if (!sound || music.gain) { + return; + } + const gain = sound.createGain(); + gain.gain.value = 0; + gain.connect(sound.destination); + const reverb = sound.createConvolver(); + reverb.buffer = impulse(sound); + reverb.connect(gain); + const filter = sound.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = 700; + filter.Q.value = 0.4; + filter.connect(reverb); + const dry = sound.createGain(); + dry.gain.value = 0.3; + filter.connect(dry).connect(gain); + music.drone = [BASE, BASE * 1.5].map((frequency) => { + const oscillator = sound.createOscillator(); + const level = sound.createGain(); + const lfo = sound.createOscillator(); + const depth = sound.createGain(); + oscillator.frequency.value = frequency; + level.gain.value = 0.04; + lfo.frequency.value = 0.05 + Math.random() * 0.05; + depth.gain.value = 0.02; + lfo.connect(depth).connect(level.gain); + oscillator.connect(level).connect(filter); + oscillator.start(); + lfo.start(); + return {oscillator, lfo}; + }); + Object.assign(music, {gain, filter}); + note(); + } + + function stopMusic() { + if (!music.gain) { + return; + } + const {gain, drone} = music; + window.clearTimeout(music.timer); + glide(gain.gain, 0, 2); + music.gain = null; + window.setTimeout(() => { + for (const {oscillator, lfo} of drone) { + oscillator.stop(); + lfo.stop(); + } + gain.disconnect(); + }, 2500); + } + + function tuneMusic(seconds) { + music.tension = Math.min(waitingCount() / 4, 1); + if (music.gain) { + glide(music.filter.frequency, 700 + 900 * music.tension, 4); + glide(music.drone[1].oscillator.detune, 8 * music.tension, 6); + glide(music.gain.gain, musicLevel(), seconds); + } + } + + function syncMusic() { + if (settings.music.on) { + startMusic(); + } else { + stopMusic(); + } + tuneMusic(FADE / 1000); + } + + function musicButton() { + const button = document.getElementById("music-toggle"); + button.setAttribute("aria-pressed", String(settings.music.on)); + button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik"; + } + + function toggleMusic() { + context(); + saveSettings({...settings, music: {...settings.music, on: !settings.music.on}}); + } diff --git a/hub/templates/page-palette.js b/hub/templates/page-palette.js new file mode 100644 index 0000000..4fb1b00 --- /dev/null +++ b/hub/templates/page-palette.js @@ -0,0 +1,151 @@ + function paletteItems() { + return [ + ...SCREENS.map(([id, label], index) => ({label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)})), + ...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Ansicht", 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)})), + ...ACTIONS.map(([id, label]) => ({label, hint: "Aktion", run: () => confirmAct(id)})), + {label: "Steuerung …", hint: "Rundruf, Nacht, Priorität", run: boardSheet}, + {label: "Regeln …", hint: "automatische Freigaben", run: rulesSheet}, + {label: "Einstellungen …", hint: "Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet}, + {label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic}, + {label: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)}, + {label: "Leitstand umschalten", hint: "Vollbild", run: toggleLeitstand}, + {label: "Tastenkürzel", hint: "?", run: helpSheet}, + ]; + } + + function drawPalette() { + if (palette.plan) { + return; + } + const raw = palette.input.value.trim(); + const q = raw.toLowerCase(); + const items = paletteItems().filter((item) => !q || item.label.toLowerCase().includes(q) || item.hint.toLowerCase().includes(q)); + if (raw) { + items.push(state.llm + ? {label: `KI-Befehl: „${raw}“`, hint: "Tab", keep: true, run: () => askModel(raw)} + : {label: "KI-Befehl ist aus", hint: "KVB_HUB_LLM_URL und KVB_HUB_LLM_MODEL in hub/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_HUB_LLM_URL, KVB_HUB_LLM_MODEL und KVB_HUB_LLM_KEY_FILE in hub/.env und installiere neu."], false)}); + } + palette.items = items; + palette.index = Math.min(palette.index, Math.max(items.length - 1, 0)); + palette.list.replaceChildren(...items.map((item, index) => h("li", {role: "option", "aria-selected": String(index === palette.index), on: { + click: () => runPalette(index), + mousemove: () => { + if (palette.index !== index) { + palette.index = index; + drawPalette(); + } + }, + }}, h("span", {}, item.label), h("span", {class: "muted"}, item.hint)))); + const current = palette.list.children[palette.index]; + if (current) { + current.scrollIntoView({block: "nearest"}); + } + } + + function runPalette(index) { + const item = palette.items[index]; + if (!item) { + return; + } + if (!item.keep) { + closePalette(); + } + item.run(); + } + + async function askModel(text) { + palette.plan = {actions: [], rejected: [], busy: true}; + palette.list.replaceChildren(h("li", {}, h("span", {class: "muted"}, "Frage das Modell … (nur Befehl, Ids und Titel gehen hinaus)"))); + const result = await sendJson("/api/nl", {text}); + if (!palette.plan) { + return; + } + if (!result.ok || !result.data) { + palette.plan = null; + palette.list.replaceChildren(h("li", {}, h("span", {}, `KI-Befehl fehlgeschlagen: ${result.status} ${result.text}`.trim()))); + return; + } + palette.plan = result.data; + const actions = result.data.actions.map((action) => h("pre", {}, action.summary)); + const rejected = result.data.rejected.map((entry) => h("p", {class: "muted"}, `verworfen: ${entry.reason} · ${entry.action}`)); + palette.list.replaceChildren(h("li", {class: "palette-plan"}, + actions.length ? actions : h("p", {class: "muted"}, "Das Modell schlug keine gültige Aktion vor."), + rejected, + h("p", {class: "muted"}, actions.length ? "Enter führt genau diese Aktionen aus · Esc bricht ab" : "Esc schließt"))); + } + + async function executePlan(plan) { + for (const action of plan.actions) { + const args = action.args; + if (action.name === "approve" || action.name === "reject" || action.name === "choose") { + await submit(args.items, action.summary.split("\n")[0]); + } else if (action.name === "snooze" || action.name === "pin") { + for (const key of args.keys) { + const item = state.inbox.find((entry) => entry.kind === key.kind && entry.id === key.id); + if (item) { + await mark(item, action.name === "snooze" ? nowSeconds() + args.hours * 3600 : item.until, action.name === "pin" ? true : item.pinned); + } + } + } else if (action.name === "view") { + useView(args.name); + } else if (action.name === "open") { + go(args.view, null); + } else if (action.name === "message") { + await message(args.to, args.text, ""); + } else if (action.name === "filter") { + filter.q = args.q; + if (ui.screen === "stream" && stream.dom) { + stream.dom.search.value = args.q; + refilter(); + } else { + go("stream", null); + } + } + } + } + + function openPalette() { + const box = document.getElementById("palette"); + palette.input = h("input", {type: "text", placeholder: "Ansicht, Agent, Aktion – oder ein Befehl in Worten …", "aria-label": "Befehl", role: "combobox", "aria-expanded": "true"}); + palette.list = h("ul", {class: "palette-list", role: "listbox"}); + palette.index = 0; + palette.plan = null; + palette.input.addEventListener("input", () => { + palette.index = 0; + palette.plan = null; + drawPalette(); + }); + palette.input.addEventListener("keydown", (event) => { + if (palette.plan && event.key === "Enter") { + event.preventDefault(); + const plan = palette.plan; + if (!plan.busy && plan.actions.length) { + closePalette(); + executePlan(plan); + } + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const size = Math.max(palette.items.length, 1); + palette.index = (palette.index + (event.key === "ArrowDown" ? 1 : size - 1)) % size; + drawPalette(); + } else if (event.key === "Tab" && palette.input.value.trim() && !palette.plan) { + event.preventDefault(); + palette.index = palette.items.length - 1; + runPalette(palette.index); + } else if (event.key === "Enter") { + event.preventDefault(); + runPalette(palette.index); + } + }); + box.replaceChildren(h("div", {class: "palette-box", role: "dialog", "aria-label": "Befehle"}, h("div", {class: "palette-row"}, palette.input, micButton(palette.input)), palette.list)); + box.hidden = false; + drawPalette(); + palette.input.focus(); + } + + function closePalette() { + document.getElementById("palette").hidden = true; + palette.plan = null; + } diff --git a/hub/templates/page-passkey.js b/hub/templates/page-passkey.js new file mode 100644 index 0000000..f3556f7 --- /dev/null +++ b/hub/templates/page-passkey.js @@ -0,0 +1,85 @@ + const RP_ID = "localhost"; + const ALGORITHMS = [-7, -8, -257]; + const onLocalhost = () => window.location.hostname === RP_ID; + const localUrl = () => `http://${RP_ID}:${window.location.port}${window.location.pathname}${window.location.hash}`; + const b64url = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")), (char) => char.charCodeAt(0)); + const localLink = () => h("a", {href: localUrl()}, localUrl()); + + async function assertion(prepared, label) { + if (!onLocalhost()) { + toast(`${label}: nicht signiert`, [`Der Passkey signiert nur unter ${localUrl()}`], false); + return null; + } + if (!prepared.credentials.length) { + toast(`${label}: nicht signiert`, ["Kein Passkey gepinnt: Einstellungen → Passkey registrieren"], false); + return null; + } + try { + const credential = await navigator.credentials.get({publicKey: { + challenge: unb64url(prepared.challenge), + rpId: RP_ID, + allowCredentials: prepared.credentials.map((id) => ({type: "public-key", id: unb64url(id)})), + userVerification: "preferred", + timeout: 120000, + }}); + const answer = credential.response; + return {id: b64url(credential.rawId), clientDataJSON: b64url(answer.clientDataJSON), authenticatorData: b64url(answer.authenticatorData), signature: b64url(answer.signature)}; + } catch (error) { + toast(`${label}: nicht signiert`, [`${(error && error.name) || error}: KeePassXC gesperrt oder abgebrochen; nichts wurde gesendet.`], false); + return null; + } + } + + async function proofFor(entries, label) { + const result = await sendJson("/api/sign", {items: entries}); + if (!result.ok || !result.data) { + toast(`${label}: nicht möglich`, fail(result), false); + return null; + } + if (result.data.payload === null) { + return {proof: null}; + } + const signed = await assertion(result.data, label); + return signed ? {proof: {payload: result.data.payload, assertion: signed}} : null; + } + + async function registerPasskey(output) { + if (!onLocalhost()) { + output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink())); + return; + } + try { + const credential = await navigator.credentials.create({publicKey: { + rp: {id: RP_ID, name: "kvb-hub"}, + user: {id: crypto.getRandomValues(new Uint8Array(16)), name: "operator", displayName: "kvb-hub Operator"}, + challenge: crypto.getRandomValues(new Uint8Array(32)), + pubKeyCredParams: ALGORITHMS.map((alg) => ({type: "public-key", alg})), + attestation: "none", + authenticatorSelection: {residentKey: "preferred", userVerification: "preferred"}, + timeout: 120000, + }}); + const answer = credential.response; + const blob = b64url(new TextEncoder().encode(JSON.stringify({id: b64url(credential.rawId), attestationObject: b64url(answer.attestationObject), clientDataJSON: b64url(answer.clientDataJSON)}))); + const command = `python3 ~/.local/share/kvb-hub/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-hub/approvers.json --credential '${blob}'`; + output.replaceChildren( + h("p", {class: "muted"}, "Diesen Befehl im Terminal des Hosts ausführen, außerhalb jeder Sandbox; erst dann gilt der Passkey. Der Hub pinnt nie selbst."), + h("div", {class: "command"}, h("code", {}, command), copyButton(command)), + h("label", {}, "Credential", h("textarea", {class: "field", readonly: true, rows: 3}, blob))); + } catch (error) { + output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`)); + } + } + + function passkeySection() { + const output = h("div", {class: "mini", "aria-live": "polite"}); + const keys = state.approvers.length + ? h("ul", {class: "mini"}, state.approvers.map((key) => h("li", {}, pill(key.alg, "ok"), " ", h("code", {title: key.id}, `${key.id.slice(0, 16)}…`)))) + : h("p", {class: "muted"}, "Kein Passkey gepinnt: Runner, Hook und Commit-Skills verweigern jede Freigabe."); + return h("section", {}, h("h3", {}, "Passkey"), + h("p", {class: "muted"}, "Freigaben von Asks, Reviews und Operator-Befehlen signiert ein Passkey in KeePassXC; Runner, Hook und Commit-Skills prüfen die Signatur selbst gegen die gepinnten Schlüssel."), + keys, + onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()), + h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))), + output); + } diff --git a/hub/templates/page-pulses.js b/hub/templates/page-pulses.js new file mode 100644 index 0000000..bdc15b3 --- /dev/null +++ b/hub/templates/page-pulses.js @@ -0,0 +1,54 @@ + function animate(duration, frame, done) { + const began = performance.now(); + const run = (now) => { + const progress = Math.min((now - began) / duration, 1); + frame(progress); + if (progress < 1) { + window.requestAnimationFrame(run); + } else { + done(); + } + }; + window.requestAnimationFrame(run); + } + + function travel(layer, row, target) { + const from = positions.get(row.source); + const shade = tone(row.class); + if (!layer.isConnected || !from) { + return; + } + if (target === "*") { + const ring = s("circle", {class: `pulse-ring t-${shade}`, cx: fixed(from.x), cy: fixed(from.y), r: 18}); + layer.append(ring); + animate(1400, (t) => { + ring.setAttribute("r", fixed(18 + 54 * t)); + ring.setAttribute("opacity", fixed(1 - t)); + }, () => ring.remove()); + return; + } + const to = positions.get(target); + if (!to) { + return; + } + const control = Space.bend(from, to); + const dot = s("circle", {class: `pulse t-${shade}`, r: 5}); + layer.append(dot); + animate(1200, (t) => { + const u = 1 - t; + dot.setAttribute("cx", fixed(u * u * from.x + 2 * u * t * control.x + t * t * to.x)); + dot.setAttribute("cy", fixed(u * u * from.y + 2 * u * t * control.y + t * t * to.y)); + }, () => dot.remove()); + } + + function pulses(rows) { + if (calm.matches || ui.screen !== "graph" || ui.graph !== "orbit" || !live() || !graphDom || !graphDom.pulses) { + return; + } + const layer = graphDom.pulses; + rows.slice(-24).forEach((row, index) => { + for (const target of row.targets.length ? row.targets : ["*"]) { + window.setTimeout(() => travel(layer, row, target), index * 260); + } + }); + } diff --git a/hub/templates/page-radio.js b/hub/templates/page-radio.js new file mode 100644 index 0000000..0a8bda9 --- /dev/null +++ b/hub/templates/page-radio.js @@ -0,0 +1,86 @@ + function radioVolume() { + if (radio.element) { + radio.element.volume = Math.min(Math.max(radio.level * settings.music.volume * (music.ducked ? DUCK : 1), 0), 1); + } + } + + function fadeRadio(target, done) { + window.clearInterval(radio.timer); + if (radio.level === target) { + done(); + return; + } + const from = radio.level; + const began = performance.now(); + radio.timer = window.setInterval(() => { + const progress = Math.min((performance.now() - began) / FADE, 1); + radio.level = from + (target - from) * progress; + radioVolume(); + if (progress >= 1) { + window.clearInterval(radio.timer); + done(); + } + }, 100); + } + + function stopStream() { + radio.element.pause(); + radio.element.removeAttribute("src"); + radio.element.load(); + radio.playing = false; + tuneMusic(FADE / 1000); + } + + function syncRadio() { + const due = Ambience.activeStream(settings.streams, Ambience.slotOf(new Date())); + const url = due ? due.url : ""; + if (url === radio.url && !radio.waiting) { + return; + } + const origin = url ? new URL(url).origin : ""; + if (origin && !pageMedia.has(origin)) { + if (state.media.includes(origin)) { + window.location.reload(); + } + return; + } + radio.url = url; + radio.waiting = false; + fadeRadio(0, () => { + if (radio.url !== url) { + return; + } + if (!url) { + stopStream(); + return; + } + radio.element.src = url; + radio.element.play().then(() => { + radio.playing = true; + tuneMusic(FADE / 1000); + fadeRadio(1, () => null); + }).catch(() => { + radio.waiting = true; + }); + }); + } + + function duck(seconds) { + music.ducked += 1; + tuneMusic(0.15); + radioVolume(); + window.setTimeout(() => { + music.ducked -= 1; + tuneMusic(1.5); + radioVolume(); + }, (seconds + 0.6) * 1000); + } + + function unlock() { + if (settings.music.on) { + context(); + } + if (radio.waiting) { + syncRadio(); + } + } diff --git a/hub/templates/page-review.js b/hub/templates/page-review.js new file mode 100644 index 0000000..7d6fbc3 --- /dev/null +++ b/hub/templates/page-review.js @@ -0,0 +1,149 @@ + function fileList(id) { + if (!files.has(id)) { + files.set(id, "loading"); + fetch(`/reviews/${encodeURIComponent(id)}/files`, {cache: "no-store"}) + .then((response) => (response.ok ? response.json() : {files: null})) + .then((data) => files.set(id, Array.isArray(data.files) ? data.files : null)) + .catch(() => files.set(id, null)) + .then(() => { + if (ui.review === id) { + drawReviewSheet(); + } + }); + } + return files.get(id); + } + + function reviewOf(id) { + const pending = state.reviews.find((review) => review.id === id); + if (pending) { + return pending; + } + const entry = stream.events.find((row) => row.kind === "review" && row.ref.id === id); + return entry ? {id, title: entry.title, notes: entry.text, created_ts: entry.ts, worktree: entry.ref.worktree, branch: entry.ref.branch, tree: entry.ref.tree, gate: entry.ref.gate, score: entry.ref.score, status: entry.ref.status} : null; + } + + function reviewSheet(id) { + ui.review = id; + ui.file = 0; + drawReviewSheet(); + } + + function drawReviewSheet() { + const review = reviewOf(ui.review); + if (!review) { + closeSheet(); + toast("Review nicht mehr im Zeitraum", [], false); + return; + } + const item = state.inbox.find((entry) => entry.kind === "review" && entry.id === review.id); + const list = fileList(review.id); + const treeNav = h("nav", {class: "card tree", "aria-label": "Geänderte Dateien"}, treePane(list)); + const diff = h("section", {class: "card diff-pane", "aria-label": "Diff"}, diffPane(list)); + const id = ui.review; + const head = h("article", {class: "card"}, + h("div", {class: "card-head"}, h("div", {}, h("p", {class: "muted"}, "eingereicht ", when(review.created_ts))), h("div", {class: "who"}, status(review.status || "pending"), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null)), + meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]), + review.notes ? h("pre", {class: "notes"}, review.notes) : null, + h("div", {class: "actions"}, + h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"), + h("span", {class: "spacer"}), + item ? actionsFor(item).map((node) => { + node.addEventListener("click", closeSheet); + return node; + }) : null)); + openSheet(review.title, [head, h("div", {class: "split"}, treeNav, diff)], true); + ui.review = id; + reviewDom = {tree: treeNav, diff}; + } + + function nest(entries) { + const root = {dirs: new Map(), files: []}; + for (const [file, index] of entries) { + const parts = file.path.split("/"); + let node = root; + for (const part of parts.slice(0, -1)) { + if (!node.dirs.has(part)) { + node.dirs.set(part, {dirs: new Map(), files: []}); + } + node = node.dirs.get(part); + } + node.files.push([file, index, parts[parts.length - 1]]); + } + return root; + } + + function stat(file) { + return h("span", {class: "stat"}, h("span", {class: "add"}, `+${file.added}`), h("span", {class: "del"}, `-${file.removed}`)); + } + + function pick(index, list) { + ui.file = index; + for (const entry of reviewDom.tree.querySelectorAll(".file-entry")) { + if (Number(entry.dataset.index) === index) { + entry.setAttribute("aria-current", "true"); + } else { + entry.removeAttribute("aria-current"); + } + } + reviewDom.diff.replaceChildren(diffPane(list)); + } + + function branch(node, list) { + const items = []; + for (const [name, child] of [...node.dirs].sort(([a], [b]) => a.localeCompare(b))) { + let label = name; + let inner = child; + while (!inner.files.length && inner.dirs.size === 1) { + const [[next, deeper]] = inner.dirs; + label = `${label}/${next}`; + inner = deeper; + } + items.push(h("li", {}, h("details", {open: true}, h("summary", {}, h("span", {class: "dir"}, `${label}/`)), branch(inner, list)))); + } + for (const [file, index, name] of node.files.sort((a, b) => a[2].localeCompare(b[2]))) { + const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"]; + items.push(h("li", {}, h("button", { + type: "button", class: "file-entry", "data-index": index, "aria-current": index === ui.file ? "true" : null, + title: file.old ? `${file.old} → ${file.path}` : file.path, on: {click: () => pick(index, list)}, + }, h("span", {class: "letter", "data-tone": tone}, letter), h("span", {class: "name"}, name), stat(file)))); + } + return h("ul", {class: "tree-list"}, items); + } + + function treePane(list) { + if (list === "loading") { + return h("p", {class: "muted pad"}, "Lade Dateiliste …"); + } + if (!Array.isArray(list) || !list.length) { + return h("p", {class: "muted pad"}, "Keine Dateiliste. Die vollständige Seite zeigt alle Änderungen."); + } + const sections = [...new Set(list.map((file) => file.section))]; + return h("div", {}, sections.map((section) => h("div", {class: "tree-section"}, + sections.length > 1 ? h("h3", {class: "tree-title"}, SECTIONS[section] || section || "Dateien") : null, + branch(nest(list.map((file, index) => [file, index]).filter(([file]) => file.section === section)), list)))); + } + + function diffRow(line) { + if (line.kind === "hunk" || line.kind === "more") { + return h("tr", {class: line.kind}, h("td", {colspan: 3}), h("td", {class: "code"}, line.text)); + } + return h("tr", {class: line.kind}, h("td", {class: "num"}, line.old), h("td", {class: "num"}, line.new), h("td", {class: "sign"}, SIGNS[line.kind] || " "), h("td", {class: "code"}, line.text)); + } + + function diffPane(list) { + if (!Array.isArray(list) || !list.length) { + return h("p", {class: "muted pad"}, list === "loading" ? "Lade Änderungen …" : "Kein Diff verfügbar."); + } + const file = list[Math.min(ui.file, list.length - 1)]; + const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"]; + return h("div", {}, + h("div", {class: "diff-head"}, + h("span", {class: "letter", "data-tone": tone}, letter), + h("code", {class: "path"}, file.old ? `${file.old} → ${file.path}` : file.path), + file.tags.map((tag) => pill(tag, "neutral")), + h("span", {class: "spacer"}), + stat(file)), + file.detail ? h("p", {class: "muted pad"}, file.detail) : null, + file.lines.length ? h("div", {class: "diff-scroll"}, h("table", {class: "diff"}, h("tbody", {}, file.lines.map(diffRow)))) : null); + } diff --git a/hub/templates/page-rules.js b/hub/templates/page-rules.js new file mode 100644 index 0000000..e9a9628 --- /dev/null +++ b/hub/templates/page-rules.js @@ -0,0 +1,74 @@ + function rulesSheet() { + 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 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/*"}); + const expiry = new Date(Date.now() + 7 * DAY * 1000); + const expires = h("input", {class: "field", type: "date", value: localInput(expiry.getTime() / 1000).slice(0, 10), required: true}); + const enabled = h("input", {type: "checkbox", checked: true}); + const toolField = h("label", {}, "Werkzeug (Glob)", tool); + const scopeField = h("label", {}, "Verzeichnis (Glob)", scope); + const hint = h("p", {class: "muted"}); + const sync = () => { + 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]; + }; + kind.addEventListener("change", sync); + sync(); + const form = h("form", {class: "rule-form"}, h("label", {}, "Art", kind), toolField, h("label", {}, "Muster (Glob)", pattern), scopeField, h("label", {}, "Läuft ab am", expires), h("label", {class: "check"}, enabled, "aktiv"), h("button", {type: "submit", class: "btn primary"}, "Regel anlegen")); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const end = Date.parse(`${expires.value}T23:59:59`) / 1000; + const fields = { + kind: kind.value, + tool: kind.value === "ask" ? tool.value.trim() : "", + pattern: pattern.value.trim() || (kind.value === "decision" ? "*" : ""), + scope: kind.value === "decision" ? "*" : scope.value.trim(), + expires: Number.isFinite(end) ? end : 0, + }; + const prepared = await sendJson("/api/rules/sign", fields); + if (!prepared.ok || !prepared.data) { + toast("Regel nicht angelegt", fail(prepared), false); + return; + } + const signed = prepared.data.sign ? await assertion(prepared.data, "Regel") : null; + if (prepared.data.sign && !signed) { + return; + } + const result = await sendJson("/api/rules", {rule: prepared.data.payload, assertion: signed, enabled: enabled.checked}); + toast(result.ok ? "Regel angelegt" : "Regel nicht angelegt", result.ok ? [] : fail(result), result.ok); + await load(); + rulesSheet(); + }); + const change = async (rule, verb) => { + const result = await send(`/api/rules/${encodeURIComponent(rule.id)}/${verb}`, "", "application/json"); + if (!result.ok) { + toast("Regel nicht geändert", [`${result.status} ${result.text}`.trim()], false); + } + await load(); + rulesSheet(); + }; + const now = nowSeconds(); + const list = state.rules.length ? h("div", {class: "mini"}, state.rules.map((rule) => h("div", {class: "rule-row"}, + pill(rule.kind, KIND_TONES[rule.kind]), + 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"), + 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"}), + btn(rule.enabled ? "Ausschalten" : "Einschalten", "btn small", () => change(rule, rule.enabled ? "disable" : "enable")), + btn("Löschen", "btn small danger", () => { + if (window.confirm("Regel löschen?")) { + change(rule, "delete"); + } + })))) : h("p", {class: "muted"}, "Noch keine Regel. Jede automatische Freigabe erscheint im Stream und lässt sich 3 s lang rückgängig machen."); + openSheet("Regeln für automatische Freigaben", [list, hint, form], false); + } diff --git a/hub/templates/page-screens.js b/hub/templates/page-screens.js new file mode 100644 index 0000000..95396d7 --- /dev/null +++ b/hub/templates/page-screens.js @@ -0,0 +1,93 @@ + const nav = document.getElementById("tabs"); + const screenButtons = new Map(SCREENS.map(([id, label], index) => { + const button = h("button", {type: "button", role: "tab", id: `tab-${id}`, "aria-controls": "main", title: `${label} (Taste ${index + 1})`, on: {click: () => go(id, null)}}, label); + nav.append(button); + return [id, button]; + })); + + function measure() { + document.documentElement.style.setProperty("--head", `${document.getElementById("head").offsetHeight}px`); + } + + function go(screen, sub) { + const hash = screen === "graph" ? `#graph/${sub || ui.graph}` : `#${screen}`; + if (window.location.hash === hash) { + apply(); + } else { + window.location.hash = hash; + } + } + + function apply() { + const [screen, sub] = decodeURIComponent(window.location.hash.slice(1)).split("/"); + ui.screen = SCREENS.some(([id]) => id === screen) ? screen : "stream"; + if (GRAPHS.some(([id]) => id === sub)) { + ui.graph = sub; + } + if (document.startViewTransition && drawn.screen && !calm.matches) { + document.startViewTransition(() => render(true)); + } else { + render(true); + } + } + + function signature() { + return JSON.stringify(ui.screen === "graph" ? [ui.graph, space.views.graph.axis] : []); + } + + function render(force) { + if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) { + ui.node = null; + } + for (const [id, button] of screenButtons) { + button.setAttribute("aria-selected", String(ui.screen === id)); + } + const sig = signature(); + if (force || drawn.screen !== ui.screen || drawn.sig !== sig) { + graphDom = null; + stream.dom = null; + tree.dom = null; + const main = document.getElementById("main"); + main.setAttribute("aria-labelledby", `tab-${ui.screen}`); + main.replaceChildren({stream: streamScreen, graph: graphScreen, tree: treeScreen}[ui.screen]()); + drawn.screen = ui.screen; + drawn.sig = sig; + } + stage(); + drawSide(); + drawInbox(); + drawStatus(); + tick(); + } + + function drawStatus() { + const alarms = alarmRows().length; + const fresh = state.now - ACTIVE; + const agents = new Set(state.claims.filter((claim) => claim.beat !== null && claim.beat >= fresh).map((claim) => claim.agent)).size; + const jobs = state.jobs.filter((job) => job.status === "queued" || job.status === "running").length; + const [mode] = NIGHT[state.night] || [state.night]; + const waiting = waitingCount(); + const box = (value, label, tone) => h("div", {class: `stat-box${tone ? ` ${tone}` : ""}`}, h("span", {class: "num"}, String(value)), h("span", {class: "lbl"}, label)); + document.getElementById("status").replaceChildren( + box(waiting, "wartet auf dich", waiting ? "urgent" : ""), + box(alarms, "Mayday", alarms ? "alarm" : ""), + box(agents, "Agenten aktiv", ""), + box(jobs, "Runner", ""), + box(mode, "Modus", "")); + const badge = document.getElementById("inbox-count"); + badge.textContent = String(waiting); + badge.className = `badge${alarms ? " alarm" : waiting ? " urgent" : ""}`; + document.title = `${waiting ? `(${waiting}) ` : ""}kvb-hub`; + } + + function toggleLeitstand() { + const on = !document.body.classList.contains("leitstand"); + document.body.classList.toggle("leitstand", on); + if (on && document.documentElement.requestFullscreen && !document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(() => null); + } + if (!on && document.fullscreenElement) { + document.exitFullscreen().catch(() => null); + } + window.setTimeout(measure, 50); + } diff --git a/hub/templates/page-settings.js b/hub/templates/page-settings.js new file mode 100644 index 0000000..2c26198 --- /dev/null +++ b/hub/templates/page-settings.js @@ -0,0 +1,139 @@ + function adoptSettings(next) { + settings = next; + settingsSig = JSON.stringify(next); + if (space.edits === space.saved) { + space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}}; + space.glides = {graph: null, sky: null}; + requestSpace(); + } + sky.mode = ""; + drawSky(); + syncMusic(); + syncRadio(); + musicButton(); + } + + async function saveSettings(next) { + const result = await sendJson("/api/settings", next); + if (!result.ok || !result.data) { + toast("Einstellungen nicht gespeichert", [`${result.status} ${result.text}`.trim()], false); + return false; + } + const fresh = result.data.streams.map((stream) => new URL(stream.url).origin).filter((origin) => !pageMedia.has(origin)); + adoptSettings(result.data); + if (fresh.length) { + toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf."], true); + window.setTimeout(() => window.location.reload(), 1500); + } + return true; + } + + function streamRow(stream, rows, list) { + const field = (attrs) => h("input", {class: "field", required: true, ...attrs}); + const name = field({type: "text", value: stream.name, placeholder: "Sendername", maxlength: 120}); + const url = field({type: "url", value: stream.url, placeholder: "https://…"}); + const from = field({type: "time", value: stream.from}); + const to = field({type: "time", value: stream.to === "24:00" ? "00:00" : stream.to}); + const on = h("input", {type: "checkbox", checked: stream.on}); + const days = WEEKDAYS.map((label, index) => h("input", {type: "checkbox", checked: stream.days.includes(index), "aria-label": label})); + const entry = {read: () => ({ + name: name.value.trim(), + url: url.value.trim(), + from: from.value, + to: to.value === "00:00" && stream.to === "24:00" ? "24:00" : to.value, + days: days.flatMap((box, index) => (box.checked ? [index] : [])), + on: on.checked, + })}; + entry.row = h("div", {class: "stream-row"}, + h("div", {class: "grid"}, h("label", {}, "Name", name), h("label", {}, "Stream-URL", url), h("label", {}, "von", from), h("label", {}, "bis", to)), + h("div", {class: "days"}, days.map((box, index) => h("label", {class: "check"}, box, WEEKDAYS[index])), h("label", {class: "check"}, on, "aktiv"), h("span", {class: "spacer"}), + btn("Entfernen", "btn small danger", () => { + rows.splice(rows.indexOf(entry), 1); + entry.row.remove(); + }))); + rows.push(entry); + list.append(entry.row); + } + + function settingsSheet() { + const number = (value, min, max, label) => h("input", {class: "field", type: "number", min, max, step: "any", value, required: true, "aria-label": label}); + const latitude = number(settings.latitude, -90, 90, "Breite"); + const longitude = number(settings.longitude, -180, 180, "Länge"); + const theme = h("select", {class: "field"}, THEME_CHOICES.map(([id, label]) => h("option", {value: id, selected: settings.theme === id}, label))); + const colors = Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, h("input", {type: "color", value: settings.colors[id]})])); + const custom = h("div", {class: "grid"}, COLOR_FIELDS.map(([id, label]) => h("label", {}, label, colors[id]))); + const verdict = h("p", {class: "muted", "aria-live": "polite"}); + const judge = () => { + custom.hidden = theme.value !== "custom"; + verdict.hidden = theme.value !== "custom"; + const panel = Ambience.THEMES[Ambience.modeOf(colors.top.value, colors.bottom.value)].panel; + const ratio = Ambience.contrast(Ambience.hex(colors.accent.value), Ambience.hex(panel)); + verdict.textContent = `Akzent auf Flächen: Kontrast ${ratio.toFixed(1)} : 1${ratio < 3 ? " – zu schwach, wähle einen kräftigeren Akzent" : ""}`; + }; + theme.addEventListener("change", judge); + Object.values(colors).forEach((input) => input.addEventListener("input", judge)); + judge(); + const musicOn = h("input", {type: "checkbox", checked: settings.music.on}); + const volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Gesamtlautstärke"}); + const tones = SOUND_KINDS.map(([id, label]) => { + const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} an`}); + const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} Lautstärke`}); + const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value)); + return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "an"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))}; + }); + const notify = h("input", {type: "checkbox", checked: settings.notify}); + const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts}); + const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday"})})); + const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)) + .map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `${entry.now} vorlesen`})})); + const absent = settings.speech.muted.filter((key) => !people()[key]); + const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "direct"})); + sample.disabled = !state.speech.tts; + const list = h("div", {class: "mini"}); + const rows = []; + settings.streams.forEach((stream) => streamRow(stream, rows, list)); + const form = h("form", {class: "settings"}, + h("section", {}, h("h3", {}, "Ort"), h("p", {class: "muted"}, "Sonne, Mond und Sterne stehen dort, wo du sie von hier aus siehst, Blick nach Süden; unter 🔭 Himmel (Taste h) drehst du den Blick. Dezimalgrad, Nord und Ost positiv."), + h("div", {class: "grid"}, h("label", {}, "Breite", latitude), h("label", {}, "Länge", longitude))), + h("section", {}, h("h3", {}, "Darstellung"), h("label", {}, "Farben", theme), custom, verdict), + h("section", {}, h("h3", {}, "Musik"), h("p", {class: "muted"}, "Generative Ambient-Musik, offline erzeugt: ruhig bei leerer Inbox, etwas gespannter, wenn etwas wartet, leiser unter Meldungen."), + h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Musik an"), h("label", {}, "Gesamtlautstärke (Musik und Radio)", volume))), + h("section", {}, h("h3", {}, "Töne und Benachrichtigungen"), h("p", {class: "muted"}, "MAYDAY und PAN-PAN sind immer am lautesten. Bei mehreren neuen Ereignissen spielt nur das dringendste."), + tones.map((tone) => tone.row), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")), + h("section", {}, h("h3", {}, "Funk vorlesen"), + h("p", {class: "muted"}, state.speech.tts + ? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen." + : "Piper oder seine Stimmen fehlen im Image; der Funk bleibt stumm."), + h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Funk vorlesen"), sample), + h("div", {class: "days", role: "group", "aria-label": "Klassen"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))), + stations.length ? h("div", {class: "days", role: "group", "aria-label": "Stationen"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null), + h("section", {}, h("h3", {}, "Radio"), h("p", {class: "muted"}, "Internetradio zu festen Zeiten mit 3 s Ein- und Ausblenden; die Musik pausiert solange. Die Seite darf nur von diesen Adressen Ton laden."), + list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "07:00", to: "09:00", days: [0, 1, 2, 3, 4], on: true}, rows, list)))), + passkeySection(), + h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Speichern"))); + form.addEventListener("submit", async (event) => { + event.preventDefault(); + context(); + const next = { + latitude: Number(latitude.value), + longitude: Number(longitude.value), + theme: theme.value, + colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])), + music: {on: musicOn.checked, volume: Number(volume.value)}, + sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])), + notify: notify.checked, + streams: rows.map((entry) => entry.read()), + speech: { + on: speechOn.checked, + classes: spoken.filter((entry) => entry.box.checked).map((entry) => entry.id), + muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)], + }, + space: savedSpace(), + }; + if (await saveSettings(next)) { + closeSheet(); + toast("Einstellungen gespeichert", [], true); + } + }); + openSheet("Einstellungen", [form], false); + } diff --git a/hub/templates/page-sky.js b/hub/templates/page-sky.js new file mode 100644 index 0000000..5978731 --- /dev/null +++ b/hub/templates/page-sky.js @@ -0,0 +1,209 @@ + const skyMoment = () => (live() ? Date.now() : (ui.asOf === null ? state.until : ui.asOf) * 1000); + + function applyTheme(sun) { + const custom = settings.theme === "custom"; + const mode = custom ? Ambience.modeOf(settings.colors.top, settings.colors.bottom) : settings.theme === "sky" ? Ambience.modeAt(sun.alt) : settings.theme; + const glass = Boolean(window.CSS && CSS.supports("(backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))")) && !window.matchMedia("(prefers-reduced-transparency: reduce)").matches; + const key = [mode, custom ? settings.colors.accent : "", glass].join("|"); + if (key === sky.mode) { + return; + } + sky.mode = key; + const root = document.documentElement; + const tokens = Ambience.THEMES[mode]; + for (const [name, value] of Object.entries(tokens)) { + root.style.setProperty(`--${name}`, value); + } + if (custom) { + root.style.setProperty("--accent", settings.colors.accent); + root.style.setProperty("--accent-soft", Ambience.css(Ambience.mix(Ambience.hex(settings.colors.accent), Ambience.hex(tokens.panel), 0.84))); + } + root.style.setProperty("--veil", `${glass ? Ambience.GLASS * 100 : 100}%`); + root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`); + root.style.colorScheme = mode; + root.dataset.mode = mode; + requestSpace(); + } + + function drawMoon(paint, at, radius, moon) { + const tilt = (moon.tilt * Math.PI) / 180; + paint.save(); + paint.translate(at.x, at.y); + paint.rotate(Math.atan2(-Math.cos(tilt), -Math.sin(tilt))); + const halo = paint.createRadialGradient(0, 0, radius, 0, 0, radius * 4); + halo.addColorStop(0, `rgb(235 240 255 / ${(0.28 * moon.lit).toFixed(3)})`); + halo.addColorStop(1, "rgb(235 240 255 / 0)"); + paint.fillStyle = halo; + paint.fillRect(-radius * 4, -radius * 4, radius * 8, radius * 8); + paint.fillStyle = "rgb(60 68 92 / 0.55)"; + paint.beginPath(); + paint.arc(0, 0, radius, 0, Math.PI * 2); + paint.fill(); + paint.fillStyle = "#f3f0e4"; + paint.beginPath(); + paint.arc(0, 0, radius, -Math.PI / 2, Math.PI / 2, false); + paint.ellipse(0, 0, Math.abs(2 * moon.lit - 1) * radius, radius, 0, Math.PI / 2, -Math.PI / 2, moon.lit < 0.5); + paint.closePath(); + paint.fill(); + paint.restore(); + } + + function drawSky() { + const ms = skyMoment(); + const sun = Ambience.sun(ms, settings.latitude, settings.longitude); + const moon = Ambience.moon(ms, settings.latitude, settings.longitude); + applyTheme(sun); + const view = seenView("sky"); + const facing = COMPASS[Space.compass(view.azimuth)]; + const turned = ["azimuth", "altitude", "fov"].some((key) => Math.abs(view[key] - Space.DEFAULTS.sky[key]) >= 0.5); + const moonText = moon.alt > 0 ? ` · Mond ${Math.round(moon.lit * 100)} % ${moon.waxing ? "zunehmend" : "abnehmend"}` : ""; + document.getElementById("sky-note").textContent = `${sun.alt > 0 ? "☀" : "☾"} ${STAGE_LABELS[Ambience.stage(sun.alt)]}${moonText}${turned ? ` · Blick ${facing[0]}` : ""}`; + if (space.look) { + const words = `Blick nach ${facing[1]} · ${((Math.round(view.azimuth) % 360) + 360) % 360}° · Höhe ${Math.round(view.altitude)}° · Sichtfeld ${Math.round(view.fov)}°`; + space.compass.textContent = words; + sky.canvas.setAttribute("aria-label", `Himmel, ${words}. Pfeiltasten drehen den Blick, Plus und Minus zoomen, 0 setzt zurück, Esc beendet.`); + } + const canvas = sky.canvas; + const paint = canvas.getContext("2d"); + if (!paint) { + return; + } + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const width = window.innerWidth; + const height = window.innerHeight; + if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) { + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + } + paint.setTransform(ratio, 0, 0, ratio, 0, 0); + const sight = Space.lens(view, width, height); + const place = (alt, az) => Space.place(sight, alt, az); + const inside = (at, margin) => at.x >= -margin && at.x <= width + margin && at.y >= -margin && at.y <= height + margin; + const seen = (body, at) => body.alt > -2 && Math.abs(at.lon) <= view.fov / 2 + 10 && inside(at, 100); + const colors = settings.theme === "custom" ? {top: Ambience.hex(settings.colors.top), bottom: Ambience.hex(settings.colors.bottom)} : Ambience.skyAt(sun.alt); + const low = place(0, view.azimuth); + const high = place(90, view.azimuth); + const air = paint.createLinearGradient(low.x + (high.x - low.x) / Space.REACH, low.y + (high.y - low.y) / Space.REACH, low.x, low.y); + air.addColorStop(0, Ambience.css(colors.top)); + air.addColorStop(1, Ambience.css(colors.bottom)); + paint.fillStyle = air; + paint.fillRect(0, 0, width, height); + const dusk = Math.max(0, 1 - Math.abs(sun.alt) / 14); + const glowAt = place(Math.max(sun.alt, -3), sun.az); + if (dusk > 0 && Math.abs(glowAt.lon) <= 135) { + const x = Math.min(Math.max(glowAt.x, sight.cx - sight.kx * 100), sight.cx + sight.kx * 100); + const glow = paint.createRadialGradient(x, glowAt.y, 0, x, glowAt.y, width * 0.5); + glow.addColorStop(0, `rgb(255 186 128 / ${(0.45 * dusk).toFixed(3)})`); + glow.addColorStop(1, "rgb(255 186 128 / 0)"); + paint.fillStyle = glow; + paint.fillRect(0, 0, width, height); + } + const light = Ambience.starlight(sun.alt); + if (light > 0) { + const spin = Ambience.sidereal(ms) + settings.longitude; + paint.fillStyle = "#ffffff"; + for (const star of sky.stars) { + const spot = Ambience.horizontal(spin - star.ra, star.dec, settings.latitude); + const at = spot.alt > 1 ? place(spot.alt, spot.az) : null; + if (at && Math.abs(at.lon) <= view.fov / 2 && inside(at, 4)) { + paint.globalAlpha = light * (0.25 + 0.75 * star.glow); + paint.beginPath(); + paint.arc(at.x, at.y, 0.5 + star.glow * 1.2, 0, Math.PI * 2); + paint.fill(); + } + } + paint.globalAlpha = 1; + } + const sunAt = place(sun.alt, sun.az); + if (seen(sun, sunAt)) { + const at = sunAt; + const halo = paint.createRadialGradient(at.x, at.y, 0, at.x, at.y, 90); + halo.addColorStop(0, "rgb(255 236 200 / 0.55)"); + halo.addColorStop(1, "rgb(255 236 200 / 0)"); + paint.fillStyle = halo; + paint.fillRect(at.x - 90, at.y - 90, 180, 180); + paint.fillStyle = Ambience.css(Ambience.mix([255, 170, 90], [255, 246, 220], Math.min(Math.max(sun.alt / 20, 0), 1))); + paint.beginPath(); + paint.arc(at.x, at.y, 15, 0, Math.PI * 2); + paint.fill(); + } + const moonAt = place(moon.alt, moon.az); + if (seen(moon, moonAt)) { + drawMoon(paint, moonAt, 12, moon); + } + const edge = Space.horizon(sight, 96); + const bottom = Math.max(height, ...edge.map((at) => at.y)) + 2; + const trace = (lift) => edge.forEach((at, index) => (index ? paint.lineTo(at.x, at.y + lift) : paint.moveTo(at.x, at.y + lift))); + const ground = paint.createLinearGradient(0, low.y, 0, low.y + height - sight.cy); + ground.addColorStop(0, Ambience.css(Ambience.mix(colors.bottom, [4, 6, 10], 0.72))); + ground.addColorStop(1, Ambience.css(Ambience.mix(colors.bottom, [4, 6, 10], 0.9))); + paint.fillStyle = ground; + paint.beginPath(); + trace(0); + paint.lineTo(edge[edge.length - 1].x, bottom); + paint.lineTo(edge[0].x, bottom); + paint.closePath(); + paint.fill(); + paint.strokeStyle = "rgb(255 255 255 / 0.18)"; + paint.lineWidth = 1; + paint.beginPath(); + trace(0.5); + paint.stroke(); + paint.fillStyle = "rgb(255 255 255 / 0.5)"; + paint.font = "600 11px system-ui, sans-serif"; + paint.textAlign = "center"; + COMPASS.forEach(([label], index) => { + const at = place(0, index * 45); + if (Math.abs(at.lon) <= view.fov / 2 + 0.5) { + paint.fillText(label, Math.min(Math.max(at.x, 14), width - 14), at.y + 16); + } + }); + if (space.glides.sky) { + redrawSky(); + } + } + + function redrawSky() { + window.cancelAnimationFrame(sky.frame); + sky.frame = window.requestAnimationFrame(drawSky); + } + + function skyLook(on) { + const canvas = sky.canvas; + const button = document.getElementById("sky-look"); + space.look = on; + document.body.classList.toggle("sky-look", on); + button.setAttribute("aria-pressed", String(on)); + document.getElementById("sky-panel").hidden = !on; + if (on) { + canvas.removeAttribute("aria-hidden"); + canvas.setAttribute("tabindex", "0"); + canvas.setAttribute("role", "application"); + canvas.setAttribute("aria-roledescription", "Himmel"); + drawSky(); + canvas.focus(); + return; + } + for (const name of ["tabindex", "role", "aria-roledescription", "aria-label"]) { + canvas.removeAttribute(name); + } + canvas.setAttribute("aria-hidden", "true"); + button.focus(); + } + + function skyPanel() { + const panel = document.getElementById("sky-panel"); + const step = (text, label, run) => h("button", {type: "button", class: "btn small", title: label, "aria-label": label, on: {click: run}}, text); + space.compass = h("span", {class: "compass", "aria-live": "polite"}); + panel.append(space.compass, + step("◀", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)), + step("▶", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)), + step("▲", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)), + step("▼", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)), + step("−", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)), + step("+", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)), + step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")), + step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false))); + steer(sky.canvas, "sky", skyHooks); + document.getElementById("sky-look").addEventListener("click", () => skyLook(!space.look)); + } diff --git a/hub/templates/page-space-scene.js b/hub/templates/page-space-scene.js new file mode 100644 index 0000000..86914dc --- /dev/null +++ b/hub/templates/page-space-scene.js @@ -0,0 +1,197 @@ + function shownCursor() { + const glide = space.cursor; + if (glide.to === null) { + return state.until; + } + const progress = calm.matches ? 1 : Math.min((performance.now() - glide.start) / PLANE, 1); + return progress >= 1 ? glide.to : glide.from + (glide.to - glide.from) * Space.ease(progress); + } + + function sceneInput() { + const span = [Math.min(Math.max(state.since, state.first), state.until), state.until]; + const range = ui.asOf !== null && ui.preset !== -1 ? [Math.min(state.first, span[0]), Math.max(state.now, span[1])] : span; + const lives = {}; + const subagents = {}; + for (const [key, entry] of space.subs) { + if (entry.data) { + lives[key] = [entry.data.since, entry.data.until]; + subagents[key] = entry.data.subagents; + } + } + return { + axis: space.views.graph.axis, range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)), + lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP, + }; + } + + function buildSpace() { + const input = sceneInput(); + space.sceneCursor = input.cursor; + space.scene = Space.scene(input); + } + + function spaceLabel() { + const hint = AXES.find(([id]) => id === space.views.graph.axis)[2]; + return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`; + } + + function stageSpace() { + if (!graphDom.space) { + const canvas = h("canvas", {class: "space", tabindex: 0, role: "application", "aria-roledescription": "3D-Graph", "aria-label": spaceLabel(), "aria-describedby": "space-help"}); + steer(canvas, "graph", graphHooks); + graphDom.space = {canvas, note: h("div", {class: "space-note", "aria-live": "polite"}), list: h("div", {class: "space-list"}), data: null, version: -1, node: undefined, words: "", open: false}; + graphDom.stage.replaceChildren(canvas, graphDom.space.note, graphDom.space.list); + } + if (space.cursor.to !== state.until) { + space.cursor = {from: space.cursor.to === null ? state.until : shownCursor(), to: state.until, start: performance.now()}; + } + if (wantsTrack() && !state.graph.track.length && Date.now() - space.trackAsked > TRACK_RETRY) { + space.trackAsked = Date.now(); + load(); + } + buildSpace(); + const held = graphDom.space; + if (held.data !== state || held.version !== space.version || held.node !== ui.node) { + held.data = state; + held.version = space.version; + held.node = ui.node; + drawSpaceList(); + refreshSubs(); + } + drawSpaceNote(); + requestSpace(); + } + + function drawSpaceNote() { + const built = space.scene; + const format = (ts) => absolute.format(stampOf(ts)); + const axis = built.axis === "time" ? `Zeitachse ${format(built.range[0])} – ${format(built.range[1])}` : `Stand ${format(built.span[1])}`; + const words = `${built.lines.length} Weltlinien · ${built.arcs.length} Bögen · ${axis}${built.dropped ? ` · ${built.dropped} weitere Ereignisse nur im Stream` : ""}`; + if (graphDom.space.words === words) { + return; + } + graphDom.space.words = words; + const dropped = built.dropped ? [h("span", {}, "Gezeichnet: Mayday und Pan-Pan, Spawns, Sicherheit, Freigaben, dann die neuesten."), btn("Funk im Stream", "btn small soft", () => useView("radio"))] : []; + graphDom.space.note.replaceChildren(h("span", {}, words), ...dropped); + } + + function drawSpaceList() { + const built = space.scene; + const held = graphDom.space; + const chip = (line) => { + const text = line.kind === "twig" ? short(line.title) : Space.nameAt(line.timeline, built.span[1]); + const former = [...new Set(line.timeline.map((entry) => entry.name))]; + const hint = line.kind === "twig" ? `${line.title} · Transkript im Stream zeigen` : `${NODE_KINDS[line.kind]}${former.length > 1 ? ` · Namen: ${former.join(" → ")}` : ""}`; + return h("button", {type: "button", class: "chip", title: hint, "aria-label": `${text}: ${hint}`, "aria-pressed": String(line.node !== null && line.node === ui.node), on: {click: () => pickLine(line)}}, line.kind === "twig" ? text : [face(text, false), text]); + }; + const twigs = built.lines.filter((line) => line.kind === "twig"); + const more = twigs.length ? h("details", {open: held.open}, h("summary", {}, `${twigs.length} Subagenten aus den Transkripten`), h("div", {class: "chips"}, twigs.map(chip))) : null; + if (more) { + more.addEventListener("toggle", () => { + held.open = more.open; + }); + } + held.list.replaceChildren(h("div", {class: "chips", role: "group", "aria-label": "Weltlinien"}, built.lines.filter((line) => line.kind !== "twig").map(chip)), more || ""); + } + + async function refreshSubs() { + if (!graph3d() || space.loading) { + return; + } + const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id)) + .filter((node) => !space.subs.has(node.id) || Date.now() - space.subs.get(node.id).at > SUBS_TTL); + if (!due.length) { + return; + } + space.loading = true; + await Promise.all(due.map(async (node) => { + let data = null; + try { + const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"}); + data = response.ok ? await response.json() : null; + } catch { + data = null; + } + space.subs.set(node.id, {at: Date.now(), data}); + })); + space.loading = false; + space.version += 1; + stage(); + } + + function spaceStyle() { + if (!space.style || space.styleKey !== sky.mode) { + const css = window.getComputedStyle(document.documentElement); + const value = (name) => css.getPropertyValue(`--${name}`).trim(); + const roles = {line: "line-strong", accent: "accent", muted: "muted", violet: "violet", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", panel: "panel", text: "text"}; + space.styleKey = sky.mode; + space.style = { + colors: Object.fromEntries(Object.entries(roles).map(([role, name]) => [role, value(name)])), + font: `560 ${value("fs-sm")} ${value("sans")}`, strong: `700 ${value("fs-sm")} ${value("sans")}`, level: "Tiefe", + }; + } + const built = space.scene; + return { + ...space.style, selected: ui.node, cursor: live() ? "Jetzt" : "Stand", + format: (ts) => (built.range[1] - built.range[0] > DAY ? absolute : clock).format(stampOf(ts)), + }; + } + + function requestSpace() { + if (!space.frame) { + space.frame = window.requestAnimationFrame(paintSpace); + } + } + + function paintSpace() { + space.frame = 0; + if (!graph3d() || !graphDom || !graphDom.space || !space.scene) { + return; + } + const canvas = graphDom.space.canvas; + const paint = canvas.getContext("2d"); + if (!paint) { + return; + } + const cursor = shownCursor(); + if (cursor !== space.sceneCursor) { + buildSpace(); + } + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const width = canvas.clientWidth; + const height = canvas.clientHeight; + if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) { + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + } + paint.setTransform(ratio, 0, 0, ratio, 0, 0); + space.cam = Space.camera(seenView("graph"), width, height); + Space.paint(paint, space.scene, space.cam, spaceStyle()); + if (space.glides.graph || cursor !== space.cursor.to) { + requestSpace(); + } + } + + function pickLine(line) { + if (line.kind === "twig") { + focusSub(line.session, line.sub); + } else { + select(line.node); + } + } + + function pickSpace(x, y) { + const hit = space.scene && space.cam ? Space.pick(space.scene, space.cam, x, y, 10) : null; + if (!hit) { + return; + } + if (hit.kind === "line") { + pickLine(hit.line); + } else if (hit.arc.kind === "spawn") { + focusSub(hit.arc.source, hit.arc.sub); + } else if (hit.arc.kind === "grant") { + select(hit.arc.target); + } else { + useSaved({name: "", kinds: ["radio"], waiting: false, q: "", agent: hit.arc.row >= 0 ? state.radio[hit.arc.row].from : nameOf(hit.arc.source)}); + } + } diff --git a/hub/templates/page-space-steer.js b/hub/templates/page-space-steer.js new file mode 100644 index 0000000..94eab56 --- /dev/null +++ b/hub/templates/page-space-steer.js @@ -0,0 +1,194 @@ + function spaceTools() { + const axis = space.views.graph.axis; + const deep = axis !== "none"; + return h("div", {class: "space-tools"}, + h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(axis === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))), + deep ? h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht") : null, + deep ? h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0") : null); + } + + const graph3d = () => ui.screen === "graph" && ui.graph === "orbit" && space.views.graph.axis !== "none"; + const wantsTrack = () => graph3d() && space.views.graph.axis === "time"; + + function chooseAxis(id) { + if (id === space.views.graph.axis) { + return; + } + setView("graph", {...space.views.graph, axis: id}, false); + render(false); + } + + function stage() { + if (!graphDom || ui.screen !== "graph") { + return; + } + graphDom.pulses = null; + if (graph3d()) { + stageSpace(); + return; + } + graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.graph]()); + } + + function drawSide() { + if (graphDom && ui.screen === "graph") { + graphDom.aside.replaceChildren(...side()); + } + } + + function seenView(kind) { + const glide = space.glides[kind]; + const progress = glide && !calm.matches ? Math.min((performance.now() - glide.start) / GLIDE, 1) : 1; + if (progress >= 1) { + space.glides[kind] = null; + return space.views[kind]; + } + return Space.tween(kind, glide.from, space.views[kind], Space.ease(progress)); + } + + function settle(kind) { + if (space.glides[kind]) { + space.views[kind] = Space.clampView(kind, seenView(kind)); + space.glides[kind] = null; + } + } + + function setView(kind, next, smooth) { + space.glides[kind] = smooth && !calm.matches ? {from: seenView(kind), start: performance.now()} : null; + space.views[kind] = Space.clampView(kind, next); + space.edits += 1; + window.clearTimeout(space.timer); + space.timer = window.setTimeout(persistViews, SAVE_DELAY); + if (kind === "graph") { + requestSpace(); + } else { + redrawSky(); + } + } + + const nudge = (kind, change, smooth) => setView(kind, change(space.views[kind]), smooth); + const savedSpace = () => ({graph: Space.tidy(space.views.graph), sky: Space.tidy(space.views.sky)}); + + async function persistViews() { + const mark = space.edits; + if (await saveSettings({...settings, space: savedSpace()}) && space.edits === mark) { + space.saved = mark; + } + } + + function resetView(kind) { + setView(kind, kind === "graph" ? {...Space.DEFAULTS.graph, axis: space.views.graph.axis} : {...Space.DEFAULTS.sky}, true); + } + + function steerKey(event, kind, hooks) { + const arrows = {ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1]}; + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (Object.hasOwn(arrows, event.key)) { + const [x, y] = arrows[event.key]; + if (event.shiftKey && hooks.panStep) { + hooks.panStep(x, y); + } else { + hooks.turnStep(x, y); + } + } else if (event.key === "+" || event.key === "=") { + hooks.zoomStep(ZOOM); + } else if (event.key === "-" || event.key === "_") { + hooks.zoomStep(1 / ZOOM); + } else if (event.key === "0") { + resetView(kind); + } else if (event.key === "Escape" && kind === "sky") { + skyLook(false); + } else { + return; + } + event.preventDefault(); + event.stopPropagation(); + } + + function steer(element, kind, hooks) { + const pointers = new Map(); + let travel = 0; + let spread = 0; + const gap = () => { + const [a, b] = [...pointers.values()]; + return Math.hypot(a.x - b.x, a.y - b.y); + }; + element.addEventListener("pointerdown", (event) => { + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + element.setPointerCapture(event.pointerId); + pointers.set(event.pointerId, {x: event.clientX, y: event.clientY}); + settle(kind); + travel = pointers.size === 1 ? 0 : Infinity; + spread = pointers.size === 2 ? gap() : 0; + }); + element.addEventListener("pointermove", (event) => { + const last = pointers.get(event.pointerId); + if (!last) { + return; + } + const dx = event.clientX - last.x; + const dy = event.clientY - last.y; + pointers.set(event.pointerId, {x: event.clientX, y: event.clientY}); + travel += Math.abs(dx) + Math.abs(dy); + if (pointers.size === 2) { + const next = gap(); + if (spread > 0 && next > 0) { + hooks.zoom(next / spread); + } + spread = next; + } else if (pointers.size === 1 && travel > 3) { + if (event.shiftKey && hooks.pan) { + hooks.pan(dx, dy); + } else { + hooks.rotate(dx, dy); + } + } + }); + const release = (event) => { + if (!pointers.delete(event.pointerId)) { + return; + } + spread = pointers.size === 2 ? gap() : 0; + if (event.type === "pointerup" && !pointers.size && travel <= 3 && hooks.pick) { + const box = element.getBoundingClientRect(); + hooks.pick(event.clientX - box.left, event.clientY - box.top); + } + }; + element.addEventListener("pointerup", release); + element.addEventListener("pointercancel", release); + element.addEventListener("wheel", (event) => { + event.preventDefault(); + settle(kind); + hooks.zoom(Math.exp(-event.deltaY * [1, 16, 400][event.deltaMode] * 0.0015)); + }, {passive: false}); + element.addEventListener("keydown", (event) => steerKey(event, kind, hooks)); + } + + const graphHooks = { + rotate: (dx, dy) => nudge("graph", (view) => ({...view, yaw: view.yaw - dx * 0.4, pitch: view.pitch + dy * 0.4}), false), + pan: (dx, dy) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + dx / (space.cam ? space.cam.scale : 1), pan_y: view.pan_y + dy / (space.cam ? space.cam.scale : 1)}), false), + zoom: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), false), + turnStep: (x, y) => nudge("graph", (view) => ({...view, yaw: view.yaw - x * TURN, pitch: view.pitch + y * TURN}), true), + panStep: (x, y) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + x * NUDGE, pan_y: view.pan_y + y * NUDGE}), true), + zoomStep: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), true), + pick: (x, y) => pickSpace(x, y), + }; + + const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight); + + const skyHooks = { + rotate: (dx, dy) => { + const sight = skyLens(); + nudge("sky", (view) => ({...view, azimuth: view.azimuth - dx / sight.kx, altitude: view.altitude + dy / sight.ky}), false); + }, + pan: null, + zoom: (factor) => nudge("sky", (view) => ({...view, fov: view.fov / factor}), false), + turnStep: (x, y) => nudge("sky", (view) => ({...view, azimuth: view.azimuth + x * TURN, altitude: view.altitude - y * TURN}), true), + panStep: null, + zoomStep: (factor) => nudge("sky", (view) => ({...view, fov: view.fov / factor}), true), + pick: null, + }; diff --git a/hub/templates/page-speech.js b/hub/templates/page-speech.js new file mode 100644 index 0000000..e034afe --- /dev/null +++ b/hub/templates/page-speech.js @@ -0,0 +1,173 @@ + function loudness(kind) { + return Voice.loudness(kind, settings); + } + + function speechFailed(lines) { + if (Date.now() - speech.warned > SPEECH_QUIET) { + speech.warned = Date.now(); + toast("Funk vorlesen fehlgeschlagen", lines, false); + } + } + + function say(item) { + speech.pending = Voice.enqueue(speech.pending, item, SPEECH_QUEUE); + pump(); + } + + async function pump() { + if (speech.busy || !speech.pending.length) { + return; + } + const sound = context(); + if (!sound) { + speech.pending = []; + return; + } + const [item, ...rest] = speech.pending; + speech.pending = rest; + speech.busy = true; + const next = () => { + speech.busy = false; + pump(); + }; + try { + const response = await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Hub": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: item.text, voice: item.voice}), cache: "no-store"}); + if (!response.ok) { + throw new Error(`${response.status} ${(await response.text()).trim()}`); + } + const buffer = await sound.decodeAudioData(await response.arrayBuffer()); + const source = sound.createBufferSource(); + const gain = sound.createGain(); + gain.gain.value = loudness(item.tone); + source.buffer = buffer; + source.connect(gain).connect(sound.destination); + source.addEventListener("ended", next, {once: true}); + duck(buffer.duration); + source.start(); + } catch (error) { + speechFailed([String(error.message || error)]); + next(); + } + } + + function speakRows(rows) { + if (!settings.speech.on || !state.speech.tts) { + return; + } + for (const row of rows.filter((entry) => entry.ts >= state.now - SPEECH_FRESH && Voice.speakable(entry, settings.speech))) { + say({text: row.speech, voice: row.voice, tone: Voice.toneOf(row, HUMAN), cls: row.class}); + } + } + + function micButton(field) { + const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙"); + const reason = !window.isSecureContext || !navigator.mediaDevices + ? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost" + : !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? "Spracheingabe ist aus: whisper.cpp oder sein Modell fehlt im Image" : ""; + if (reason) { + button.disabled = true; + button.title = reason; + return button; + } + const press = (event) => { + event.preventDefault(); + record(field, button); + }; + button.addEventListener("pointerdown", press); + for (const name of ["pointerup", "pointerleave", "pointercancel"]) { + button.addEventListener(name, release); + } + button.addEventListener("keydown", (event) => { + if ((event.key === " " || event.key === "Enter") && !event.repeat) { + press(event); + } + }); + button.addEventListener("keyup", (event) => { + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + release(); + } + }); + return button; + } + + async function record(field, button) { + if (mic.held || mic.recorder || mic.busy) { + return; + } + mic.held = true; + let media = null; + try { + media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}}); + } catch (error) { + mic.held = false; + toast("Mikrofon nicht verfügbar", [String(error.message || error)], false); + return; + } + if (!mic.held) { + media.getTracks().forEach((track) => track.stop()); + return; + } + const recorder = new MediaRecorder(media); + const chunks = []; + recorder.addEventListener("dataavailable", (event) => { + if (event.data.size) { + chunks.push(event.data); + } + }); + recorder.addEventListener("stop", () => { + media.getTracks().forEach((track) => track.stop()); + mic.recorder = null; + button.setAttribute("aria-pressed", "false"); + transcribe(new Blob(chunks, {type: recorder.mimeType}), field, button); + }, {once: true}); + mic.recorder = recorder; + recorder.start(); + button.setAttribute("aria-pressed", "true"); + mic.timer = window.setTimeout(release, state.speech.seconds * 1000); + } + + function release() { + mic.held = false; + window.clearTimeout(mic.timer); + if (mic.recorder && mic.recorder.state === "recording") { + mic.recorder.stop(); + } + } + + async function transcribe(blob, field, button) { + const sound = context(); + if (!sound || !blob.size) { + return; + } + mic.busy = true; + button.disabled = true; + try { + const decoded = await sound.decodeAudioData(await blob.arrayBuffer()); + const rate = state.speech.rate; + const length = Voice.frames(decoded.duration, rate, state.speech.seconds); + if (length < rate / 4) { + throw new Error("zu kurz: halte die Taste, solange du sprichst"); + } + const offline = new OfflineAudioContext(1, length, rate); + const source = offline.createBufferSource(); + source.buffer = decoded; + source.connect(offline.destination); + source.start(); + const rendered = await offline.startRendering(); + const result = await send("/api/stt", Voice.wav(rendered.getChannelData(0), rate), "audio/wav"); + if (!result.ok || !result.data) { + throw new Error(`${result.status} ${result.text}`.trim()); + } + if (result.data.text) { + field.value = [field.value.trim(), result.data.text].filter(Boolean).join(" "); + field.dispatchEvent(new Event("input", {bubbles: true})); + } + field.focus(); + } catch (error) { + toast("Spracheingabe fehlgeschlagen", [String(error.message || error)], false); + } finally { + mic.busy = false; + button.disabled = false; + } + } diff --git a/hub/templates/page-state.js b/hub/templates/page-state.js new file mode 100644 index 0000000..468fb4a --- /dev/null +++ b/hub/templates/page-state.js @@ -0,0 +1,60 @@ + function stored(key, fallback) { + try { + const text = window.localStorage.getItem(key); + return text === null ? fallback : JSON.parse(text); + } catch { + return fallback; + } + } + + function keep(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } + } + + function validViews(value) { + const names = new Set(KINDS.map(([id]) => id)); + if (!Array.isArray(value)) { + return []; + } + return value.filter((view) => view && typeof view.name === "string" && view.name && Array.isArray(view.kinds) && view.kinds.length && view.kinds.every((name) => names.has(name))) + .map((view) => ({name: view.name.slice(0, 60), kinds: view.kinds, waiting: view.waiting === true, q: typeof view.q === "string" ? view.q : "", agent: typeof view.agent === "string" ? view.agent : ""})); + } + + let state = JSON.parse(document.getElementById("state").textContent); + const ui = {screen: "stream", graph: "orbit", preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0}; + const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))}; + const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""}; + const tree = {data: null, busy: false, q: "", open: new Set(), dom: null}; + const palette = {items: [], index: 0, input: null, list: null, plan: null, box: null}; + const known = {inbox: null, alarms: null, radio: null, batches: new Set()}; + const motion = {current: new Map(), from: new Map(), target: new Map(), start: 0, duration: 0, frame: 0, asOf: undefined}; + const drawn = {screen: "", sig: ""}; + const files = new Map(); + const pageMedia = new Set(state.media); + const sky = {canvas: document.getElementById("sky"), stars: Ambience.stars(320, 7), mode: "", frame: 0}; + const music = {gain: null, filter: null, drone: [], timer: 0, tension: 0, ducked: 0}; + const radio = {element: null, url: "", level: 0, timer: 0, playing: false, waiting: false}; + const speech = {pending: [], busy: false, warned: 0}; + const mic = {recorder: null, held: false, busy: false, timer: 0}; + const space = { + views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, + scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0, + cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0, + }; + let userViews = validViews(stored(STORE.views, [])); + let settings = state.settings; + let settingsSig = JSON.stringify(settings); + let drawerDom = null; + let timeDom = null; + let graphDom = null; + let reviewDom = null; + let replay = 0; + let positions = new Map(); + let inboxSig = ""; + let audio = null; + let searchTimer = 0; diff --git a/hub/templates/page-stream.js b/hub/templates/page-stream.js new file mode 100644 index 0000000..813e6f9 --- /dev/null +++ b/hub/templates/page-stream.js @@ -0,0 +1,186 @@ + async function fetchEvents() { + if (stream.busy) { + stream.again = true; + return; + } + const key = `${windowKey()}&${filterQuery()}`; + if (stream.key !== key) { + stream.key = key; + stream.events = []; + stream.entries = []; + stream.last = 0; + } + stream.busy = true; + try { + const response = await fetch(`/api/events?${windowQuery()}&${filterQuery()}&after=${stream.last}`, {cache: "no-store"}); + if (response.ok) { + const data = await response.json(); + if (stream.key === key) { + const seen = new Set(stream.entries.map((entry) => entry.id)); + stream.events = data.events; + stream.entries = [...stream.entries, ...data.entries.filter((entry) => !seen.has(entry.id))].sort((a, b) => a.ts - b.ts).slice(-STREAM_KEEP); + stream.last = data.last; + stream.sessions = data.sessions; + stream.error = ""; + } + } else { + stream.error = `${response.status} ${(await response.text()).trim()}`; + } + } catch { + ui.offline = true; + } + stream.busy = false; + drawStream(); + if (stream.again) { + stream.again = false; + fetchEvents(); + } + } + + async function expand(entry, holder, button) { + button.disabled = true; + const response = await fetch(`/api/stream/${entry.ref.entry}`, {cache: "no-store"}); + if (response.ok) { + const data = await response.json(); + entry.text = data.text; + entry.ref.truncated = false; + holder.textContent = data.text; + button.remove(); + } else { + button.textContent = "Nicht mehr verfügbar"; + } + } + + function focusSub(session, sub) { + filter.view = ""; + filter.saved = ""; + filter.kinds = new Set(["transcript"]); + filter.waiting = false; + filter.session = session; + filter.sub = sub; + closeAgent(); + if (ui.screen === "stream" && stream.dom) { + refilter(); + } else { + go("stream", null); + } + } + + function inboxOf(entry) { + const id = entry.kind === "operator" ? entry.ref.task : entry.ref.id; + return state.inbox.find((item) => item.kind === entry.kind && item.id === id) || null; + } + + function kindPill(entry) { + if (entry.kind === "radio") { + return klass(entry.part); + } + if (entry.kind === "transcript") { + return pill((PARTS.find(([id]) => id === entry.part) || [entry.part, entry.part])[1], PART_TONES[entry.part] || "neutral"); + } + return pill((KINDS.find(([id]) => id === entry.kind) || [entry.kind, entry.kind])[1], KIND_TONES[entry.kind] || "neutral"); + } + + function extras(entry) { + const ref = entry.ref; + if (entry.kind === "review") { + return [status(ref.status), ref.gate ? pill(ref.gate === "pass" ? "Gate grün" : "Gate rot", ref.gate === "pass" ? "ok" : "bad") : null]; + } + if (entry.kind === "ask") { + return [status(ref.status), ...ref.warnings.map((warning) => pill(warning, "bad"))]; + } + if (entry.kind === "decision") { + return [status(ref.status), ref.choice ? pill(ref.choice, "ok") : null]; + } + if (entry.kind === "cron") { + return [pill(ref.active ? "aktiv" : "inaktiv", ref.active ? "ok" : "neutral")]; + } + if (entry.kind === "run" || entry.kind === "operator" || entry.kind === "register") { + return [ref.status ? status(ref.status) : null]; + } + return []; + } + + function body(entry) { + if (entry.kind === "transcript") { + const text = h("pre", {}, entry.text); + const more = entry.ref.truncated ? h("button", {type: "button", class: "btn small ghost"}, "Ganzen Text zeigen") : null; + if (more) { + more.addEventListener("click", () => expand(entry, text, more)); + } + return [text, more]; + } + if (entry.kind === "ask" || entry.kind === "operator") { + const where = entry.kind === "ask" ? entry.ref.cwd : entry.ref.directory; + return [h("div", {class: "command"}, h("code", {}, entry.kind === "ask" ? entry.text : entry.title), copyButton(entry.kind === "ask" ? entry.text : entry.title)), h("span", {class: "muted"}, `in ${dash(where)}`), entry.kind === "operator" && entry.text !== "-" ? h("p", {class: "text"}, entry.text) : null]; + } + if (entry.kind === "review") { + return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, btn("Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))]; + } + if (entry.kind === "cron") { + const ref = entry.ref; + const ended = ref.deleted ? [" · beendet ", when(ref.deleted)] : []; + return [entry.text ? h("pre", {}, entry.text) : null, h("span", {class: "muted"}, "angelegt ", when(ref.created), ended)]; + } + if (entry.kind === "run") { + return [entry.text ? h("pre", {}, entry.text) : null]; + } + return [entry.text ? h("p", {class: "text"}, entry.text) : null]; + } + + function eventRow(entry) { + const item = entry.waiting ? inboxOf(entry) : null; + const alarm = entry.kind === "radio" && (entry.part === "mayday" || entry.part === "pan-pan"); + const renamed = entry.now && entry.now !== entry.agent ? h("span", {title: `jetzt: ${entry.now}`}, person(entry.agent)) : person(entry.agent); + const who = entry.agent ? [face(entry.agent, false), renamed] : entry.session ? h("strong", {title: entry.session}, entry.session.slice(0, 8)) : null; + const headline = entry.kind === "transcript" || entry.kind === "ask" || entry.kind === "operator" ? null : h("span", {class: "headline"}, entry.title); + return h("li", {class: `entry k-${entry.kind}${entry.waiting ? " waiting" : ""}${alarm ? " alarm" : ""}`}, + h("div", {class: "msg-meta"}, when(entry.ts), kindPill(entry), extras(entry)), + h("div", {}, + h("div", {class: "route"}, who, entry.sub ? h("button", {type: "button", class: "linkish muted", on: {click: () => focusSub(entry.session, entry.sub)}}, `› Subagent ${entry.sub.slice(0, 12)}`) : null, entry.kind === "ask" ? h("strong", {}, entry.title) : null, headline), + body(entry), + item ? h("div", {class: "line-actions"}, actionsFor(item)) : null)); + } + + function drawStream() { + if (!stream.dom || ui.screen !== "stream") { + return; + } + const floor = live() && ui.preset > 0 ? nowSeconds() - ui.preset : -Infinity; + const rows = [...stream.events, ...stream.entries.filter((entry) => entry.ts >= floor)].sort((a, b) => b.ts - a.ts || (a.id < b.id ? 1 : -1)); + const shown = rows.slice(0, FEED_LIMIT); + stream.dom.count.textContent = `${rows.length} Einträge${live() ? " · folgt live" : ""}`; + if (stream.error) { + stream.dom.list.replaceChildren(emptyState("Stream nicht lesbar", stream.error)); + return; + } + stream.dom.list.replaceChildren(rows.length + ? h("div", {class: "card flush"}, h("ol", {class: "feed"}, shown.map(eventRow)), + rows.length > shown.length ? h("p", {class: "footnote"}, `${rows.length - shown.length} ältere Einträge ausgeblendet; grenze mit Filter oder Zeitraum ein.`) : null) + : emptyState(stream.busy ? "Lade …" : "Keine Einträge", activeWaiting() ? "Gerade wartet nichts auf dich." : "Im gewählten Zeitraum und Filter gibt es nichts.")); + } + + function streamScreen() { + const agents = h("select", {"aria-label": "Agent", on: {change: (event) => { + filter.agent = event.target.value; + filter.saved = ""; + refilter(); + }}}); + const search = h("input", {type: "search", placeholder: "Suchen … (/)", value: filter.q, "aria-label": "Stream durchsuchen", on: {input: (event) => { + filter.q = event.target.value; + window.clearTimeout(searchTimer); + searchTimer = window.setTimeout(fetchEvents, 300); + }}}); + const count = h("span", {class: "muted"}); + const presets = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"}); + const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"}); + const parts = h("div", {class: "chips", role: "group", "aria-label": "Transkript-Teile"}); + const list = h("div", {}); + stream.dom = {agents, search, count, presets, kinds, parts, list}; + window.setTimeout(() => { + drawFilters(); + drawStream(); + fetchEvents(); + }, 0); + return h("div", {}, presets, h("div", {class: "filters"}, agents, search, count), kinds, parts, list); + } diff --git a/hub/templates/page-time.js b/hub/templates/page-time.js new file mode 100644 index 0000000..4b6c735 --- /dev/null +++ b/hub/templates/page-time.js @@ -0,0 +1,131 @@ + function windowQuery() { + if (ui.preset === -1) { + return `from=${Math.floor(ui.from)}&to=${Math.ceil(ui.to)}`; + } + const end = ui.asOf === null ? nowSeconds() : ui.asOf; + const begin = ui.preset === 0 ? 0 : end - ui.preset; + return `from=${Math.floor(begin)}${ui.asOf === null ? "" : `&to=${Math.ceil(end)}`}`; + } + + const localInput = (ts) => { + const date = stampOf(ts); + return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); + }; + + function timeBar() { + const bar = document.getElementById("timebar"); + const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"}); + const from = h("input", {type: "datetime-local", "aria-label": "Von"}); + const to = h("input", {type: "datetime-local", "aria-label": "Bis"}); + const custom = h("span", {class: "custom", hidden: true}, from, h("span", {class: "muted"}, "bis"), to, + h("button", {type: "button", class: "btn small soft", on: {click: () => { + const begin = Date.parse(from.value) / 1000; + const end = Date.parse(to.value) / 1000; + if (!Number.isFinite(begin) || !Number.isFinite(end) || begin >= end) { + toast("Zeitraum ungültig", ["„Von“ muss vor „Bis“ liegen."], false); + return; + } + ui.from = begin; + ui.to = end; + reload(); + }}}, "Anwenden")); + const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"}); + const play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe"); + const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live"); + const label = h("span", {class: "window-label"}); + for (const [seconds, name] of PRESETS) { + presets.append(h("button", {type: "button", "data-preset": seconds, on: {click: () => choosePreset(seconds)}}, name)); + } + slider.addEventListener("input", () => { + stopReplay(); + const span = state.now - state.first; + ui.asOf = Number(slider.value) >= 1000 ? null : state.first + (span * Number(slider.value)) / 1000; + reload(); + }); + play.addEventListener("click", () => (ui.playing ? stopReplay() : startReplay())); + liveButton.addEventListener("click", () => { + stopReplay(); + ui.asOf = null; + if (ui.preset === -1) { + ui.preset = DAY; + } + reload(); + }); + bar.append(presets, custom, h("div", {class: "replay"}, play, slider, liveButton, label)); + timeDom = {presets, custom, from, to, slider, play, live: liveButton, label}; + } + + function reload() { + load(); + if (ui.screen === "stream") { + fetchEvents(); + } + } + + function choosePreset(seconds) { + stopReplay(); + ui.preset = seconds; + if (seconds === -1) { + ui.from = ui.from === null ? state.since : ui.from; + ui.to = ui.to === null ? state.until : ui.to; + timeDom.from.value = localInput(ui.from); + timeDom.to.value = localInput(ui.to); + } + reload(); + } + + function drawTime() { + if (!timeDom) { + return; + } + for (const button of timeDom.presets.children) { + button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset)); + } + timeDom.custom.hidden = ui.preset !== -1; + timeDom.slider.disabled = ui.preset === -1; + timeDom.play.disabled = ui.preset === -1; + timeDom.live.disabled = live(); + const span = Math.max(state.now - state.first, 1); + if (!ui.playing) { + timeDom.slider.value = String(ui.asOf === null ? 1000 : Math.round((1000 * (ui.asOf - state.first)) / span)); + } + timeDom.label.textContent = `${absolute.format(stampOf(state.since))} – ${absolute.format(stampOf(state.until))}${live() ? " · live" : ""}`; + } + + function startReplay() { + if (ui.preset === -1) { + return; + } + const began = state.first; + const span = Math.max(state.now - began, 1); + const resume = ui.asOf !== null && ui.asOf < state.now - 60; + let progress = resume ? (ui.asOf - began) / span : 0; + ui.playing = true; + timeDom.play.textContent = "❚❚ Pause"; + const advance = async () => { + if (!ui.playing) { + return; + } + progress = Math.min(progress + 1 / 48, 1); + ui.asOf = progress >= 1 ? null : began + span * progress; + timeDom.slider.value = String(Math.round(progress * 1000)); + await load(); + if (ui.screen === "stream") { + fetchEvents(); + } + if (progress >= 1) { + stopReplay(); + } else if (ui.playing) { + replay = window.setTimeout(advance, 500); + } + }; + advance(); + } + + function stopReplay() { + ui.playing = false; + window.clearTimeout(replay); + if (timeDom) { + timeDom.play.textContent = "▶ Wiedergabe"; + } + } diff --git a/hub/templates/page-tree.js b/hub/templates/page-tree.js new file mode 100644 index 0000000..9787851 --- /dev/null +++ b/hub/templates/page-tree.js @@ -0,0 +1,87 @@ + function treeScreen() { + const search = h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => { + tree.q = event.target.value; + drawTree(); + }}}); + const count = h("span", {class: "muted"}); + const list = h("div", {class: "repos"}); + tree.dom = {list, count}; + window.setTimeout(() => { + drawTree(); + fetchTree(); + }, 0); + return h("div", {}, h("div", {class: "filters"}, search, count, btn("Neu laden", "btn small soft", fetchTree)), list); + } + + async function fetchTree() { + if (tree.busy) { + return; + } + tree.busy = true; + try { + const response = await fetch("/api/tree", {cache: "no-store"}); + if (response.ok) { + tree.data = await response.json(); + } + } catch { + ui.offline = true; + } + tree.busy = false; + drawTree(); + } + + function worktreeRow(wt) { + const review = wt.review; + return h("div", {class: "wt"}, + h("code", {}, wt.path), + h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)), + wt.dirty === null ? pill("fehlt", "neutral") : wt.dirty ? pill("geändert", "warn") : pill("sauber", "ok"), + wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"), + review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null, + copyButton(wt.path)); + } + + function drawTree() { + if (!tree.dom || ui.screen !== "tree") { + return; + } + if (!tree.data) { + tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", "Die Repositories werden höchstens jede Minute neu gelesen.")); + return; + } + const q = tree.q.trim().toLowerCase(); + const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q)); + const repos = []; + for (const repo of tree.data.repos) { + const branches = repo.branches.filter((entry) => hit(repo.name, entry.name, ...entry.worktrees.flatMap((wt) => [wt.path, ...wt.workers]))); + const detached = repo.detached.filter((wt) => hit(repo.name, wt.path, ...wt.workers)); + if (branches.length || detached.length) { + repos.push({repo, branches, detached}); + } + } + tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`; + if (!repos.length) { + tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter.")); + return; + } + tree.dom.list.replaceChildren(...repos.map(({repo, branches, detached}) => { + const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached); + const dirty = trees.filter((wt) => wt.dirty).length; + const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)}, + h("summary", {}, h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), dirty ? pill(`${dirty} geändert`, "warn") : null), + branches.map((entry) => h("div", {class: "branch-row"}, + h("div", {class: "branch-name"}, h("strong", {}, entry.name), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : h("span", {class: "muted"}, "ohne Upstream"), + entry.ahead ? pill(`${entry.ahead} voraus`, "info") : null, entry.behind ? pill(`${entry.behind} zurück`, "warn") : null, entry.gone ? pill("Upstream weg", "bad") : null, + h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10))), + entry.worktrees.map(worktreeRow))), + detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null); + details.addEventListener("toggle", () => { + if (details.open) { + tree.open.add(repo.path); + } else { + tree.open.delete(repo.path); + } + }); + return details; + })); + } diff --git a/hub/templates/space-paint.js b/hub/templates/space-paint.js new file mode 100644 index 0000000..0aabefe --- /dev/null +++ b/hub/templates/space-paint.js @@ -0,0 +1,191 @@ +const SpacePaint = (() => { + "use strict"; + const {clamp, project, depthSort} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js"); + const LOOKS = { + guide: ["line", 1, 1], + "line:operator": ["accent", 2.4, 0.9], + "line:chair": ["accent", 2, 0.85], + "line:session": ["muted", 1.6, 0.8], + "line:subagent": ["violet", 1.2, 0.75], + "line:twig": ["violet", 1, 0.6], + "line:selected": ["accent", 3.2, 1], + "arc:direct": ["direct", 1.3, 0.6], + "arc:broadcast": ["broadcast", 1.2, 0.55], + "arc:alarm": ["alarm", 2, 0.9], + "arc:security": ["security", 1.5, 0.75], + "arc:grant": ["grant", 1.6, 0.75], + "arc:spawn": ["violet", 1.4, 0.8], + }; + const DISCS = {operator: 9, chair: 7, session: 5.5, subagent: 3.5, twig: 3}; + const RIM = 330; + const HALO = 14; + const SLICE = 25; + const ROUND = 48; + const MARGIN = 40; + + function ring(center, radius, count) { + return Array.from({length: count}, (_, index) => { + const angle = (2 * Math.PI * index) / count; + return {x: center.x + radius * Math.cos(angle), y: center.y + radius * Math.sin(angle), z: center.z}; + }); + } + + function arcPoints(arc) { + return arc.kind === "broadcast" ? ring(arc.from, HALO, 12) : [arc.from, arc.via, arc.to]; + } + + function visible(cam, marks) { + let [left, right, upper, lower] = [Infinity, -Infinity, Infinity, -Infinity]; + for (const mark of marks) { + if (mark === null) { + return false; + } + left = Math.min(left, mark.x); + right = Math.max(right, mark.x); + upper = Math.min(upper, mark.y); + lower = Math.max(lower, mark.y); + } + return right >= -MARGIN && left <= cam.width + MARGIN && lower >= -MARGIN && upper <= cam.height + MARGIN; + } + + function mean(marks) { + return marks.reduce((sum, mark) => sum + mark.depth, 0) / marks.length; + } + + function pen(ctx, colors, look) { + const [role, width, alpha] = LOOKS[look]; + ctx.strokeStyle = colors[role]; + ctx.lineWidth = width; + ctx.globalAlpha = alpha; + } + + function paint(ctx, built, cam, style) { + const colors = style.colors; + const stats = {items: 0, arcs: 0, culled: 0, batches: 0, labels: 0}; + const buckets = new Map(Object.keys(LOOKS).map((look) => [look, []])); + const others = []; + const marksOf = (points) => points.map((entry) => project(cam, entry)); + const stroke = (look, marks, type, closed) => { + if (!visible(cam, marks)) { + stats.culled += 1; + return false; + } + buckets.get(look).push({type, look, marks, closed, depth: Math.round(mean(marks) / SLICE) * SLICE}); + return true; + }; + const label = (at, text, tone, strong) => { + const mark = project(cam, at); + if (mark && visible(cam, [mark])) { + others.push({type: "label", mark, text, tone, strong, depth: mark.depth - 1}); + } + }; + for (const guide of built.guides) { + const marks = marksOf(ring({x: 0, y: 0, z: guide.z}, RIM, ROUND)); + if (stroke("guide", marks, "path", true)) { + label({x: 0, y: -RIM, z: guide.z}, built.axis === "time" ? style.format(guide.ts) : `${style.level} ${guide.level}`, "muted", false); + } + } + if (built.plane) { + const marks = marksOf(ring({x: 0, y: 0, z: built.plane.z}, RIM, ROUND)); + if (visible(cam, marks)) { + others.push({type: "plane", marks, depth: mean(marks)}); + label({x: RIM * Math.SQRT1_2, y: -RIM * Math.SQRT1_2, z: built.plane.z}, `${style.cursor} ${style.format(built.plane.ts)}`, "accent", true); + } + } + built.lines.forEach((line) => { + const marks = marksOf(line.points); + const look = line.node !== null && line.node === style.selected ? "line:selected" : `line:${line.kind}`; + for (let index = 1; index < marks.length; index += 1) { + stroke(look, [marks[index - 1], marks[index]], "path", false); + } + const tail = marks[marks.length - 1]; + if ((line.alive || built.axis !== "time") && tail && visible(cam, [tail])) { + others.push({type: "disc", mark: tail, look, radius: DISCS[line.kind] * clamp(tail.size, 0.5, 2.5), dim: !line.alive, depth: tail.depth - 0.5}); + } + }); + for (const arc of built.arcs) { + if (stroke(`arc:${arc.tone}`, marksOf(arcPoints(arc)), arc.kind === "broadcast" ? "path" : "curve", arc.kind === "broadcast")) { + stats.arcs += 1; + } + } + for (const entry of built.labels) { + label(entry.at, entry.text, "text", built.lines[entry.line].node !== null && built.lines[entry.line].node === style.selected); + } + const items = depthSort([...[...buckets.values()].flat(), ...others]); + stats.items = items.length; + ctx.clearRect(0, 0, cam.width, cam.height); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.textAlign = "center"; + let open = ""; + const flush = () => { + if (open) { + ctx.stroke(); + stats.batches += 1; + open = ""; + } + }; + for (const item of items) { + const marks = item.marks; + if (item.type === "path" || item.type === "curve") { + if (item.look !== open) { + flush(); + pen(ctx, colors, item.look); + ctx.beginPath(); + open = item.look; + } + ctx.moveTo(marks[0].x, marks[0].y); + if (item.type === "curve") { + ctx.quadraticCurveTo(marks[1].x, marks[1].y, marks[2].x, marks[2].y); + } else { + for (let index = 1; index < marks.length; index += 1) { + ctx.lineTo(marks[index].x, marks[index].y); + } + if (item.closed) { + ctx.closePath(); + } + } + continue; + } + flush(); + if (item.type === "plane") { + ctx.beginPath(); + marks.forEach((mark, index) => (index ? ctx.lineTo(mark.x, mark.y) : ctx.moveTo(mark.x, mark.y))); + ctx.closePath(); + ctx.globalAlpha = 0.1; + ctx.fillStyle = colors.accent; + ctx.fill(); + ctx.globalAlpha = 0.55; + ctx.strokeStyle = colors.accent; + ctx.lineWidth = 1.2; + ctx.stroke(); + } else if (item.type === "disc") { + ctx.beginPath(); + ctx.arc(item.mark.x, item.mark.y, item.radius, 0, 2 * Math.PI); + ctx.globalAlpha = item.dim ? 0.5 : 1; + ctx.fillStyle = colors.panel; + ctx.fill(); + pen(ctx, colors, item.look); + ctx.globalAlpha = item.dim ? 0.5 : 1; + ctx.stroke(); + } else { + stats.labels += 1; + ctx.globalAlpha = 1; + ctx.font = item.strong ? style.strong : style.font; + ctx.lineWidth = 4; + ctx.strokeStyle = colors.panel; + ctx.strokeText(item.text, item.mark.x, item.mark.y - 10); + ctx.fillStyle = colors[item.tone]; + ctx.fillText(item.text, item.mark.x, item.mark.y - 10); + } + } + flush(); + ctx.globalAlpha = 1; + return stats; + } + + return {arcPoints, paint}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = SpacePaint; +} diff --git a/hub/templates/space-pick.js b/hub/templates/space-pick.js new file mode 100644 index 0000000..c207645 --- /dev/null +++ b/hub/templates/space-pick.js @@ -0,0 +1,66 @@ +const SpacePick = (() => { + "use strict"; + const {clamp, project} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js"); + const {arcPoints} = typeof SpacePaint === "object" ? SpacePaint : require("./space-paint.js"); + const CURVE = 8; + + function gap(x, y, a, b) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const length = dx * dx + dy * dy; + const share = length ? clamp(((x - a.x) * dx + (y - a.y) * dy) / length, 0, 1) : 0; + return Math.hypot(a.x + dx * share - x, a.y + dy * share - y); + } + + function nearest(x, y, marks) { + if (marks.some((mark) => mark === null)) { + return {distance: Infinity, depth: Infinity}; + } + if (marks.length === 1) { + return {distance: Math.hypot(marks[0].x - x, marks[0].y - y), depth: marks[0].depth}; + } + let best = {distance: Infinity, depth: Infinity}; + for (let index = 1; index < marks.length; index += 1) { + const distance = gap(x, y, marks[index - 1], marks[index]); + if (distance < best.distance) { + best = {distance, depth: (marks[index - 1].depth + marks[index].depth) / 2}; + } + } + return best; + } + + function curve(marks) { + if (marks.some((mark) => mark === null)) { + return marks; + } + const [a, b, c] = marks; + return Array.from({length: CURVE + 1}, (_, index) => { + const t = index / CURVE; + const u = 1 - t; + return {x: u * u * a.x + 2 * u * t * b.x + t * t * c.x, y: u * u * a.y + 2 * u * t * b.y + t * t * c.y, depth: u * u * a.depth + 2 * u * t * b.depth + t * t * c.depth}; + }); + } + + function pick(built, cam, x, y, reach) { + let best = null; + const consider = (found, hit) => { + if (found.distance > reach) { + return; + } + if (!best || found.distance < best.distance - 0.5 || (found.distance <= best.distance + 0.5 && found.depth < best.depth)) { + best = {...hit, distance: found.distance, depth: found.depth}; + } + }; + built.lines.forEach((line, index) => consider(nearest(x, y, line.points.map((entry) => project(cam, entry))), {kind: "line", index, line})); + built.arcs.forEach((arc, index) => { + const marks = arcPoints(arc).map((entry) => project(cam, entry)); + consider(nearest(x, y, arc.kind === "broadcast" ? [...marks, marks[0]] : curve(marks)), {kind: "arc", index, arc}); + }); + return best; + } + + return {pick}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = SpacePick; +} diff --git a/hub/templates/space-scene.js b/hub/templates/space-scene.js new file mode 100644 index 0000000..f419021 --- /dev/null +++ b/hub/templates/space-scene.js @@ -0,0 +1,227 @@ +const SpaceScene = (() => { + "use strict"; + const {LEVELS, clamp, turn, depthOf} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js"); + const TONES = {direct: "direct", broadcast: "broadcast", mayday: "alarm", "pan-pan": "alarm", security: "security", grant: "grant", spawn: "spawn"}; + const RANKS = {alarm: 0, spawn: 1, security: 2, grant: 3, direct: 4, broadcast: 5}; + const MOON = 38; + const MOON_SPREAD = 0.5; + const TWIG = 30; + const TWIG_SPREAD = 0.45; + const ARC_CAP = 1500; + + function bend(from, to) { + const dx = to.x - from.x; + const dy = to.y - from.y; + const length = Math.hypot(dx, dy) || 1; + return {x: ((from.x + to.x) / 2) * 0.82 - (dy / length) * 22, y: ((from.y + to.y) / 2) * 0.82 + (dx / length) * 22}; + } + + function satellite(base, index, count, reach, spread) { + const angle = base.angle + (index - (count - 1) / 2) * spread; + return {x: base.x + reach * Math.cos(angle), y: base.y + reach * Math.sin(angle), angle}; + } + + function moon(base, index, count) { + return satellite(base, index, count, MOON, MOON_SPREAD); + } + + function nameAt(timeline, ts) { + const valid = timeline.filter((entry) => entry.since <= ts); + return (valid.length ? valid[valid.length - 1] : timeline[0]).name; + } + + function spotAt(track, id, ts) { + let after = 0; + while (after < track.length && track[after].ts < ts) { + after += 1; + } + const late = track[Math.min(after, track.length - 1)].orbit[id]; + const early = track[Math.max(after - 1, 0)].orbit[id]; + if (!late || !early) { + return late || early || null; + } + const span = after > 0 && after < track.length ? track[after].ts - track[after - 1].ts : 0; + const share = span > 0 ? (ts - track[after - 1].ts) / span : 1; + return {angle: turn(early.angle, late.angle, share), radius: early.radius + (late.radius - early.radius) * share}; + } + + function born(row) { + return row.since === null ? row.started : row.since; + } + + function tree(rows) { + const order = [...rows].sort((a, b) => born(a) - born(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const parents = {}; + order.forEach((row, index) => { + const elders = order.slice(0, index).filter((other) => other.depth === row.depth - 1); + const living = elders.filter((other) => other.until === null || other.until >= born(row)); + const chosen = (living.length ? living : elders).at(-1); + parents[row.id] = row.depth > 1 && chosen ? chosen.id : ""; + }); + return {order, parents}; + } + + function capArcs(arcs, cap) { + if (arcs.length <= cap) { + return {kept: arcs, dropped: 0}; + } + const ranks = arcs.map((arc) => (Object.hasOwn(RANKS, arc.tone) ? RANKS[arc.tone] : RANKS.direct)); + const order = arcs.map((arc, index) => index).sort((a, b) => ranks[a] - ranks[b] || arcs[b].ts - arcs[a].ts || a - b); + const chosen = order.slice(0, cap).sort((a, b) => a - b); + return {kept: chosen.map((index) => arcs[index]), dropped: arcs.length - cap}; + } + + function scene(input) { + const {axis, range, span, cursor, nodes, track, now, lives, subagents, radio, grants, chair, cap} = input; + const floor = span[0]; + const top = Math.max(Math.min(cursor, span[1]), floor); + const last = track.length ? track[track.length - 1].ts : -Infinity; + const byId = new Map(nodes.map((node) => [node.id, node])); + const moons = new Map(); + for (const node of nodes.filter((entry) => entry.kind === "subagent")) { + moons.set(node.parent, [...(moons.get(node.parent) || []), node]); + } + const lines = []; + const labels = []; + const arcs = []; + const point = (spot, ts, level) => ({x: spot.x, y: spot.y, z: depthOf(axis, {ts, level}, range), ts}); + const headed = (spot) => ({x: spot.x, y: spot.y, angle: spot.x === 0 && spot.y === 0 ? -Math.PI / 2 : Math.atan2(spot.y, spot.x)}); + const stamps = (start, end) => (axis === "time" && start < end ? [start, ...track.map((sample) => sample.ts).filter((ts) => ts > start && ts < end), end] : [end]); + + function anchor(id, ts) { + const node = byId.get(id); + if (!node || node.kind === "operator") { + return {x: 0, y: 0}; + } + if (node.kind === "subagent") { + const family = moons.get(node.parent); + return moon(headed(anchor(byId.has(node.parent) ? node.parent : chair, ts)), family.indexOf(node), family.length); + } + const spot = axis === "time" && ts < last ? spotAt(track, id, ts) : null; + return spot ? {x: spot.radius * Math.cos(spot.angle), y: spot.radius * Math.sin(spot.angle)} : now[id] || {x: 0, y: 0}; + } + + function within(life) { + const start = Math.max(floor, life && life[0] !== null ? life[0] : floor); + const end = Math.min(top, life && life[1] !== null ? life[1] : top); + return start <= end ? [start, end] : [floor, top]; + } + + function addLine(entry, place, start, end) { + const points = stamps(start, end).map((ts) => point(place(ts), ts, entry.level)); + const index = lines.length; + lines.push({...entry, points}); + if (entry.kind === "subagent" || (entry.kind === "twig" && !entry.alive)) { + return; + } + const tail = points[points.length - 1]; + labels.push({line: index, at: tail, text: nameAt(entry.timeline, tail.ts)}); + if (axis !== "time") { + return; + } + const head = points[0]; + const changes = entry.timeline.filter((change) => change.since > head.ts && change.since < tail.ts); + for (const change of changes) { + labels.push({line: index, at: point(place(change.since), change.since, entry.level), text: change.name}); + } + if (changes.length) { + labels.push({line: index, at: head, text: nameAt(entry.timeline, head.ts)}); + } + } + + function connect(source, targets, tone, ts, row, count, kind) { + const moment = axis === "time" ? ts : top; + const from = point(anchor(source, moment), moment, 0); + if (!targets.length) { + arcs.push({kind: "broadcast", tone, ts, source, target: "*", sub: "", row, count, from, via: from, to: from}); + return; + } + for (const target of targets) { + if (target !== source && byId.has(target)) { + const to = point(anchor(target, moment), moment, 0); + arcs.push({kind, tone, ts, source, target, sub: "", row, count, from, via: {...bend(from, to), z: from.z}, to}); + } + } + } + + for (const node of nodes) { + const owner = node.kind === "subagent" ? node.parent : node.id; + const [start, end] = within(lives[owner]); + const timeline = node.timeline && node.timeline.length ? node.timeline : [{name: node.label, since: floor}]; + addLine({node: node.id, kind: node.kind, session: owner, sub: "", level: node.kind === "subagent" ? 1 : 0, timeline, title: node.label, alive: end >= top}, (ts) => anchor(node.id, ts), start, end); + } + for (const session of Object.keys(subagents).sort()) { + if (!byId.has(session)) { + continue; + } + const {order, parents} = tree(subagents[session]); + const families = new Map(); + for (const row of order) { + families.set(parents[row.id], [...(families.get(parents[row.id]) || []), row.id]); + } + const chains = new Map(); + for (const row of order) { + const family = families.get(parents[row.id]); + chains.set(row.id, [...(chains.get(parents[row.id]) || []), {index: family.indexOf(row.id), count: family.length}]); + } + const reach = (chain, ts) => chain.reduce((spot, link) => satellite(spot, link.index, link.count, TWIG, TWIG_SPREAD), headed(anchor(session, ts))); + for (const row of order) { + const since = born(row); + if (since > top || (row.until !== null && row.until < floor)) { + continue; + } + const chain = chains.get(row.id); + const end = Math.min(top, row.until === null ? top : row.until); + const alive = row.until === null || row.until >= top; + const name = row.type || "Subagent"; + addLine({node: null, kind: "twig", session, sub: row.id, level: row.depth, timeline: [{name, since}], title: `${name}: ${row.description}`, alive}, (ts) => reach(chain, ts), Math.max(floor, since), end); + if (axis !== "time" || since >= floor) { + const moment = axis === "time" ? since : end; + const from = point(reach(chain.slice(0, -1), moment), moment, row.depth - 1); + const to = point(reach(chain, moment), moment, row.depth); + arcs.push({kind: "spawn", tone: "spawn", ts: since, source: session, target: session, sub: row.id, row: -1, count: 1, from, via: {x: (from.x + to.x) / 2, y: (from.y + to.y) / 2, z: (from.z + to.z) / 2}, to}); + } + } + } + const heard = radio.map((row, index) => ({row, index})).filter(({row}) => row.ts >= floor && row.ts <= top && byId.has(row.source)); + const granted = grants.map((grant, index) => ({grant, index})).filter(({grant}) => grant.ts >= floor && grant.ts <= top && byId.has(chair)); + if (axis === "time") { + for (const {row, index} of heard) { + connect(row.source, row.targets, TONES[row.class] || "direct", row.ts, index, 1, "message"); + } + for (const {grant, index} of granted) { + connect(chair, [grant.target], "grant", grant.ts, index, 1, "grant"); + } + } else { + const groups = new Map(); + const gather = (source, targets, tone, ts, kind) => { + for (const target of targets.length ? targets : ["*"]) { + const key = `${kind}\u0000${source}\u0000${target}\u0000${tone}`; + const group = groups.get(key) || {source, target, tone, ts, kind, count: 0}; + group.count += 1; + group.ts = Math.max(group.ts, ts); + groups.set(key, group); + } + }; + for (const {row} of heard) { + gather(row.source, row.targets, TONES[row.class] || "direct", row.ts, "message"); + } + for (const {grant} of granted) { + gather(chair, [grant.target], "grant", grant.ts, "grant"); + } + for (const group of groups.values()) { + connect(group.source, group.target === "*" ? [] : [group.target], group.tone, group.ts, -1, group.count, group.kind); + } + } + const {kept, dropped} = capArcs(arcs, cap); + const levels = [...new Set(lines.map((line) => clamp(line.level, 0, LEVELS)))].sort((a, b) => a - b); + const guides = axis === "time" ? range.map((ts) => ({z: depthOf(axis, {ts, level: 0}, range), ts, level: 0})) : levels.map((level) => ({z: depthOf(axis, {ts: top, level}, range), ts: top, level})); + const plane = axis === "time" ? {z: depthOf(axis, {ts: top, level: 0}, range), ts: top} : null; + return {axis, range, span: [floor, top], lines, labels, arcs: kept, guides, plane, total: arcs.length, dropped}; + } + + return {TONES, ARC_CAP, bend, moon, nameAt, tree, capArcs, scene}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = SpaceScene; +} diff --git a/hub/templates/space-view.js b/hub/templates/space-view.js new file mode 100644 index 0000000..179da36 --- /dev/null +++ b/hub/templates/space-view.js @@ -0,0 +1,127 @@ +const SpaceView = (() => { + "use strict"; + const RAD = Math.PI / 180; + const DEFAULTS = { + graph: {axis: "none", yaw: 30, pitch: 20, zoom: 1, pan_x: 0, pan_y: 0}, + sky: {azimuth: 180, altitude: 0, fov: 180}, + }; + const LIMITS = { + graph: {yaw: [-180, 180], pitch: [-90, 90], zoom: [0.25, 4], pan_x: [-2000, 2000], pan_y: [-2000, 2000]}, + sky: {azimuth: [0, 360], altitude: [-30, 85], fov: [20, 240]}, + }; + const AXES = ["none", "time", "spawn"]; + const DEPTH = 600; + const LEVELS = 4; + const FRAME = 860; + const DISTANCE = 1600; + const NEAR = 120; + const HORIZON = 0.86; + const REACH = 0.94; + const clamp = (value, low, high) => Math.min(Math.max(value, low), high); + const signed = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180; + const around = (deg) => ((deg % 360) + 360) % 360; + + function ease(progress) { + return progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2; + } + + function turn(from, to, share) { + const full = 2 * Math.PI; + const delta = ((((to - from + Math.PI) % full) + full) % full) - Math.PI; + return from + delta * share; + } + + function rotation(yaw, pitch) { + const [cy, sy, cp, sp] = [Math.cos(yaw * RAD), Math.sin(yaw * RAD), Math.cos(pitch * RAD), Math.sin(pitch * RAD)]; + return [cy, 0, sy, sp * sy, cp, -sp * cy, -cp * sy, sp, cp * cy]; + } + + function apply(matrix, point) { + return { + x: matrix[0] * point.x + matrix[1] * point.y + matrix[2] * point.z, + y: matrix[3] * point.x + matrix[4] * point.y + matrix[5] * point.z, + z: matrix[6] * point.x + matrix[7] * point.y + matrix[8] * point.z, + }; + } + + function camera(view, width, height) { + return {matrix: rotation(view.yaw, view.pitch), scale: (Math.min(width, height) / FRAME) * view.zoom, panX: view.pan_x, panY: view.pan_y, cx: width / 2, cy: height / 2, width, height}; + } + + function project(cam, point) { + const turned = apply(cam.matrix, point); + const reach = DISTANCE + turned.z; + if (reach < NEAR) { + return null; + } + const size = (cam.scale * DISTANCE) / reach; + return {x: cam.cx + (turned.x + cam.panX) * size, y: cam.cy + (turned.y + cam.panY) * size, depth: turned.z, size}; + } + + function depthSort(items) { + return [...items].sort((a, b) => b.depth - a.depth); + } + + function depthOf(axis, sample, range) { + if (axis === "time") { + const [low, high] = range; + const share = high > low ? clamp((sample.ts - low) / (high - low), 0, 1) : 1; + return DEPTH * (0.5 - share); + } + if (axis === "spawn") { + return -DEPTH / 2 + (DEPTH / LEVELS) * clamp(sample.level, 0, LEVELS); + } + return 0; + } + + function clampView(kind, view) { + const limits = LIMITS[kind]; + const wrapped = kind === "graph" ? {...view, yaw: signed(view.yaw)} : {...view, azimuth: around(view.azimuth)}; + return Object.fromEntries(Object.entries(wrapped).map(([key, value]) => [key, Object.hasOwn(limits, key) ? clamp(value, limits[key][0], limits[key][1]) : value])); + } + + function tidy(view) { + return Object.fromEntries(Object.entries(view).map(([key, value]) => [key, typeof value === "number" ? Math.round(value * 100) / 100 : value])); + } + + function tween(kind, from, to, share) { + const angle = kind === "graph" ? "yaw" : "azimuth"; + return Object.fromEntries(Object.entries(to).map(([key, value]) => { + if (typeof value !== "number") { + return [key, value]; + } + if (key === angle) { + return [key, turn(from[key] * RAD, value * RAD, share) / RAD]; + } + return [key, from[key] + (value - from[key]) * share]; + })); + } + + function lens(view, width, height) { + return {matrix: rotation(-view.azimuth, -view.altitude), kx: width / view.fov, ky: ((REACH * HORIZON * height) / 90) * (180 / view.fov), cx: width / 2, cy: HORIZON * height, width, height, view}; + } + + function place(sight, alt, az) { + const flat = Math.cos(alt * RAD); + const turned = apply(sight.matrix, {x: Math.sin(az * RAD) * flat, y: -Math.sin(alt * RAD), z: Math.cos(az * RAD) * flat}); + const lon = Math.atan2(turned.x, turned.z) / RAD; + const lat = Math.asin(clamp(-turned.y, -1, 1)) / RAD; + return {x: sight.cx + sight.kx * lon, y: sight.cy - sight.ky * lat, lon, lat}; + } + + function horizon(sight, steps) { + return Array.from({length: steps + 1}, (_, index) => place(sight, 0, sight.view.azimuth - 180 + (360 * index) / steps)).sort((a, b) => a.x - b.x); + } + + function compass(azimuth) { + return Math.round(around(azimuth) / 45) % 8; + } + + return { + DEFAULTS, LIMITS, AXES, DEPTH, LEVELS, REACH, + clamp, ease, turn, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, lens, place, horizon, compass, + }; +})(); +if (typeof module === "object" && module.exports) { + module.exports = SpaceView; +} diff --git a/hub/templates/space.js b/hub/templates/space.js new file mode 100644 index 0000000..d76c6e7 --- /dev/null +++ b/hub/templates/space.js @@ -0,0 +1,16 @@ +const Space = (() => { + "use strict"; + const {DEFAULTS, LIMITS, AXES, DEPTH, REACH, ease, turn, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, lens, place, horizon, compass} = typeof SpaceView === "object" ? SpaceView : require("./space-view.js"); + const {TONES, ARC_CAP, bend, moon, nameAt, tree, capArcs, scene} = typeof SpaceScene === "object" ? SpaceScene : require("./space-scene.js"); + const {paint} = typeof SpacePaint === "object" ? SpacePaint : require("./space-paint.js"); + const {pick} = typeof SpacePick === "object" ? SpacePick : require("./space-pick.js"); + + return { + DEFAULTS, LIMITS, AXES, TONES, ARC_CAP, DEPTH, REACH, + ease, turn, bend, moon, rotation, apply, camera, project, depthSort, depthOf, clampView, tidy, tween, nameAt, tree, capArcs, scene, paint, pick, + lens, place, horizon, compass, + }; +})(); +if (typeof module === "object" && module.exports) { + module.exports = Space; +} diff --git a/hub/templates/voice.js b/hub/templates/voice.js new file mode 100644 index 0000000..18390ca --- /dev/null +++ b/hub/templates/voice.js @@ -0,0 +1,71 @@ +const Voice = (() => { + "use strict"; + const RANKS = {mayday: 0, "pan-pan": 1, security: 2}; + const ALARMS = new Set(["mayday", "pan-pan"]); + const ROUTINE = 3; + + function toneOf(row, human) { + if (ALARMS.has(row.class)) { + return "mayday"; + } + return row.class === "security" || row.to.includes(human) ? "direct" : "radio"; + } + + function loudness(tone, settings) { + if (tone !== "mayday") { + return settings.sounds[tone].volume; + } + return Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume); + } + + function speakable(row, speech) { + if (!speech.on || !row.speech || !row.voice) { + return false; + } + return row.class === "mayday" || (speech.classes.includes(row.class) && !speech.muted.includes(row.source)); + } + + function rank(item) { + return Object.hasOwn(RANKS, item.cls) ? RANKS[item.cls] : ROUTINE; + } + + function enqueue(pending, item, limit) { + const at = pending.findIndex((other) => rank(other) > rank(item)); + const next = at < 0 ? [...pending, item] : [...pending.slice(0, at), item, ...pending.slice(at)]; + return next.slice(0, limit); + } + + function frames(duration, rate, seconds) { + return Math.max(Math.min(Math.ceil(duration * rate), rate * seconds), 0); + } + + function wav(samples, rate) { + const size = samples.length * 2; + const buffer = new ArrayBuffer(44 + size); + const view = new DataView(buffer); + const text = (offset, value) => [...value].forEach((char, index) => view.setUint8(offset + index, char.charCodeAt(0))); + text(0, "RIFF"); + view.setUint32(4, 36 + size, true); + text(8, "WAVE"); + text(12, "fmt "); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, rate, true); + view.setUint32(28, rate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + text(36, "data"); + view.setUint32(40, size, true); + samples.forEach((sample, index) => { + const level = Math.max(-1, Math.min(1, sample)); + view.setInt16(44 + index * 2, level < 0 ? level * 0x8000 : level * 0x7fff, true); + }); + return buffer; + } + + return {toneOf, loudness, speakable, enqueue, frames, wav}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = Voice; +} diff --git a/scripts/hub.sh b/scripts/hub.sh new file mode 100644 index 0000000..7c3cf06 --- /dev/null +++ b/scripts/hub.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Usage: hub.sh install | check | pin | runner | hook +# install copy hub/ to ~/.local/share/kvb-hub, write its .env, build and +# start the container, register the MCP server for Claude Code +# check image imports, container, logs, HTTP, pinned keys, hook, runner, +# disk; exit 1 when a check fails +# pin pin the credential blob the hub's "Passkey registrieren" shows +# runner install, enable and restart the runner user service +# hook register the PermissionRequest hook in ~/.claude/settings.json, +# replacing an earlier kvb-hub entry, keeping everything else and the +# file mode, after a copy to settings.json.before-kvb-hub-hook +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +HUB="${HOME}/.local/share/kvb-hub" +STATE="${HOME}/.local/state/kvb-hub" +APPROVALS="${HOME}/.local/state/kvb-hub-approvals" +APPROVERS="${HOME}/.config/kvb-hub" +UNITS="${HOME}/.config/systemd/user" +# council.py ROOT: every agent must agree on this directory +COUNCIL="${AGENTS_COUNCIL_ROOT:-/tmp/agents-council}" +SETTINGS="${HOME}/.claude/settings.json" +LOCAL_SETTINGS="${HOME}/.claude/settings.local.json" +HOOK_TIMEOUT=110 +RUNNER="kvb-hub-runner.service" +IMAGE="kvb-hub:local" +FLOOR_GB=15 +FAILED=0 + +log() { printf '%s\n' "$*"; } +warn() { printf '%s\n' "$*" >&2; } +ok() { printf 'ok %s\n' "$*"; } +todo() { printf 'todo %s\n' "$*"; } +fail() { + printf 'FAIL %s\n' "$*" + FAILED=1 +} + +port_of() { + sed -n 's/^KVB_HUB_PORT=//p' "$1" +} + +install_hub() { + command -v docker &>/dev/null || { + warn "skills: docker not found; skipped the kvb-hub review hub." + return 0 + } + local port + port="$(port_of "${REPO_ROOT}/hub/.env")" + log "skills: starting the kvb-hub review hub on http://localhost:${port}/" + rm -rf "${HUB}" + mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}" + chmod 700 "${APPROVERS}" + cp -a "${REPO_ROOT}/hub/." "${HUB}/" + chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" + log "skills: the kvb-hub PermissionRequest hook client is ${HUB}/client/permission_hook.py" + log "skills: approvals need a pinned passkey: hub settings, 'Passkey registrieren', then 'make hub-pin CREDENTIAL='; until then every executor refuses" + printf 'KVB_HUB_USER=%s:%s\nKVB_HUB_STATE=%s\nKVB_HUB_COUNCIL=%s\nKVB_HUB_SKILLS=%s\nKVB_HUB_PROJECTS=%s\nKVB_HUB_REPOS=%s\nKVB_HUB_WORKTREES=%s\nKVB_HUB_TIMERS=%s\nKVB_HUB_APPROVERS=%s\n' \ + "$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${HOME}/Repositories" "${HOME}/.local/share/worktrees" "${UNITS}" "${APPROVERS}" >>"${HUB}/.env" + if ! docker compose --file "${HUB}/compose.yml" up -d --build; then + warn "skills: docker compose failed; the kvb-hub stays down until 'make hub-install' succeeds." + fi + if ! command -v claude &>/dev/null; then + warn "skills: claude not found; skipped registering the kvb-hub MCP server." + return 0 + fi + claude mcp remove --scope user kvb-hub &>/dev/null || true + if claude mcp add --transport http --scope user kvb-hub "http://127.0.0.1:${port}/mcp"; then + log "skills: registered the kvb-hub MCP server for Claude Code at user scope" + else + warn "skills: registering the kvb-hub MCP server for Claude Code failed." + fi +} + +runner() { + if ! systemctl --user show-environment &>/dev/null; then + warn "skills: no systemd user session; the kvb-hub runner is off and approved operator commands wait." + return 1 + fi + cp -f "${HUB}/systemd/${RUNNER}" "${UNITS}/" + systemctl --user daemon-reload + systemctl --user enable "${RUNNER}" + systemctl --user restart "${RUNNER}" + log "skills: the kvb-hub runner service runs the operator commands approved in the hub ($(systemctl --user is-active "${RUNNER}"))" +} + +check_keys() { + local file="${APPROVERS}/approvers.json" + if [[ ! -f "${file}" ]]; then + todo "no pinned passkey: hub settings, 'Passkey registrieren', then make hub-pin CREDENTIAL=" + return 0 + fi + local modes + modes="$(stat -c '%a' "${APPROVERS}" "${file}" | paste -sd ' ')" + if [[ "${modes}" == "700 600" ]]; then + ok "pinned keys $(python3 -c 'import json,sys; print([c["alg"] for c in json.load(open(sys.argv[1]))["credentials"]])' "${file}") (modes ${modes})" + else + fail "pinned key modes are ${modes}, expected 700 600" + fi +} + +check() { + local port code free + port="$(port_of "${HUB}/.env")" + if docker run --rm "${IMAGE}" --help >/dev/null; then ok "image ${IMAGE} imports every module"; else fail "image ${IMAGE} does not start"; fi + if docker compose --file "${HUB}/compose.yml" ps --status running --services | grep -qx hub; then ok "container running"; else fail "container not running"; fi + if docker compose --file "${HUB}/compose.yml" logs --tail 200 2>&1 | grep -q Traceback; then + fail "traceback in the hub log:" + docker compose --file "${HUB}/compose.yml" logs --tail 30 + else + ok "no traceback in the last 200 log lines" + fi + code="$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${port}/" || true)" + if [[ "${code}" == "200" ]]; then ok "http://localhost:${port}/ answers 200"; else fail "http://localhost:${port}/ answers ${code}"; fi + check_keys + if grep -qs 'kvb-hub/client/permission_hook.py' "${SETTINGS}" "${LOCAL_SETTINGS}"; then + ok "PermissionRequest hook registered" + else + todo "PermissionRequest hook missing: make hub-hook" + fi + if [[ "$(systemctl --user is-active "${RUNNER}" 2>/dev/null || true)" == "active" ]]; then + ok "runner active; last lines:" + journalctl --user -u "${RUNNER}" -n 10 --no-pager + else + todo "runner off: make hub-runner" + fi + free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')" + if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi + return "${FAILED}" +} + +pin() { + [[ -n "${1:-}" ]] || { + warn "usage: make hub-pin CREDENTIAL=" + exit 2 + } + python3 "${HUB}/client/approval.py" pin --port "$(port_of "${HUB}/.env")" --approvers "${APPROVERS}/approvers.json" --credential "$1" + check_keys + return "${FAILED}" +} + +hook() { + local command + command="python3 \"${HUB}/client/permission_hook.py\" --port $(port_of "${HUB}/.env") --timeout ${HOOK_TIMEOUT} --approvers \"${APPROVERS}/approvers.json\" --replay \"${APPROVALS}/hook.json\"" + [[ -f "${SETTINGS}" ]] && cp -p "${SETTINGS}" "${SETTINGS}.before-kvb-hub-hook" + python3 - "${SETTINGS}" "${command}" "$((HOOK_TIMEOUT + 10))" <<'PY' +import json, os, sys +from pathlib import Path + +path, command, timeout = Path(sys.argv[1]), sys.argv[2], int(sys.argv[3]) +settings = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} +mode = path.stat().st_mode & 0o777 if path.exists() else 0o600 +entries = settings.setdefault("hooks", {}).setdefault("PermissionRequest", []) +entries[:] = [entry for entry in entries if not any("kvb-hub/client/permission_hook.py" in hook.get("command", "") for hook in entry.get("hooks", []))] +entries.append({"matcher": "Bash", "hooks": [{"type": "command", "command": command, "timeout": timeout}]}) +temporary = path.with_name(f"{path.name}.{os.getpid()}") +temporary.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +temporary.chmod(mode) +temporary.replace(path) +PY + log "skills: registered the kvb-hub PermissionRequest hook in ${SETTINGS} (copy before: ${SETTINGS}.before-kvb-hub-hook); restart Claude Code to load it" +} + +case "${1:-}" in +install) install_hub ;; +check) check ;; +pin) pin "${2:-}" ;; +runner) runner ;; +hook) hook ;; +*) + warn "usage: hub.sh install | check | pin | runner | hook" + exit 2 + ;; +esac diff --git a/scripts/install.sh b/scripts/install.sh index 4d13ab8..9879c76 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -64,6 +64,12 @@ if [[ "${TARGET}" == "${HOME}" ]]; then else warn "skills: no systemd user session; skipped the kvb-dream notification timer." fi + if command -v docker &>/dev/null; then + bash "${REPO_ROOT}/scripts/hub.sh" install + bash "${REPO_ROOT}/scripts/hub.sh" runner || true + else + warn "skills: docker not found; skipped the kvb-hub review hub." + fi log "skills: installing or updating the codebase-memory-mcp code graph for every detected agent" if ! curl -fsSL --proto '=https' https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash; then warn "skills: the codebase-memory-mcp installer failed; the code graph keeps its previous setup." diff --git a/scripts/watch-check.sh b/scripts/watch-check.sh new file mode 100644 index 0000000..4d64322 --- /dev/null +++ b/scripts/watch-check.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Usage: watch-check.sh +# Shows whether the agents-council watch hook is stuck: running hook +# processes, holders of the kept queue history, and the age of the stored +# resource stage. Exit 1 when the stored stage is older than STALE_SECONDS. +set -euo pipefail + +# council.py ROOT: every agent must agree on this directory +STATE="${AGENTS_COUNCIL_ROOT:-/tmp/agents-council}/watch.json" +KEPT="${HOME}/.local/state/agents-council/queue-history.jsonl" +STALE_SECONDS=600 + +printf 'watch hooks running: %s\n' "$(pgrep -fc 'watch.py watch' || true)" +if command -v lsof &>/dev/null; then + printf 'holders of %s:\n' "${KEPT}" + lsof "${KEPT}" 2>/dev/null || printf ' none\n' +else + printf 'lsof not installed; holders of %s unknown\n' "${KEPT}" +fi +python3 -c ' +import json, sys, time +state = json.load(open(sys.argv[1])) +stage, free, age = state["stage"], state["free"], round(time.time() - state["checked"]) +print(f"stored stage {stage}, {free:.1f} GB free, {age} s old") +sys.exit(1 if age > int(sys.argv[2]) else 0) +' "${STATE}" "${STALE_SECONDS}" diff --git a/skills/kvb-git-commit-next/SKILL.md b/skills/kvb-git-commit-next/SKILL.md index 3b5de3a..bf63741 100644 --- a/skills/kvb-git-commit-next/SKILL.md +++ b/skills/kvb-git-commit-next/SKILL.md @@ -48,6 +48,9 @@ description: > 6. Close with the staged bundle's commit table (`score.py ... --commit-table `: 📁 worktree, 🌿 branch, 📊 diff stat, scores, gate), what remains unstaged, and wait for the operator's commit - decision; never auto-commit the newly staged bundle. A gate exit 4 says + decision; never auto-commit the newly staged bundle. Ask through the + kvb-git-diff skill: a hub approval is the operator's commit decision for + that one bundle only once its `approval.py verify` step exits 0 on the + exact staged tree; otherwise the chat question stays the fallback. A gate exit 4 says up front that the bundle cannot be committed until its critical finding is fixed. diff --git a/skills/kvb-git-commit-no-verify/SKILL.md b/skills/kvb-git-commit-no-verify/SKILL.md index 5e1b27b..cf391f4 100644 --- a/skills/kvb-git-commit-no-verify/SKILL.md +++ b/skills/kvb-git-commit-no-verify/SKILL.md @@ -24,7 +24,10 @@ description: > the call is never sent, not even on the operator's word; fix and rescan first. Exit 1 (below 90/5) is shown, the operator's word decides; exit 3 means ask the operator again before the commit, naming each cause the - gate prints. The same duty holds for every call that creates a commit, + gate prints. Ask through the kvb-git-diff skill: a hub approval is the + operator's word for this one commit only once its `approval.py verify` + step exits 0 on the exact staged tree; otherwise the chat question stays + the fallback. The same duty holds for every call that creates a commit, `git merge` included (there the incoming diff stat, and the incoming diff in the message before). 4. Commit exactly the staging index with `git commit --no-verify` and a diff --git a/skills/kvb-git-diff/SKILL.md b/skills/kvb-git-diff/SKILL.md new file mode 100644 index 0000000..c6246be --- /dev/null +++ b/skills/kvb-git-diff/SKILL.md @@ -0,0 +1,44 @@ +--- +name: kvb-git-diff +description: > + Approve a staged commit in the kvb-hub browser page instead of the chat; + the commit skills run it. +--- + +The commit skills run this where they ask for the commit approval. A hub +approval is the operator's word for one commit, the exact tree it names, only +once the operator's pinned passkey signature over it verifies here. + +1. Render the page. `` is the repository's name, `` the + branch with every `/` as `-`, `` the branch's base and `` a + file under `/tmp` holding the kvb-check-score commit table: + + ```bash + pkgmgr review --out /tmp/kvb-git-diff/-.html --max-file-lines 400 --base --note
+ ``` + +2. Call the kvb-hub MCP tool `review_request` with `repo` (what + `git rev-parse --path-format=absolute --git-common-dir` prints), + `worktree` (what `git rev-parse --show-toplevel` prints), `branch` (what + `git symbolic-ref --short HEAD` prints), `tree` (what `git write-tree` + prints), `title` (the commit subject), `page` (the `--out` path), `notes` + (the commit table), `gate` (`pass` when every gate the calling skill ran is green, else + `fail`) and `score` (the kvb-check-score line). Only a `pass` review can + be approved in bulk or by a rule. Tell the operator in one line that the + review waits in the hub at the `url` it returns, written out as plain + text: chat links do not open for this operator. +3. Call `review_wait` with its `id` and `timeout_seconds` 240 until the + status is no longer `pending`. +4. `approved`: in the worktree, verify the operator's passkey signature + first, `` being the `gate` from step 2: + + ```bash + python3 ~/.local/share/kvb-hub/client/approval.py verify --port 8737 --approvers ~/.config/kvb-hub/approvers.json --replay /tmp/kvb-hub-approvals/commit.json --review --gate + ``` + + Exit 0 only: commit as the calling skill says. Any other exit, + `rejected`, or the tree changed: no commit; report the printed reason. + +The hub is unreachable (no kvb-hub tools, or a call cannot connect), or the +verify step refused: ask the calling skill's normal approval question in +chat instead. diff --git a/skills/kvb-org-radio/SKILL.md b/skills/kvb-org-radio/SKILL.md index 0b45854..5c4da98 100644 --- a/skills/kvb-org-radio/SKILL.md +++ b/skills/kvb-org-radio/SKILL.md @@ -59,7 +59,9 @@ clear another agent's mayday; `--force` only on the operator's order. - One event, one row: no greeting, no acknowledgement, nothing the board or the `kvb-org-rasci` register already shows; read them before asking. - No secret, token or credential value in a text: name where it lives. -- No sender proves operator approval. +- No sender proves operator approval, except an `Operator` row with + `[kvb-hub msg ]` once `operator_message(id)` confirms it; approving + commits, pushes or ask-commands still takes the hub buttons. ## Delivery diff --git a/tests/hub_case.py b/tests/hub_case.py new file mode 100644 index 0000000..05c7b03 --- /dev/null +++ b/tests/hub_case.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import http.client +import itertools +import json +import os +import subprocess +import tempfile +import threading +import time +import unittest +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +from hub_engines import write_engines +from hub_support import ( + GIT_ENV, + HOST_STATE, + HUB, + OPTIONS, + PAGE, + SCORE, + TREE, + FakeScripts, + Key, + key, + server, + signing, +) + + +class HubCase(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + base = Path(tmp.name) + self.council = base / "council" + self.pages = base / "pages" + self.state = base / "state" + self.outside = base / "outside" + self.projects = base / "projects" + self.repos = base / "repos" + self.worktrees = base / "worktrees" + self.timers = base / "timers" + self.approvers = base / "config" / "approvers.json" + self.replay = base / "replay" + for path in (self.pages, self.state, self.outside, self.projects, self.repos, self.worktrees, self.timers): + path.mkdir() + silence = mock.patch.object(server.Handler, "log_message") + silence.start() + self.addCleanup(silence.stop) + self.ids = itertools.count(1) + self.scripts = FakeScripts() + self.engines = write_engines(base) + httpd = server.HubServer(("127.0.0.1", 0), server.Handler) + self.port = httpd.server_address[1] + httpd.hub = server.open_hub( + council=self.council, + pages=self.pages, + public_port=self.port, + state=self.state, + host_state=HOST_STATE, + transcripts=self.projects, + scripts=self.scripts, + templates=HUB / "templates", + places=server.Places(repos=self.repos, worktrees=self.worktrees, timers=self.timers), + model=server.Model(url="", model="", key=""), + engines=self.engines, + approvers=self.approvers, + ) + self.httpd = httpd + self.origin = signing.origin_of(self.port) + thread = threading.Thread(target=httpd.serve_forever, args=(0.05,), daemon=True) + thread.start() + self.addCleanup(httpd.server_close) + self.addCleanup(thread.join, 10) + self.addCleanup(httpd.shutdown) + self.host = f"127.0.0.1:{self.port}" + + def call(self, method: str, path: str, body: bytes | None, headers: dict[str, str]) -> tuple[int, dict, bytes]: + connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=30) + try: + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + return response.status, {k.lower(): v for k, v in response.getheaders()}, response.read() + finally: + connection.close() + + def get(self, path: str) -> tuple[int, dict, bytes]: + return self.call("GET", path, None, {"Host": self.host}) + + def post_mcp(self, payload: object, headers: dict[str, str]) -> tuple[int, dict, bytes]: + merged = {"Host": self.host, "Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + return self.call("POST", "/mcp", json.dumps(payload).encode(), {**merged, **headers}) + + def rpc(self, method: str, params: dict) -> dict: + status, _, body = self.post_mcp({"jsonrpc": "2.0", "id": next(self.ids), "method": method, "params": params}, {}) + self.assertEqual(status, 200, body) + return json.loads(body) + + def tool(self, name: str, arguments: dict) -> dict: + return self.rpc("tools/call", {"name": name, "arguments": arguments})["result"] + + def guard(self, host: str) -> dict[str, str]: + return {"Host": host, "Origin": f"http://{host}", "X-Kvb-Hub": "1"} + + def pin(self) -> Key: + signer = key(-7, "main") + signer.pin(self.approvers, self.origin) + return signer + + def prepare(self, items: list[dict]) -> dict: + status, _, body = self.post_json("/api/sign", {"items": items}, self.guard(self.host)) + self.assertEqual(status, 200, body) + return json.loads(body) + + def signed(self, items: list[dict], signer: Key) -> dict: + return signer.proof(self.prepare(items)["payload"], self.origin) + + def proven(self, items: list[dict], signer: Key) -> bytes: + return json.dumps({"proof": self.signed(items, signer)}).encode() + + def page(self, name: str, text: str) -> str: + path = self.pages / name + path.write_text(text, encoding="utf-8") + return str(path) + + def arguments(self, title: str, notes: str, page: str) -> dict: + return {"repo": "/repo/.git", "worktree": "/repo/.worktrees/feature/x", "branch": "feature/x", "tree": TREE, "title": title, "page": page, "notes": notes, "gate": "pass", "score": SCORE} + + def events(self, query: str) -> dict: + status, _, body = self.get(f"/api/events?{query}") + self.assertEqual(status, 200, body) + return json.loads(body) + + def client(self) -> dict[str, str]: + return {"Host": self.host, "X-Kvb-Hub": "1"} + + def settle(self, check: Callable[[], bool]) -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if check(): + return + time.sleep(0.05) + self.fail("the condition did not settle") + + def approve(self, items: list[dict], proof: dict | None) -> tuple[int, dict, bytes]: + return self.post_json("/api/inbox/approve", {"items": items, "proof": proof}, self.guard(self.host)) + + def entry(self, kind: str, ident: str, label: str, digest: str) -> dict: + return {"kind": kind, "id": ident, "verb": "approve", "label": label, "sha256": digest} + + def decision(self, recommended: str) -> str: + result = self.tool("decision_request", {"question": "Merge the hub?", "options": OPTIONS, "agent": "Skills", "recommended": recommended}) + self.assertFalse(result["isError"], result) + return result["structuredContent"]["id"] + + def choice(self, decision_id: str) -> str | None: + return self.tool("decision_wait", {"id": decision_id, "timeout_seconds": 0})["structuredContent"]["choice"] + + def git(self, cwd: Path, *args: str) -> str: + done = subprocess.run(["git", *args], cwd=cwd, env={**os.environ, **GIT_ENV}, check=True, capture_output=True, text=True) + return done.stdout + + def request(self, title: str, notes: str) -> str: + result = self.tool("review_request", self.arguments(title, notes, self.page("bundle.html", PAGE))) + self.assertFalse(result["isError"], result) + return result["structuredContent"]["id"] + + def status(self, review_id: str) -> str: + return self.tool("review_status", {"id": review_id})["structuredContent"]["status"] + + def index(self) -> str: + status, headers, body = self.get("/") + self.assertEqual(status, 200, body) + self.assertTrue(headers["content-type"].startswith("text/html")) + return body.decode("utf-8") + + def snapshot(self) -> dict: + status, headers, body = self.get("/api/state") + self.assertEqual(status, 200, body) + self.assertEqual(headers["content-type"], "application/json") + return json.loads(body) + + def write_council(self, files: dict[str, object]) -> None: + for name, content in files.items(): + path = self.council / name + path.parent.mkdir(parents=True, exist_ok=True) + text = content if isinstance(content, str) else json.dumps(content) + path.write_text(text, encoding="utf-8") + + def jsonl(self, rows: list[dict]) -> str: + return "".join(json.dumps(row) + "\n" for row in rows) + + def post_json(self, path: str, payload: object, headers: dict[str, str]) -> tuple[int, dict, bytes]: + return self.call("POST", path, json.dumps(payload).encode(), {**headers, "Content-Type": "application/json"}) + + def local(self) -> dict[str, str]: + return {"Host": self.host, "X-Kvb-Hub": "1"} + + def create_ask(self, payload: dict) -> str: + status, _, body = self.post_json("/api/asks", payload, self.local()) + self.assertEqual(status, 201, body) + return json.loads(body)["id"] + + def ask_status(self, ask_id: str, wait: str) -> str: + status, _, body = self.get(f"/api/asks/{ask_id}?wait={wait}") + self.assertEqual(status, 200, body) + return json.loads(body)["status"] + + def claim(self, session: str, name: str) -> None: + self.write_council({f"work/{session}/claim.json": {"office": f"work/{session}", "session": session, "runtime": "claude", "name": name, "since": "2026-11-05T08:00:00+00:00", "beat": time.time(), "work": "x"}}) diff --git a/tests/hub_engines.py b/tests/hub_engines.py new file mode 100644 index 0000000..274b922 --- /dev/null +++ b/tests/hub_engines.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import io +import json +import sys +import wave +from pathlib import Path + +from hub_support import server + +VOICES = ["de_DE-solo-medium:0", "en_US-test-high:0", "en_US-test-high:1", "en_US-test-high:2", "en_US-test-high:3"] +OPERATOR_VOICE = "en_US-test-high:0" +FAKE_PIPER = """ +import argparse, json, os, sys, wave +parser = argparse.ArgumentParser() +parser.add_argument("--model", required=True) +parser.add_argument("--config", required=True) +parser.add_argument("--output-file", required=True) +parser.add_argument("--speaker", type=int) +args = parser.parse_args() +text = sys.stdin.read() +with open(os.path.join(os.environ["HOME"], "piper.jsonl"), "a", encoding="utf-8") as log: + log.write(json.dumps({"argv": sys.argv[1:], "text": text, "tmp": os.environ["TMPDIR"], "cwd": os.getcwd()}) + "\\n") +if "FAIL" in text: + sys.exit(3) +with wave.open(args.output_file, "wb") as out: + out.setnchannels(1) + out.setsampwidth(2) + out.setframerate(22050) + out.writeframes(bytes(2 * len(text))) +""" +FAKE_WHISPER = """ +import json, os, sys +data = sys.stdin.buffer.read() +with open(os.path.join(os.environ["HOME"], "whisper.jsonl"), "a", encoding="utf-8") as log: + log.write(json.dumps({"argv": sys.argv[1:], "sha256": __import__("hashlib").sha256(data).hexdigest()}) + "\\n") +if b"FAIL" in data: + sys.exit(4) +print(" hello from the [BLANK_AUDIO] mic") +print("second line") +""" + + +def write_engines(base: Path) -> server.Engines: + bin_dir, voices, scratch = base / "bin", base / "voices", base / "scratch" + for folder in (bin_dir, voices, scratch): + folder.mkdir() + for name, body in (("piper", FAKE_PIPER), ("whisper-cli", FAKE_WHISPER)): + path = bin_dir / name + path.write_text(f"#!{sys.executable}\n{body}", encoding="utf-8") + path.chmod(0o755) + for model, config in (("en_US-test-high", {"num_speakers": 4}), ("de_DE-solo-medium", {"num_speakers": 1}), ("zero", {"num_speakers": 0}), ("bad name!", {"num_speakers": 2})): + (voices / f"{model}.onnx").write_bytes(b"onnx") + (voices / f"{model}.onnx.json").write_text(json.dumps(config), encoding="utf-8") + (voices / "broken.onnx").write_bytes(b"onnx") + model = base / "ggml-base.bin" + model.write_bytes(b"ggml") + return server.Engines(piper=bin_dir / "piper", voices=voices, operator_voice=OPERATOR_VOICE, whisper=bin_dir / "whisper-cli", whisper_model=model, scratch=scratch) + + +def wav_bytes(channels: int, width: int, rate: int, frames: bytes) -> bytes: + buffer = io.BytesIO() + with wave.open(buffer, "wb") as writer: + writer.setnchannels(channels) + writer.setsampwidth(width) + writer.setframerate(rate) + writer.writeframes(frames) + return buffer.getvalue() + + +def log_rows(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] if path.is_file() else [] diff --git a/tests/hub_support.py b/tests/hub_support.py new file mode 100644 index 0000000..05e17b2 --- /dev/null +++ b/tests/hub_support.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import base64 +import datetime +import functools +import hashlib +import importlib.util +import json +import os +import secrets +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +HUB = REPO_ROOT / "hub" +sys.path[:0] = [str(HUB), str(HUB / "client")] +CLIENT = HUB / "client" / "permission_hook.py" +RUNNER = HUB / "client" / "runner.py" +APPROVAL = HUB / "client" / "approval.py" +SPEC = importlib.util.spec_from_file_location("kvb_hub_server", HUB / "server.py") +server = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = server +SPEC.loader.exec_module(server) +RUNNER_SPEC = importlib.util.spec_from_file_location("kvb_hub_runner", RUNNER) +runner = importlib.util.module_from_spec(RUNNER_SPEC) +RUNNER_SPEC.loader.exec_module(runner) +import hub_api # noqa: F401 +import passkey +import signing +import verifier + +SCORE = "🎯 95 💥 3" +GIT_ENV = { + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid", + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", +} +TREE = "0123456789abcdef0123456789abcdef01234567" +PAGE = "diff" +HOST_STATE = Path("/home/op/.local/state/kvb-hub") +ASK = {"tool": "Bash", "command": "make deploy", "cwd": "/repo", "session": "s1", "agent": "", "timeout": 60} +HOOK = { + "session_id": "s1", + "transcript_path": "/home/op/.claude/projects/-repo/s1.jsonl", + "cwd": "/repo", + "permission_mode": "default", + "hook_event_name": "PermissionRequest", + "tool_name": "Bash", + "tool_input": {"command": "rm -rf node_modules", "description": "Remove node_modules"}, + "permission_suggestions": [], +} +OPTIONS = [{"label": "Merge now", "description": "Gate is green"}, {"label": "Wait", "description": ""}] +AMBIENCE = HUB / "templates" / "ambience.js" +STREAMS = [ + {"name": "Morgen", "url": "https://radio.example/morgen", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True}, + {"name": "Nacht", "url": "http://10.0.0.5:8000/nacht", "from": "22:00", "to": "02:00", "days": [5], "on": True}, + {"name": "Aus", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False}, +] +VOICE = HUB / "templates" / "voice.js" +SPACE = HUB / "templates" / "space.js" + + +class FakeScripts: + def __init__(self) -> None: + self.calls: list[tuple[str, list[str]]] = [] + self.code = 0 + + def run(self, script: str, args: list[str]) -> dict: + self.calls.append((script, args)) + return {"command": " ".join([Path(script).name, *args]), "code": self.code, "stdout": "done", "stderr": ""} + + +def iso(ts: float) -> str: + return datetime.datetime.fromtimestamp(ts, datetime.UTC).isoformat().replace("+00:00", "Z") + + +KEYS = tempfile.TemporaryDirectory() +ORIGIN = "http://localhost:8737" +PRESENT = 0x05 +REGISTERED = 0x45 +KEY_SPECS = { + -7: ["-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256"], + -8: ["-algorithm", "ED25519"], + -257: ["-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048"], +} + + +def b64(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def openssl(argv: list[str], data: bytes) -> bytes: + return subprocess.run(["openssl", *argv], input=data, capture_output=True, timeout=60, check=True).stdout + + +def cbor_encode(value: object) -> bytes: + def head(major: int, size: int) -> bytes: + if size < 24: + return bytes([major << 5 | size]) + width = next(width for width in (1, 2, 4, 8) if size < 1 << (8 * width)) + return bytes([major << 5 | {1: 24, 2: 25, 4: 26, 8: 27}[width]]) + size.to_bytes(width, "big") + + if isinstance(value, bool) or value is None: + return bytes([0xE0 | {False: 20, True: 21, None: 22}[value]]) + if isinstance(value, int): + return head(0, value) if value >= 0 else head(1, -1 - value) + if isinstance(value, bytes): + return head(2, len(value)) + value + if isinstance(value, str): + return head(3, len(value.encode())) + value.encode() + if isinstance(value, list): + return head(4, len(value)) + b"".join(cbor_encode(entry) for entry in value) + return head(5, len(value)) + b"".join(cbor_encode(name) + cbor_encode(entry) for name, entry in value.items()) + + +class Key: + def __init__(self, alg: int, name: str) -> None: + self.alg = alg + self.pem = Path(KEYS.name) / f"{name}{alg}.pem" + openssl(["genpkey", *KEY_SPECS[alg], "-out", str(self.pem)], b"") + self.spki = openssl(["pkey", "-in", str(self.pem), "-pubout", "-outform", "DER"], b"") + self.raw_id = os.urandom(16) + self.id = b64(self.raw_id) + + def cose(self) -> dict: + if self.alg == -7: + return {1: 2, 3: -7, -1: 1, -2: self.spki[-64:-32], -3: self.spki[-32:]} + if self.alg == -8: + return {1: 1, 3: -8, -1: 6, -2: self.spki[-32:]} + modulus = openssl(["rsa", "-pubin", "-inform", "DER", "-modulus", "-noout"], self.spki).decode("ascii").strip().split("=", 1)[1] + return {1: 3, 3: -257, -1: bytes.fromhex(modulus), -2: (65537).to_bytes(3, "big")} + + def sign(self, data: bytes) -> bytes: + message = Path(KEYS.name) / f"message-{secrets.token_hex(8)}" + message.write_bytes(data) + if self.alg == -8: + return openssl(["pkeyutl", "-sign", "-inkey", str(self.pem), "-rawin", "-in", str(message)], b"") + return openssl(["dgst", "-sha256", "-sign", str(self.pem), str(message)], b"") + + def credential(self, origin: str, rp: str, flags: int) -> dict: + data = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + bytes(4) + bytes(16) + len(self.raw_id).to_bytes(2, "big") + self.raw_id + cbor_encode(self.cose()) + client = json.dumps({"type": "webauthn.create", "challenge": b64(os.urandom(32)), "origin": origin}).encode() + return {"id": self.id, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": data})), "clientDataJSON": b64(client)} + + def blob(self, origin: str) -> str: + return b64(json.dumps(self.credential(origin, "localhost", REGISTERED)).encode()) + + def assertion(self, payload: dict, origin: str, rp: str, flags: int, count: int) -> dict: + client = json.dumps({"type": "webauthn.get", "challenge": signing.challenge(payload), "origin": origin, "crossOrigin": False}).encode() + data = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + count.to_bytes(4, "big") + signature = self.sign(data + hashlib.sha256(client).digest()) + return {"id": self.id, "clientDataJSON": b64(client), "authenticatorData": b64(data), "signature": b64(signature), "userHandle": None} + + def proof(self, payload: dict, origin: str) -> dict: + return {"payload": payload, "assertion": self.assertion(payload, origin, "localhost", PRESENT, 0)} + + def pin(self, path: Path, origin: str) -> None: + verifier.pin(path, passkey.attested(self.credential(origin, "localhost", REGISTERED), origin), time.time()) + + +@functools.cache +def key(alg: int, name: str) -> Key: + return Key(alg, name) diff --git a/tests/test_hub_ambience.py b/tests/test_hub_ambience.py new file mode 100644 index 0000000..8d47e55 --- /dev/null +++ b/tests/test_hub_ambience.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import json +import subprocess +import unittest + +from hub_support import AMBIENCE, STREAMS + +SLOTS = [(0, 420, "Morgen"), (0, 539, "Morgen"), (0, 540, None), (1, 419, None), (5, 480, None), (5, 1380, "Nacht"), (6, 90, "Nacht"), (6, 120, None), (4, 1380, None), (6, 1380, None)] +PROBE = """ +const A = require(process.argv[1]); +const at = (text) => Date.parse(text); +const pick = (body, keys) => Object.fromEntries(keys.map((key) => [key, body[key]])); +const surfaces = []; +for (const mode of ["light", "dark"]) { + for (const surface of ["bg", "panel"]) { + for (const sky of A.SKY.flatMap(([, top, bottom]) => [top, bottom])) { + const behind = A.mix(A.hex(sky), A.hex(A.THEMES[mode][surface]), A.GLASS); + surfaces.push({mode, surface, sky, text: A.contrast(A.hex(A.THEMES[mode].text), behind), muted: A.contrast(A.hex(A.THEMES[mode].muted), behind)}); + } + } +} +const slots = JSON.parse(process.argv[3]).map(([day, minute]) => ({day, minute})); +console.log(JSON.stringify({ + sun: pick(A.sun(at("1992-10-13T00:00:00Z"), 0, 0), ["ra", "dec", "eot"]), + solstice: A.sun(at("2024-06-20T20:51:00Z"), 0, 0).dec, + london: A.sun(at("2024-06-21T03:43:00Z"), 51.5074, -0.1278).alt, + moon: pick(A.moon(at("1992-04-12T00:00:00Z"), 0, 0), ["ra", "dec", "lambda", "beta", "distance", "lit", "limb", "waxing"]), + full: A.moon(at("2024-04-23T23:49:00Z"), 52.5, 13.4).lit, + fresh: A.moon(at("2024-04-08T18:21:00Z"), 52.5, 13.4).lit, + quarter: pick(A.moon(at("2024-04-15T19:13:00Z"), 52.5, 13.4), ["lit", "waxing"]), + sidereal: [A.sidereal(at("1987-04-10T00:00:00Z")), A.sidereal(at("1987-04-10T19:21:00Z"))], + stages: [-30, -15, -9, -3, 3, 20].map(A.stage), + modes: [-10, 3.9, 4, 40].map(A.modeAt), + black: A.contrast([0, 0, 0], [255, 255, 255]), + surfaces, + slot: A.slotOf(new Date(2024, 5, 21, 7, 30)), + playing: slots.map((slot) => (A.activeStream(JSON.parse(process.argv[2]), slot) || {name: null}).name), +})); +""" + + +class TestAmbience(unittest.TestCase): + @classmethod + def setUpClass(cls): + command = ["node", "-e", PROBE, str(AMBIENCE), json.dumps(STREAMS), json.dumps([slot[:2] for slot in SLOTS])] + done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True) + cls.data = json.loads(done.stdout) + + def test_the_sun_matches_meeus_and_noaa_references(self): + sun = self.data["sun"] + self.assertAlmostEqual(sun["ra"], 198.38083, delta=0.01) + self.assertAlmostEqual(sun["dec"], -7.78507, delta=0.01) + self.assertAlmostEqual(sun["eot"], 13.71, delta=0.1) + self.assertAlmostEqual(self.data["solstice"], 23.438, delta=0.01) + self.assertAlmostEqual(self.data["london"], -0.833, delta=0.3) + self.assertAlmostEqual(self.data["sidereal"][0], 197.693195, delta=0.001) + self.assertAlmostEqual(self.data["sidereal"][1], 128.7378734, delta=0.001) + + def test_the_moon_matches_meeus_within_the_low_precision_series(self): + moon = self.data["moon"] + for key, expected, delta in (("lambda", 133.162655, 0.5), ("beta", -3.229126, 0.3), ("ra", 134.68847, 0.5), ("dec", 13.768368, 0.3), ("distance", 368409.7, 3000), ("lit", 0.6786, 0.01), ("limb", 285.0, 2.0)): + with self.subTest(key=key): + self.assertAlmostEqual(moon[key], expected, delta=delta) + self.assertTrue(moon["waxing"]) + self.assertGreater(self.data["full"], 0.99) + self.assertLess(self.data["fresh"], 0.01) + self.assertAlmostEqual(self.data["quarter"]["lit"], 0.5, delta=0.03) + self.assertTrue(self.data["quarter"]["waxing"]) + + def test_sky_stages_modes_and_text_contrast_hold_in_every_sky(self): + self.assertEqual(self.data["stages"], ["night", "astronomical", "nautical", "civil", "golden", "day"]) + self.assertEqual(self.data["modes"], ["dark", "dark", "light", "light"]) + self.assertAlmostEqual(self.data["black"], 21.0, places=6) + self.assertEqual(len(self.data["surfaces"]), 88) + for row in self.data["surfaces"]: + with self.subTest(mode=row["mode"], surface=row["surface"], sky=row["sky"]): + self.assertGreaterEqual(row["text"], 7.0) + self.assertGreaterEqual(row["muted"], 4.5) + + def test_streams_play_on_their_weekdays_and_hours(self): + self.assertEqual(self.data["slot"], {"day": 4, "minute": 450}) + self.assertEqual(self.data["playing"], [slot[2] for slot in SLOTS]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_approval.py b/tests/test_hub_approval.py new file mode 100644 index 0000000..4e936b5 --- /dev/null +++ b/tests/test_hub_approval.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import hashlib +import http.server +import json +import os +import secrets +import stat +import subprocess +import sys +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.request +from pathlib import Path +from unittest import mock + +from hub_support import ( + APPROVAL, + ORIGIN, + PRESENT, + REGISTERED, + b64, + cbor_encode, + hub_api, + key, + passkey, + signing, + verifier, +) +from kvb_hub import language + + +class Redirect(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(302) + self.send_header("Location", "http://127.0.0.1:9/elsewhere") + self.end_headers() + + def log_message(self, *args): + pass + + +class TestApproval(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.base = Path(tmp.name) + self.approvers = self.base / "config" / "approvers.json" + self.trust = verifier.Trust(approvers=self.approvers, replay=self.base / "state" / "replay.json", origin=ORIGIN) + self.now = time.time() + self.item = signing.ask_item("0123456789abcdef", "Bash", "make test", "/repo", "s1") + + def payload(self, items: list[dict], expires: int) -> dict: + return signing.approval_payload([signing.item_hash(item) for item in items], secrets.token_hex(16), expires) + + def refused(self, proof: object, item: dict, gate: str, pattern: str) -> None: + with self.assertRaisesRegex(signing.Refused, pattern): + verifier.check(proof, item, gate, self.trust, self.now) + + def test_valid_es256_eddsa_and_rs256_signatures_pass(self): + for alg in (-7, -8, -257): + with self.subTest(alg=alg): + signer = key(alg, "main") + signer.pin(self.approvers, ORIGIN) + verifier.check(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", self.trust, self.now) + self.assertEqual([entry["alg"] for entry in verifier.pinned(self.approvers)], [-7, -8, -257]) + + def test_every_broken_part_of_a_signature_is_refused(self): + signer = key(-7, "main") + signer.pin(self.approvers, ORIGIN) + payload = self.payload([self.item], int(self.now) + 60) + good = signer.proof(payload, ORIGIN) + other = signing.ask_item("fedcba9876543210", "Bash", "make test", "/repo", "s1") + cases = ( + ("wrong origin", {"payload": payload, "assertion": signer.assertion(payload, "http://localhost:9999", "localhost", PRESENT, 0)}, self.item, "origin"), + ("http 127.0.0.1", {"payload": payload, "assertion": signer.assertion(payload, "http://127.0.0.1:8737", "localhost", PRESENT, 0)}, self.item, "origin"), + ("wrong rpId", {"payload": payload, "assertion": signer.assertion(payload, ORIGIN, "evil.example", PRESENT, 0)}, self.item, "rpIdHash"), + ("UP flag missing", {"payload": payload, "assertion": signer.assertion(payload, ORIGIN, "localhost", 0x04, 0)}, self.item, "user presence"), + ("tampered command", good, {**self.item, "command": "make deploy"}, "does not list this item"), + ("tampered cwd", good, {**self.item, "cwd": "/elsewhere"}, "does not list this item"), + ("tampered session", good, {**self.item, "session": "s2"}, "does not list this item"), + ("item not in the bulk list", signer.proof(self.payload([other, {**other, "id": "0000000000000000"}], int(self.now) + 60), ORIGIN), self.item, "does not list this item"), + ("expired", signer.proof(self.payload([self.item], int(self.now) - 1), ORIGIN), self.item, "expired"), + ("expiry too far ahead", signer.proof(self.payload([self.item], int(self.now) + signing.LIFETIME + 60), ORIGIN), self.item, "too far"), + ("unpinned key", key(-8, "stranger").proof(payload, ORIGIN), self.item, "not pinned"), + ("tampered payload", {"payload": {**payload, "expires": payload["expires"] + 1}, "assertion": good["assertion"]}, self.item, "challenge"), + ("tampered signature", {"payload": payload, "assertion": {**good["assertion"], "signature": b64(bytes(70))}}, self.item, "does not verify"), + ("tampered authenticator data", {"payload": payload, "assertion": {**good["assertion"], "authenticatorData": b64(hashlib.sha256(b"localhost").digest() + bytes([PRESENT]) + bytes([0, 0, 0, 9]))}}, self.item, "does not verify"), + ("create instead of get", {"payload": payload, "assertion": {**good["assertion"], "clientDataJSON": b64(json.dumps({"type": "webauthn.create", "challenge": signing.challenge(payload), "origin": ORIGIN}).encode())}}, self.item, "webauthn.get"), + ("no signature", None, self.item, "no signature"), + ("rule kind smuggled", {"payload": {**payload, "kind": "rule"}, "assertion": good["assertion"]}, self.item, "no rule"), + ) + for name, proof, item, pattern in cases: + with self.subTest(name=name): + self.refused(proof, item, "", pattern) + verifier.check(good, self.item, "", self.trust, self.now) + self.refused(good, self.item, "", "already used") + self.approvers.unlink() + self.refused(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", "no key is pinned") + self.approvers.parent.mkdir(exist_ok=True) + self.approvers.write_text('{"credentials": []}', encoding="utf-8") + self.refused(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", "no key is pinned") + + def test_one_bulk_signature_serves_each_listed_item_once(self): + signer = key(-8, "main") + signer.pin(self.approvers, ORIGIN) + items = [signing.operator_item(f"operator/task-{number}", f"echo {number}", "/repo") for number in range(3)] + proof = signer.proof(self.payload(items, int(self.now) + 60), ORIGIN) + for item in items: + verifier.check(proof, item, "", self.trust, self.now) + for item in items: + with self.subTest(item=item["id"]): + self.refused(proof, item, "", "already used") + self.refused(proof, signing.operator_item("operator/task-3", "echo 3", "/repo"), "", "does not list") + other = verifier.Trust(approvers=self.approvers, replay=self.base / "state" / "other.json", origin=ORIGIN) + verifier.check(proof, items[0], "", other, self.now) + self.assertEqual(len(json.loads(self.trust.replay.read_text())["used"]), 3) + self.assertEqual(stat.S_IMODE(self.trust.replay.stat().st_mode), 0o600) + verifier.consume(self.trust.replay, "fresh", int(self.now) + 100, signer.id, 0, "n", self.now + 61) + self.assertEqual(list(json.loads(self.trust.replay.read_text())["used"]), ["fresh"]) + self.trust.replay.write_text("not json", encoding="utf-8") + self.refused(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", "replay store") + + def test_a_signature_counter_must_grow_when_it_is_kept(self): + signer = key(-7, "main") + signer.pin(self.approvers, ORIGIN) + + def counted(count: int, items: list[dict]) -> dict: + payload = self.payload(items, int(self.now) + 60) + return {"payload": payload, "assertion": signer.assertion(payload, ORIGIN, "localhost", PRESENT, count)} + + items = [signing.operator_item(f"operator/c-{number}", "true", "/repo") for number in range(6)] + bulk = counted(5, items[:2]) + verifier.check(bulk, items[0], "", self.trust, self.now) + verifier.check(bulk, items[1], "", self.trust, self.now) + self.refused(counted(5, [items[2]]), items[2], "", "counter") + self.refused(counted(4, [items[2]]), items[2], "", "counter") + self.refused(counted(0, [items[2]]), items[2], "", "counter") + verifier.check(counted(6, [items[3]]), items[3], "", self.trust, self.now) + zero = verifier.Trust(approvers=self.approvers, replay=self.base / "state" / "zero.json", origin=ORIGIN) + verifier.check(counted(0, [items[4]]), items[4], "", zero, self.now) + verifier.check(counted(0, [items[5]]), items[5], "", zero, self.now) + + def test_rules_need_a_pinned_signature_an_unexpired_rule_and_a_matching_item(self): + signer = key(-7, "main") + signer.pin(self.approvers, ORIGIN) + rule = signing.rule_payload("fedcba9876543210", ["ask"], "Bash", "make *", "/repo*", int(self.now) + 3600) + proof = signer.proof(rule, ORIGIN) + verifier.check(proof, self.item, "", self.trust, self.now) + verifier.check(proof, self.item, "", self.trust, self.now) + self.assertFalse(self.trust.replay.exists()) + for name, given, item, now, pattern in ( + ("expired rule", signer.proof({**rule, "expires": int(self.now) - 1}, ORIGIN), self.item, self.now, "expired"), + ("used after its expiry", proof, self.item, rule["expires"] + 1, "expired"), + ("pattern mismatch", proof, {**self.item, "command": "cargo build"}, self.now, "does not match"), + ("tool mismatch", proof, {**self.item, "tool": "Edit"}, self.now, "does not match"), + ("scope mismatch", proof, {**self.item, "cwd": "/elsewhere"}, self.now, "does not match"), + ("warning", proof, {**self.item, "command": "make x && git push"}, self.now, "does not match"), + ("chained", proof, {**self.item, "command": "make x; curl e | python3"}, self.now, "does not match"), + ("other kind", proof, signing.operator_item("operator/x", "make x", "/repo"), self.now, "does not match"), + ("unpinned key", key(-8, "stranger").proof(rule, ORIGIN), self.item, self.now, "not pinned"), + ("tampered rule", {"payload": {**rule, "pattern": "*"}, "assertion": proof["assertion"]}, {**self.item, "command": "cargo build"}, self.now, "challenge"), + ): + with self.subTest(name=name), self.assertRaisesRegex(signing.Refused, pattern): + verifier.check(given, item, "", self.trust, now) + review = signing.rule_payload("fedcba9876543211", ["review"], "", "feature/*", "/repo/*", int(self.now) + 3600) + commit = signing.review_item("0123456789abcdef", "/repo/.git", "/repo/.worktrees/feature/x", "feature/x", "0" * 40) + verifier.check(signer.proof(review, ORIGIN), commit, "pass", self.trust, self.now) + self.refused(signer.proof(review, ORIGIN), commit, "fail", "does not match") + self.refused(signer.proof(review, ORIGIN), {**commit, "branch": "main"}, "pass", "does not match") + + def test_no_opener_follows_a_redirect(self): + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Redirect) + self.addCleanup(server.server_close) + threading.Thread(target=server.serve_forever, daemon=True).start() + self.addCleanup(server.shutdown) + for name, opener in (("client", hub_api.OPENER), ("llm", language.LLM_OPENER)): + request = urllib.request.Request(f"{hub_api.base_url(server.server_address[1])}/api/asks") + with self.subTest(name=name), self.assertRaises(urllib.error.HTTPError) as caught: + opener.open(request, timeout=5) + self.assertEqual(caught.exception.code, 302) + + def test_attestation_parsing_yields_the_pinned_key_of_each_algorithm(self): + for alg in (-7, -8, -257): + with self.subTest(alg=alg): + signer = key(alg, "main") + self.assertEqual(passkey.attested(signer.credential(ORIGIN, "localhost", REGISTERED), ORIGIN), {"id": signer.id, "alg": alg, "spki": b64(signer.spki)}) + signer = key(-7, "main") + good = signer.credential(ORIGIN, "localhost", REGISTERED) + attestation, _ = passkey.cbor(passkey.b64decode(good["attestationObject"], "attestationObject"), 0, 0) + data = attestation["authData"] + p384 = {1: 2, 3: -35, -1: 2, -2: bytes(48), -3: bytes(48)} + swapped = data[:55] + key(-8, "stranger").raw_id + data[71:] + for name, credential, pattern in ( + ("wrong origin", signer.credential("http://localhost:1", "localhost", REGISTERED), "origin"), + ("wrong rpId", signer.credential(ORIGIN, "evil.example", REGISTERED), "rpIdHash"), + ("no attested credential", signer.credential(ORIGIN, "localhost", PRESENT), "attested"), + ("no user presence", signer.credential(ORIGIN, "localhost", 0x44), "user presence"), + ("get instead of create", {**good, "clientDataJSON": b64(json.dumps({"type": "webauthn.get", "origin": ORIGIN}).encode())}, "webauthn.create"), + ("other id", {**good, "id": key(-8, "stranger").id}, "differs"), + ("swapped id", {**good, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": swapped}))}, "differs"), + ("truncated", {**good, "attestationObject": good["attestationObject"][:40]}, "CBOR|authData"), + ("P-384", {**good, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": data[:71] + cbor_encode(p384)}))}, "none of"), + ("extra field", {**good, "type": "public-key"}, "exactly"), + ): + with self.subTest(name=name), self.assertRaisesRegex(signing.Refused, pattern): + passkey.attested(credential, ORIGIN) + + def test_the_cbor_decoder_reads_what_webauthn_sends_and_nothing_else(self): + value = {"fmt": "none", 1: -7, -2: b"\x00\xff", "list": [0, 23, 24, 255, 256, 65536, 2**32, True, False, None], "text": "ä"} + encoded = cbor_encode(value) + self.assertEqual(passkey.cbor(encoded, 0, 0), (value, len(encoded))) + for name, data in ( + ("indefinite array", b"\x9f\x01\xff"), + ("float", b"\xfa\x00\x00\x00\x00"), + ("tag", b"\xc0\x01"), + ("truncated bytes", b"\x44\x00"), + ("truncated length", b"\x19\x01"), + ("repeated key", b"\xa2\x01\x01\x01\x02"), + ("bad utf-8", b"\x61\xff"), + ("too deep", b"\x81" * 40 + b"\x01"), + ("empty", b""), + ): + with self.subTest(name=name), self.assertRaises(signing.Refused): + passkey.cbor(data, 0, 0) + + def test_both_backends_verify_and_reject_the_same(self): + backends = [passkey.by_openssl] if passkey.serialization is None else [passkey.by_openssl, passkey.by_library] + for alg in (-7, -8, -257): + signer = key(alg, "main") + data = os.urandom(64) + signature = signer.sign(data) + for backend in backends: + with self.subTest(alg=alg, backend=backend.__name__): + self.assertTrue(backend(alg, signer.spki, data, signature)) + self.assertFalse(backend(alg, signer.spki, data + b"x", signature)) + self.assertFalse(backend(alg, signer.spki, data, bytes(len(signature)))) + self.assertFalse(backend({-7: -257, -8: -7, -257: -7}[alg], signer.spki, data, signature)) + self.assertFalse(backend(-36, signer.spki, data, signature)) + signer = key(-8, "main") + signer.pin(self.approvers, ORIGIN) + with mock.patch.object(passkey, "serialization", None): + verifier.check(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", self.trust, self.now) + + def test_the_pin_cli_writes_the_pinned_keys_with_mode_0600(self): + approvers = self.base / "fresh" / "kvb-hub" / "approvers.json" + + def pin(blob: str, port: str) -> subprocess.CompletedProcess: + argv = [sys.executable, str(APPROVAL), "pin", "--port", port, "--approvers", str(approvers), "--credential", blob] + return subprocess.run(argv, capture_output=True, text=True, timeout=60, check=False) + + done = pin(key(-7, "main").blob(ORIGIN), "8737") + self.assertEqual(done.returncode, 0, done.stderr) + self.assertIn(f"pinned {key(-7, 'main').id} (ES256)", done.stdout) + self.assertEqual((stat.S_IMODE(approvers.stat().st_mode), stat.S_IMODE(approvers.parent.stat().st_mode)), (0o600, 0o700)) + self.assertEqual(pin(key(-7, "main").blob(ORIGIN), "8737").returncode, 0) + self.assertEqual(pin(key(-8, "main").blob(ORIGIN), "8737").returncode, 0) + self.assertEqual([(entry["id"], entry["alg"]) for entry in verifier.pinned(approvers)], [(key(-7, "main").id, -7), (key(-8, "main").id, -8)]) + self.assertEqual(stat.S_IMODE(approvers.stat().st_mode), 0o600) + before = approvers.read_bytes() + for blob, port, reason in ((key(-257, "main").blob(ORIGIN), "9999", "origin"), ("not-a-blob!", "8737", "base64url"), (b64(b"[1]"), "8737", "exactly")): + with self.subTest(reason=reason): + done = pin(blob, port) + self.assertEqual(done.returncode, 1, done.stdout) + self.assertIn(reason, done.stderr) + self.assertEqual(approvers.read_bytes(), before) + self.assertEqual(sorted(path.name for path in approvers.parent.iterdir()), ["approvers.json"]) + approvers.chmod(0o664) + with self.assertRaisesRegex(signing.Refused, "writable by others"): + verifier.pinned(approvers) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_asks.py b/tests/test_hub_asks.py new file mode 100644 index 0000000..f887238 --- /dev/null +++ b/tests/test_hub_asks.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +import socket +import subprocess +import sys +import threading +import time +import unittest +from collections.abc import Callable + +from hub_case import HubCase +from hub_support import ASK, CLIENT, HOOK, OPTIONS, Key, key + + +class TestHubAsks(HubCase): + def first_ask(self) -> dict: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + asks = self.snapshot()["asks"] + if asks: + return asks[0] + time.sleep(0.05) + self.fail("no ask arrived") + + def hook(self, port: int, timeout: str) -> subprocess.Popen: + trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "hook.json")] + command = [sys.executable, str(CLIENT), "--port", str(port), "--timeout", timeout, *trust] + process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + process.stdin.write(json.dumps(HOOK)) + process.stdin.close() + self.addCleanup(process.stdout.close) + self.addCleanup(process.stderr.close) + return process + + def finish(self, process: subprocess.Popen) -> tuple[int, str]: + process.wait(30) + return process.returncode, process.stdout.read() + + def test_asks_are_created_waited_on_and_decided(self): + self.claim("s1", "Skills session") + bad_headers = ( + {"Host": self.host}, + {**self.local(), "X-Kvb-Hub": "0"}, + {**self.local(), "Origin": "http://evil.example"}, + {**self.local(), "Origin": "null"}, + {"Host": "evil.example", "X-Kvb-Hub": "1"}, + ) + for headers in bad_headers: + with self.subTest(headers=headers): + self.assertEqual(self.post_json("/api/asks", ASK, headers)[0], 403) + for payload in ({**ASK, "extra": 1}, {k: v for k, v in ASK.items() if k != "cwd"}, {**ASK, "tool": "two\nlines"}, {**ASK, "timeout": -1}, [1]): + with self.subTest(payload=payload): + self.assertEqual(self.post_json("/api/asks", payload, self.local())[0], 400) + self.assertEqual(self.post_json("/api/asks", ASK, {**self.local(), "Origin": f"http://{self.host}"})[0], 201) + ask_id = self.create_ask(ASK) + asks = [row for row in self.snapshot()["asks"] if row["id"] == ask_id] + self.assertEqual([(row["tool"], row["command"], row["cwd"], row["agent"], row["session"]) for row in asks], [("Bash", "make deploy", "/repo", "Skills", "s1")]) + self.assertEqual(self.ask_status(ask_id, "0.1"), "pending") + results = [] + waiter = threading.Thread(target=lambda: results.append(self.ask_status(ask_id, "20"))) + waiter.start() + time.sleep(0.3) + for headers in ({"Host": self.host, "X-Kvb-Hub": "1"}, {"Host": self.host, "Origin": f"http://{self.host}"}, {**self.guard(self.host), "Origin": "http://evil.example"}): + with self.subTest(headers=headers): + self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", b"", headers)[0], 403) + signer = key(-7, "main") + stranger = self.create_ask({**ASK, "command": "make other"}) + for body in (b"", b'{"proof": null}', self.proven([self.entry("ask", stranger, "", "")], signer), b'{"proof": {"payload": {}, "assertion": {}}}'): + with self.subTest(body=body[:60]): + self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", body, self.guard(self.host))[0], 400) + self.assertEqual(self.ask_status(ask_id, "0"), "pending") + self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", self.proven([self.entry("ask", ask_id, "", "")], signer), self.guard(self.host))[0], 200) + waiter.join(10) + self.assertEqual(results, ["allowed"]) + self.assertEqual(self.get(f"/api/asks/{ask_id}?wait=0")[2].count(b'"assertion"'), 1) + self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/deny", b"", self.guard(self.host))[0], 409) + other = self.create_ask(ASK) + self.assertEqual(self.call("POST", f"/api/asks/{other}/deny", b"", self.guard(f"localhost:{self.port}"))[0], 200) + self.assertEqual(self.ask_status(other, "0"), "denied") + expired = self.create_ask({**ASK, "timeout": 0}) + self.assertEqual(self.ask_status(expired, "5"), "expired") + self.assertEqual(self.call("POST", f"/api/asks/{expired}/allow", b"", self.guard(self.host))[0], 409) + self.assertNotIn(expired, [row["id"] for row in self.snapshot()["asks"]]) + self.assertEqual(self.call("POST", "/api/asks/0123456789abcdef/allow", b"", self.guard(self.host))[0], 404) + self.assertEqual(self.get("/api/asks/0123456789abcdef?wait=0")[0], 404) + self.assertEqual(self.get(f"/api/asks/{ask_id}/allow")[0], 405) + self.assertEqual(self.get(f"/api/asks/{ask_id}?wait=soon")[0], 400) + + def test_the_hook_allows_only_a_valid_signature_and_denies_unsigned(self): + relayed = [] + + def asked(decide: Callable[[str], None]) -> tuple[int, str]: + process = self.hook(self.port, "20") + ask = self.first_ask() + self.assertEqual((ask["tool"], ask["command"], ask["cwd"], ask["session"]), ("Bash", "rm -rf node_modules", "/repo", "s1")) + decide(ask["id"]) + return self.finish(process) + + def deny(ask_id: str) -> None: + self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/deny", b"", self.guard(self.host))[0], 200) + + def allow_by(signer: Key) -> Callable[[str], None]: + def allow(ask_id: str) -> None: + body = self.proven([self.entry("ask", ask_id, "", "")], signer) + relayed.append(json.loads(body)["proof"]) + self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", body, self.guard(self.host))[0], 200) + + return allow + + def relay(proof: dict | None) -> Callable[[str], None]: + def compromised(ask_id: str) -> None: + self.httpd.hub.asks.decide(ask_id, {"status": "allowed", "proof": proof}) + + return compromised + + code, out = asked(deny) + self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "deny", "message": "The operator denied this in the kvb-hub."}}})) + self.assertEqual(asked(allow_by(key(-7, "main"))), (0, "")) + self.pin() + self.assertEqual(asked(allow_by(key(-8, "stranger"))), (0, "")) + self.assertEqual(asked(relay(None)), (0, "")) + code, out = asked(allow_by(key(-7, "main"))) + self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "allow"}}})) + self.assertEqual(asked(relay(relayed[-1])), (0, "")) + self.assertEqual(self.finish(self.hook(self.port, "0.3")), (0, "")) + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + closed = probe.getsockname()[1] + self.assertEqual(self.finish(self.hook(closed, "5")), (0, "")) + + def test_decisions_are_requested_chosen_and_waited_for(self): + for arguments in ( + {"question": "Merge?", "options": [], "agent": "Skills"}, + {"question": "Merge?", "options": [OPTIONS[0], OPTIONS[0]], "agent": "Skills"}, + {"question": "Merge?", "options": [{"label": "x"}], "agent": "Skills"}, + {"question": "Merge?", "options": [{**OPTIONS[0], "extra": 1}], "agent": "Skills"}, + {"question": "Merge?", "options": "Merge now", "agent": "Skills"}, + {"question": " ", "options": OPTIONS, "agent": "Skills"}, + {"question": "Merge?", "options": OPTIONS, "agent": "two\nlines"}, + {"question": "Merge?", "options": OPTIONS}, + {"question": "Merge?", "options": OPTIONS, "agent": "Skills"}, + {"question": "Merge?", "options": OPTIONS, "agent": "Skills", "recommended": "Later"}, + {"question": "Merge?", "options": OPTIONS, "agent": "Skills", "recommended": 5}, + ): + with self.subTest(arguments=arguments): + self.assertTrue(self.tool("decision_request", arguments)["isError"]) + result = self.tool("decision_request", {"question": "Merge the hub?", "options": OPTIONS, "agent": "Skills", "recommended": ""})["structuredContent"] + decision_id = result["id"] + self.assertEqual((result["status"], result["url"]), ("pending", f"http://localhost:{self.port}/")) + listed = self.snapshot()["decisions"] + self.assertEqual([(row["id"], row["question"], row["agent"], [o["label"] for o in row["options"]]) for row in listed], [(decision_id, "Merge the hub?", "Skills", ["Merge now", "Wait"])]) + self.assertEqual(self.tool("decision_wait", {"id": decision_id, "timeout_seconds": 0})["structuredContent"]["status"], "pending") + results = [] + waiter = threading.Thread(target=lambda: results.append(self.tool("decision_wait", {"id": decision_id, "timeout_seconds": 30}))) + waiter.start() + time.sleep(0.3) + path = f"/api/decisions/{decision_id}/choose" + self.assertEqual(self.post_json(path, {"label": "Merge now"}, {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403) + self.assertEqual(self.post_json(path, {"label": "Later"}, self.guard(self.host))[0], 400) + self.assertEqual(self.post_json(path, {"label": "Merge now", "x": 1}, self.guard(self.host))[0], 400) + self.assertEqual(self.post_json(path, {"label": "Merge now"}, self.guard(self.host))[0], 200) + waiter.join(10) + decided = results[0]["structuredContent"] + self.assertEqual((decided["status"], decided["choice"]), ("decided", "Merge now")) + self.assertIsNotNone(decided["decided"]) + self.assertEqual(self.post_json(path, {"label": "Wait"}, self.guard(self.host))[0], 409) + self.assertEqual(self.snapshot()["decisions"], []) + self.assertTrue(self.tool("decision_wait", {"id": "0123456789abcdef", "timeout_seconds": 0})["isError"]) + self.assertEqual(self.post_json("/api/decisions/0123456789abcdef/choose", {"label": "x"}, self.guard(self.host))[0], 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_board.py b/tests/test_hub_board.py new file mode 100644 index 0000000..94a8231 --- /dev/null +++ b/tests/test_hub_board.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import dataclasses +import http.server +import json +import threading +import time +import unittest +from unittest import mock +from urllib.parse import quote + +from hub_case import HubCase +from hub_support import ASK, HOST_STATE, iso, server +from kvb_hub import handler, messaging, skill_scripts, transcripts + + +class TestHubBoard(HubCase): + def test_uploads_are_sanitised_and_bounded(self): + headers = {**self.guard(self.host), "Content-Type": "application/octet-stream"} + status, _, body = self.call("POST", f"/api/uploads?name={quote('../../évil name.sh')}", b"data", headers) + self.assertEqual(status, 201, body) + upload = json.loads(body) + self.assertEqual(upload["name"], "_vil_name.sh") + self.assertEqual(upload["path"], str(HOST_STATE / "uploads" / upload["id"] / "_vil_name.sh")) + self.assertEqual((self.state / "uploads" / upload["id"] / "_vil_name.sh").read_bytes(), b"data") + for name, expected in (("..", "upload"), (".bashrc", "bashrc"), ("a\\b/c.txt", "c.txt"), ("x" * 300, "x" * 120)): + with self.subTest(name=name): + status, _, body = self.call("POST", f"/api/uploads?name={quote(name)}", b"1", headers) + self.assertEqual((status, json.loads(body)["name"]), (201, expected)) + with mock.patch.object(handler, "UPLOAD_LIMIT", 8): + self.assertEqual(self.call("POST", "/api/uploads?name=big", b"123456789", headers)[0], 413) + self.assertEqual(self.call("POST", "/api/uploads?name=fits", b"12345678", headers)[0], 201) + self.assertEqual(self.call("POST", "/api/uploads?name=x", b"1", {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403) + self.assertEqual(self.get("/api/uploads")[0], 405) + + def test_operator_messages_go_by_radio_and_verify_over_mcp(self): + self.claim("s1", "Skills session") + headers = {**self.guard(self.host), "Content-Type": "application/octet-stream"} + upload = json.loads(self.call("POST", "/api/uploads?name=plan.md", b"# plan", headers)[2]) + status, _, body = self.post_json("/api/messages", {"to": "Skills", "text": "please\n rebase", "upload": upload["id"]}, self.guard(self.host)) + self.assertEqual(status, 201, body) + sent = json.loads(body) + self.assertTrue(sent["ok"]) + self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Skills session", "--session", "Operator", f"[kvb-hub msg {sent['id']}] please rebase file: {upload['path']}"])]) + verified = self.tool("operator_message", {"id": sent["id"]})["structuredContent"] + self.assertEqual((verified["to"], verified["text"], verified["upload"]), ("Skills", "please\n rebase", upload["path"])) + self.assertEqual([(row["id"], row["to"], row["upload"]) for row in self.snapshot()["messages"]], [(sent["id"], "Skills", "plan.md")]) + self.assertTrue(self.tool("operator_message", {"id": "0123456789abcdef"})["isError"]) + self.assertTrue(self.tool("operator_message", {"id": "../x"})["isError"]) + for payload in ( + {"to": "Skills", "text": " ", "upload": ""}, + {"to": "Skills", "text": "hi", "upload": "0123456789abcdef"}, + {"to": "Skills", "text": "hi", "upload": "../../etc"}, + {"to": "two\nlines", "text": "hi", "upload": ""}, + {"to": "Skills", "text": "hi"}, + ): + with self.subTest(payload=payload): + self.assertEqual(self.post_json("/api/messages", payload, self.guard(self.host))[0], 400) + self.assertEqual(self.post_json("/api/messages", {"to": "Skills", "text": "hi", "upload": ""}, {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403) + self.assertEqual(len(self.scripts.calls), 1) + + def test_board_actions_run_the_skill_scripts(self): + self.claim("s1", "Skills session") + self.claim("s2", "Gatekeeper") + now = time.time() + self.write_council({ + "radio-silence.json": {"since": now, "until": now + 60, "by": "Docs", "text": "disk full"}, + "night.json": {"mode": "evening", "active": "Operator", "awake": []}, + "rasci.json": {"work/a": {"worker": "A", "status": "running", "prio": 2}, "work/b": {"worker": "B session", "status": "queued", "prio": 1}, "work/c": {"worker": "C", "status": "done"}}, + }) + state = self.snapshot() + self.assertEqual((state["silence"]["by"], state["night"], state["priority"]), ("Docs", "evening", ["B session", "A"])) + + def board(action: str, payload: object) -> tuple[int, dict | None]: + status, _, body = self.post_json(f"/api/board/{action}", payload, self.guard(self.host)) + return status, json.loads(body) if status == 200 else None + + status, result = board("clear-mayday", {}) + self.assertEqual((status, result["ok"], result["runs"][0]["stdout"]), (200, True, "done")) + self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])) + status, result = board("broadcast", {"text": "main is green"}) + message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"]})["structuredContent"]["id"] + self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-hub msg {message_id}] main is green"])) + self.assertEqual(board("order", {"agents": ["A", "B session"]})[0], 200) + self.assertEqual(self.scripts.calls[-1], (skill_scripts.RASCI, ["order", "A,B session"])) + for payload in ({"agents": ["A,B"]}, {"agents": ["A", "A"]}, {"agents": []}, {"agents": ["-x"]}, {"agents": "A"}, {"text": "x"}): + with self.subTest(payload=payload): + self.assertEqual(board("order", payload)[0], 400) + calls = len(self.scripts.calls) + self.assertEqual(board("evening", {})[0], 200) + self.assertEqual(self.scripts.calls[calls:], [ + (skill_scripts.NIGHT, ["set", "evening", "--session", "Operator"]), + (skill_scripts.RADIO, ["send", "direct", "--wake", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]), + ]) + self.assertEqual(board("sunrise", {})[0], 200) + self.assertEqual(self.scripts.calls[-2][1], ["clear", "--session", "Operator"]) + self.scripts.code = 1 + calls = len(self.scripts.calls) + status, result = board("sunset", {}) + self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["set", "sunset", "--session", "Operator"])])) + self.assertEqual(board("reboot", {})[0], 404) + self.assertEqual(board("broadcast", {"text": " "})[0], 400) + self.assertEqual(self.post_json("/api/board/sunset", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403) + self.assertEqual(self.get("/api/board/sunset")[0], 405) + + def test_nl_actions_are_validated_against_a_faked_endpoint(self): + status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host)) + self.assertEqual(status, 400, body) + self.assertIn(b"KVB_HUB_LLM_URL", body) + seen: list[tuple[str, str | None, dict]] = [] + replies: list[str] = [] + + class Fake(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: + data = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + seen.append((self.path, self.headers.get("Authorization"), data)) + answer = json.dumps({"choices": [{"message": {"content": replies[-1]}}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(answer))) + self.end_headers() + self.wfile.write(answer) + + def log_message(self, *args: object) -> None: + return + + fake = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fake) + threading.Thread(target=fake.serve_forever, args=(0.05,), daemon=True).start() + self.addCleanup(fake.server_close) + self.addCleanup(fake.shutdown) + self.httpd.hub = dataclasses.replace(self.httpd.hub, model=server.Model(url=f"http://127.0.0.1:{fake.server_address[1]}/v1", model="m", key="k")) + self.claim("s1", "Skills session") + transcript = self.projects / "-repo" / "s1.jsonl" + transcript.parent.mkdir(parents=True) + transcript.write_text(self.jsonl([{"type": "user", "sessionId": "s1", "timestamp": iso(time.time()), "message": {"role": "user", "content": "SECRET-TRANSCRIPT"}}]), encoding="utf-8") + with mock.patch.object(transcripts, "STREAM_SCAN", 0): + self.assertEqual(len(self.events("kinds=transcript")["entries"]), 1) + review_id = self.request("feat: nl", "") + ask_id = self.create_ask(ASK) + decision_id = self.decision("Merge now") + replies.append(json.dumps({"actions": [ + {"name": "approve", "arguments": {"ids": [review_id, decision_id]}}, + {"name": "choose", "arguments": {"id": decision_id, "label": "Wait"}}, + {"name": "message", "arguments": {"to": "Skills", "text": "rebase"}}, + {"name": "view", "arguments": {"name": "crons"}}, + {"name": "snooze", "arguments": {"ids": [ask_id], "hours": 2}}, + {"name": "approve", "arguments": {"ids": ["ffffffffffffffff"]}}, + {"name": "reject", "arguments": {"ids": [decision_id]}}, + {"name": "message", "arguments": {"to": "Nobody", "text": "x"}}, + {"name": "shutdown", "arguments": {}}, + {"name": "snooze", "arguments": {"ids": [ask_id], "hours": 500}}, + {"name": "view", "arguments": {"name": "crons", "extra": 1}}, + ]})) + status, _, body = self.post_json("/api/nl", {"text": "gib alles Grüne frei"}, self.guard(self.host)) + self.assertEqual(status, 200, body) + result = json.loads(body) + self.assertEqual([action["name"] for action in result["actions"]], ["approve", "choose", "message", "view", "snooze"]) + self.assertEqual(result["actions"][0]["args"]["items"], [self.entry("review", review_id, "", ""), self.entry("decision", decision_id, "Merge now", "")]) + self.assertEqual(result["actions"][4]["args"], {"keys": [{"kind": "ask", "id": ask_id}], "hours": 2}) + self.assertEqual(len(result["rejected"]), 6) + path, key, sent = seen[-1] + self.assertEqual((path, key, sent["model"]), ("/v1/chat/completions", "Bearer k", "m")) + self.assertNotIn("SECRET-TRANSCRIPT", json.dumps(sent)) + self.assertIn(review_id, sent["messages"][1]["content"]) + self.assertIn("approve", sent["messages"][0]["content"]) + replies.append("not json") + self.assertEqual(self.post_json("/api/nl", {"text": "x"}, self.guard(self.host))[0], 400) + self.assertEqual(self.post_json("/api/nl", {"text": "x"}, self.client())[0], 403) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_executors.py b/tests/test_hub_executors.py new file mode 100644 index 0000000..03352ff --- /dev/null +++ b/tests/test_hub_executors.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import sys +import time +import unittest +from unittest import mock + +import hub_support +from hub_case import HubCase +from hub_support import ( + APPROVAL, + ASK, + GIT_ENV, + PAGE, + PRESENT, + RUNNER, + TREE, + runner, + signing, + verifier, +) +from kvb_hub import batches, rules, skill_scripts + +# An import named key trips F402: test_rules_are_signed_once_and_checked_by_the_executor rebinds it in a comprehension. +key = hub_support.key + + +class TestHubExecutors(HubCase): + def trust(self, name: str) -> verifier.Trust: + return verifier.Trust(approvers=self.approvers, replay=self.replay / f"{name}.json", origin=self.origin) + + def runner_jobs(self) -> list[dict]: + status, _, body = self.call("GET", "/api/runner/jobs", None, self.client()) + self.assertEqual(status, 200, body) + return json.loads(body)["jobs"] + + def run_runner(self) -> None: + trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")] + command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, "--once"] + done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + + def test_the_runner_runs_only_signed_commands_once(self): + command = "echo hello && echo world >&2" + register = { + "operator/greet": {"title": command, "workdir": str(self.outside), "description": "say hi", "check": "manual", "status": "queued", "roles": {}}, + "operator/decide": {"title": "pick a name", "workdir": str(self.outside), "check": "decision", "status": "queued", "roles": {}}, + } + self.write_council({"rasci.json": register}) + items = {item["id"]: item for item in self.snapshot()["inbox"]} + greet = items["operator/greet"] + digest = hashlib.sha256(command.encode()).hexdigest() + self.assertEqual((greet["runnable"], greet["sha256"], items["operator/decide"]["runnable"]), (True, digest, False)) + self.assertEqual(self.approve([self.entry("operator", "operator/greet", "", "0" * 64)], None)[0], 409) + self.assertEqual(self.approve([self.entry("operator", "operator/decide", "", "")], None)[0], 400) + self.assertEqual(self.approve([self.entry("operator", "operator/greet", "", digest)], None)[0], 400) + self.assertEqual(self.call("GET", "/api/runner/jobs", None, {"Host": self.host})[0], 403) + self.assertEqual(self.call("GET", "/api/runner/jobs", None, self.guard(self.host))[0], 403) + signer = self.pin() + greeting = [self.entry("operator", "operator/greet", "", digest)] + with mock.patch.object(batches, "UNDO", 0.05): + self.assertEqual(self.approve(greeting, self.signed(greeting, signer))[0], 202) + self.settle(lambda: self.runner_jobs() != []) + queued = self.runner_jobs() + self.assertEqual([(job["task"], job["command"], job["directory"], job["sha256"]) for job in queued], [("operator/greet", command, str(self.outside), digest)]) + proof = queued[0]["proof"] + self.assertIn(signing.item_hash(signing.operator_item("operator/greet", command, str(self.outside))), proof["payload"]["items"]) + self.assertNotIn("operator/greet", [item["id"] for item in self.snapshot()["inbox"]]) + self.run_runner() + job = self.snapshot()["jobs"][-1] + self.assertEqual((job["status"], job["code"], job["output"]), ("done", 0, "hello\nworld\n")) + self.assertNotIn("proof", job) + self.assertEqual(self.scripts.calls[-1], (skill_scripts.OPERATOR_SCRIPT, ["done", "greet"])) + claim = {**self.client(), "Content-Type": "application/json"} + self.assertEqual(self.call("POST", f"/api/runner/{job['id']}/claim", b"{}", claim)[0], 409) + self.assertEqual(self.call("POST", f"/api/runner/{job['id']}/claim", b"{}", {**self.guard(self.host), "Content-Type": "application/json"})[0], 403) + stranger = key(-8, "stranger").proof(proof["payload"], self.origin) + forged = ( + ("echo evil", str(self.outside), proof, "does not list this item"), + (command, "/", proof, "does not list this item"), + (command, str(self.outside), proof, "already used"), + (command, str(self.outside), None, "no signature"), + (command, str(self.outside), stranger, "not pinned"), + ) + for text, directory, relayed, reason in forged: + with self.subTest(command=text, directory=directory, reason=reason): + fields = {"task": "operator/greet", "command": text, "directory": directory, "sha256": digest, "status": "queued", "proof": relayed} + tampered = self.httpd.hub.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None}) + self.run_runner() + record = self.httpd.hub.jobs.get(tampered["id"]) + self.assertEqual(record["status"], "refused") + self.assertTrue(record["output"].startswith(runner.REFUSED), record["output"]) + self.assertIn(reason, record["output"]) + self.assertEqual(len([call for call in self.scripts.calls if call[0] == skill_scripts.OPERATOR_SCRIPT]), 1) + self.approvers.unlink() + job = {"task": "operator/greet", "command": command, "directory": str(self.outside), "proof": proof} + self.assertIn("no key is pinned", runner.refusal(job, self.trust("other"), time.time())) + reports = [] + self.assertEqual(runner.execute({"command": "exit 3", "directory": str(self.outside)}, reports.append), 3) + self.assertEqual(reports[-1], {"output": "", "code": 3, "refused": False}) + + def test_rules_are_signed_once_and_checked_by_the_executor(self): + now = time.time() + rule = {"kind": "ask", "tool": "Bash", "pattern": "make *", "scope": "/repo*", "expires": now + 3600} + for payload in ( + {**rule, "expires": now - 1}, + {**rule, "expires": now + 400 * 86400}, + {**rule, "tool": ""}, + {**rule, "kind": "review"}, + {**rule, "pattern": " "}, + {**rule, "scope": "two\nlines"}, + {key: value for key, value in rule.items() if key != "scope"}, + ): + with self.subTest(payload=payload): + self.assertEqual(self.post_json("/api/rules/sign", payload, self.guard(self.host))[0], 400) + self.assertEqual(self.post_json("/api/rules/sign", rule, self.client())[0], 403) + signer = self.pin() + + def prepared(fields: dict) -> dict: + status, _, body = self.post_json("/api/rules/sign", fields, self.guard(self.host)) + self.assertEqual(status, 200, body) + return json.loads(body) + + def created(payload: dict, assertion: dict | None) -> tuple[int, dict, bytes]: + return self.post_json("/api/rules", {"rule": payload, "assertion": assertion, "enabled": True}, self.guard(self.host)) + + ask_rule = prepared(rule) + self.assertEqual((ask_rule["sign"], ask_rule["challenge"], ask_rule["credentials"]), (True, signing.challenge(ask_rule["payload"]), [signer.id])) + signed = signer.assertion(ask_rule["payload"], self.origin, "localhost", PRESENT, 0) + for payload, assertion in ( + (ask_rule["payload"], None), + ({**ask_rule["payload"], "kinds": ["ask", "review"]}, signed), + ({**ask_rule["payload"], "expires": now + 400 * 86400}, signed), + ({**ask_rule["payload"], "id": "x"}, signed), + ): + with self.subTest(payload=payload): + self.assertEqual(created(payload, assertion)[0], 400) + self.assertEqual(self.post_json("/api/rules", {"rule": ask_rule["payload"], "assertion": signed, "enabled": True}, self.client())[0], 403) + asks = json.loads(created(ask_rule["payload"], signed)[2]) + self.assertEqual(created(ask_rule["payload"], signed)[0], 409) + review_rule = prepared({"kind": "review", "tool": "", "pattern": "feature/*", "scope": "/repo/*", "expires": now + 3600}) + self.assertEqual(created(review_rule["payload"], signer.assertion(review_rule["payload"], self.origin, "localhost", PRESENT, 0))[0], 201) + decision_rule = prepared({"kind": "decision", "tool": "", "pattern": "*", "scope": "*", "expires": now + 3600}) + self.assertFalse(decision_rule["sign"]) + self.assertEqual(created(decision_rule["payload"], signed)[0], 400) + self.assertEqual(created(decision_rule["payload"], None)[0], 201) + forged_rule = prepared({**rule, "pattern": "cargo *"}) + self.assertEqual(created(forged_rule["payload"], key(-8, "stranger").assertion(forged_rule["payload"], self.origin, "localhost", PRESENT, 0))[0], 201) + self.assertEqual(sorted((entry["kind"], entry["signed"]) for entry in self.snapshot()["rules"]), [("ask", True), ("ask", True), ("decision", False), ("review", True)]) + with mock.patch.object(batches, "UNDO", 0.05): + matched = self.create_ask(ASK) + risky = self.create_ask({**ASK, "command": "make deploy && git push"}) + other = self.create_ask({**ASK, "tool": "Edit"}) + outside = self.create_ask({**ASK, "cwd": "/elsewhere"}) + cargo = self.create_ask({**ASK, "command": "cargo build"}) + green = self.request("feat: auto", "") + red = self.tool("review_request", {**self.arguments("feat: red", "", self.page("red.html", PAGE)), "gate": "fail"})["structuredContent"]["id"] + chosen = self.decision("Wait") + plain = self.decision("") + self.settle(lambda: self.ask_status(matched, "0") == "allowed") + self.settle(lambda: self.ask_status(cargo, "0") == "allowed") + self.settle(lambda: self.status(green) == "approved") + self.settle(lambda: self.choice(chosen) == "Wait") + time.sleep(0.3) + self.assertEqual([self.ask_status(ask, "0") for ask in (risky, other, outside)], ["pending", "pending", "pending"]) + self.assertEqual((self.status(red), self.choice(plain)), ("pending", None)) + self.assertEqual(len(self.events("kinds=rule")["events"]), 4) + hook = self.trust("hook") + verifier.check(json.loads(self.get(f"/api/asks/{matched}?wait=0")[2])["proof"], signing.ask_item(matched, "Bash", "make deploy", "/repo", "s1"), "", hook, time.time()) + with self.assertRaisesRegex(signing.Refused, "not pinned"): + verifier.check(json.loads(self.get(f"/api/asks/{cargo}?wait=0")[2])["proof"], signing.ask_item(cargo, "Bash", "cargo build", "/repo", "s1"), "", hook, time.time()) + relayed = json.loads(self.get(f"/api/reviews/{green}")[2])["proof"] + commit = signing.review_item(green, "/repo/.git", "/repo/.worktrees/feature/x", "feature/x", TREE) + verifier.check(relayed, commit, "pass", self.trust("commit"), time.time()) + with self.assertRaisesRegex(signing.Refused, "does not match"): + verifier.check(relayed, commit, "fail", self.trust("commit"), time.time()) + self.assertEqual(self.call("POST", f"/api/rules/{asks['id']}/disable", b"", self.guard(self.host))[0], 200) + with mock.patch.object(batches, "UNDO", 0.05): + later = self.create_ask(ASK) + time.sleep(0.3) + self.assertEqual(self.ask_status(later, "0"), "pending") + self.assertEqual(self.call("POST", f"/api/rules/{asks['id']}/delete", b"", self.guard(self.host))[0], 200) + self.assertEqual(self.call("POST", f"/api/rules/{asks['id']}/delete", b"", self.guard(self.host))[0], 404) + stored = {**asks, "enabled": True} + subject = {"item": signing.ask_item(later, "Bash", "make deploy", "/repo", "s1"), "gate": ""} + self.assertTrue(rules.rule_matches(stored, "ask", subject, now)) + self.assertFalse(rules.rule_matches({**stored, "expires": now - 1}, "ask", subject, now)) + self.assertFalse(rules.rule_matches({**stored, "proof": None}, "ask", subject, now)) + self.assertFalse(rules.rule_matches(stored, "ask", subject, stored["proof"]["payload"]["expires"] + 1)) + + def test_the_commit_check_verifies_the_staged_tree_before_a_commit(self): + repo = self.outside / "repo" + repo.mkdir() + self.git(repo, "init", "--quiet", "--initial-branch", "feature/x") + (repo / "a.txt").write_text("one\n", encoding="utf-8") + self.git(repo, "add", "a.txt") + tree = self.git(repo, "write-tree").strip() + common = self.git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir").strip() + top = self.git(repo, "rev-parse", "--show-toplevel").strip() + arguments = {**self.arguments("feat: signed", "", self.page("signed.html", PAGE)), "repo": common, "worktree": top, "tree": tree} + review_id = self.tool("review_request", arguments)["structuredContent"]["id"] + + def verify(gate: str) -> subprocess.CompletedProcess: + argv = ["verify", "--port", str(self.port), "--approvers", str(self.approvers), "--replay", str(self.replay / "commit.json"), "--review", review_id, "--gate", gate] + return subprocess.run([sys.executable, str(APPROVAL), *argv], cwd=repo, env={**os.environ, **GIT_ENV}, capture_output=True, text=True, timeout=60, check=False) + + done = verify("pass") + self.assertEqual(done.returncode, 1, done.stdout) + self.assertIn("the review is pending", done.stderr) + signer = self.pin() + entries = [self.entry("review", review_id, "", "")] + with mock.patch.object(batches, "UNDO", 0.05): + self.assertEqual(self.approve(entries, self.signed(entries, signer))[0], 202) + self.settle(lambda: self.status(review_id) == "approved") + (repo / "a.txt").write_text("two\n", encoding="utf-8") + self.git(repo, "add", "a.txt") + done = verify("pass") + self.assertEqual(done.returncode, 1, done.stdout) + self.assertIn("does not list this item", done.stderr) + (repo / "a.txt").write_text("one\n", encoding="utf-8") + self.git(repo, "add", "a.txt") + done = verify("pass") + self.assertEqual(done.returncode, 0, done.stderr) + self.assertIn(f"tree {tree} on feature/x", done.stdout) + done = verify("pass") + self.assertEqual(done.returncode, 1, done.stdout) + self.assertIn("already used", done.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_graph.py b/tests/test_hub_graph.py new file mode 100644 index 0000000..8b42ac0 --- /dev/null +++ b/tests/test_hub_graph.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import json +import math +import os +import time +import unittest +from unittest import mock + +from hub_case import HubCase +from hub_support import iso +from kvb_hub import lifetimes, names, orbits + + +class TestHubGraph(HubCase): + def test_affinity_pulls_talking_agents_together(self): + now = 1_000_000.0 + nodes = [{"id": name, "kind": kind, "parent": None} for name, kind in (("Operator", "operator"), ("Maintainer", "chair"), ("Alpha", "session"), ("Beta", "session"), ("Gamma", "session"))] + talk = [{"ts": now - n * 60, "source": "Alpha" if n % 2 else "Beta", "targets": ["Beta" if n % 2 else "Alpha"], "class": "direct", "text": "x"} for n in range(20)] + + def spot(layout: dict, name: str) -> tuple[float, float]: + place = layout["orbit"][name] + return place["radius"] * math.cos(place["angle"]), place["radius"] * math.sin(place["angle"]) + + layout = orbits.affinity(nodes, talk, {}, [], now, str) + self.assertEqual(layout, orbits.affinity(nodes, talk, {}, [], now, str)) + self.assertEqual(set(layout["order"]), {"Maintainer", "Alpha", "Beta", "Gamma"}) + self.assertLess(math.dist(spot(layout, "Alpha"), spot(layout, "Beta")), math.dist(spot(layout, "Alpha"), spot(layout, "Gamma"))) + self.assertLess(math.dist(spot(layout, "Alpha"), spot(layout, "Beta")), math.dist(spot(layout, "Beta"), spot(layout, "Gamma"))) + self.assertLess(layout["orbit"]["Alpha"]["radius"], layout["orbit"]["Gamma"]["radius"]) + self.assertEqual(layout["orbit"]["Gamma"]["radius"], orbits.OUTER) + old = [{**row, "ts": now - 86400} for row in talk] + fresh = [{"ts": now - 60, "source": "Gamma", "targets": ["Beta"], "class": "direct", "text": "x"}] * 3 + later = orbits.affinity(nodes, old + fresh, {}, [], now, str) + self.assertLess(math.dist(spot(later, "Beta"), spot(later, "Gamma")), math.dist(spot(later, "Alpha"), spot(later, "Beta"))) + shared = orbits.affinity(nodes, [], {"work/x": {"status": "running", "worker": "Alpha", "roles": {"Gamma": "C"}}}, [], now, str) + self.assertLess(math.dist(spot(shared, "Alpha"), spot(shared, "Gamma")), math.dist(spot(shared, "Alpha"), spot(shared, "Beta"))) + self.assertIn("layout", self.snapshot()["graph"]) + + def test_the_track_samples_the_affinity_over_the_window_on_request(self): + now = time.time() + news = [{"ts": now - 3000 + n * 60, "from": "Beta" if n % 2 else "Alpha", "to": ["Alpha" if n % 2 else "Beta"], "text": "x"} for n in range(30)] + news.append({"ts": now - 100, "from": "Gamma", "to": [], "text": "here"}) + self.write_council({"news.jsonl": self.jsonl(news)}) + self.assertEqual(self.snapshot()["graph"]["track"], []) + status, _, body = self.get(f"/api/state?from={int(now - 3600)}&track=1") + self.assertEqual(status, 200, body) + state = json.loads(body) + graph, track = state["graph"], state["graph"]["track"] + stamps = [sample["ts"] for sample in track] + self.assertEqual(len(track), orbits.TRACK_STEPS + 1) + self.assertEqual((stamps[0], stamps[-1]), (state["first"], state["until"])) + self.assertEqual(stamps, sorted(stamps)) + self.assertEqual(track[-1]["orbit"], graph["layout"]["orbit"]) + for sample in track: + self.assertEqual(set(sample["orbit"]), set(graph["layout"]["order"])) + keys = {node["label"]: node["id"] for node in graph["nodes"]} + self.assertEqual([sample["orbit"][keys["Gamma"]]["radius"] for sample in track[:-1]], [orbits.OUTER] * orbits.TRACK_STEPS) + self.assertLess(track[0]["orbit"][keys["Alpha"]]["radius"], orbits.OUTER) + self.assertLess(track[-1]["orbit"][keys["Alpha"]]["radius"], track[-1]["orbit"][keys["Gamma"]]["radius"]) + for broken in ("0", "yes", "1&track=1"): + with self.subTest(broken=broken): + self.assertEqual(self.get(f"/api/state?track={broken}")[0], 400) + + def test_a_rename_keeps_the_session_and_shows_the_name_of_its_moment(self): + now = time.time() + before = {"ts": now - 100, "from": "Skills", "to": ["Docs"], "text": "before"} + claim = {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills", "since": iso(now - 200), "beat": now, "work": "x"} + self.write_council({"work/s1/claim.json": claim, "news.jsonl": self.jsonl([before])}) + self.assertEqual([row["agent"] for row in self.events("kinds=radio")["events"]], ["Skills"]) + folder = self.projects / "-repo" / "s1" + folder.mkdir(parents=True) + title = folder / "custom-title.json" + title.write_text(json.dumps({"customTitle": "IAM"}), encoding="utf-8") + os.utime(title, (now - 50, now - 50)) + self.write_council({"news.jsonl": self.jsonl([before, {"ts": now - 20, "from": "IAM", "to": ["Docs"], "text": "after"}])}) + with mock.patch.object(names, "TITLE_SCAN", 0): + rows = self.events("kinds=radio,rename")["events"] + self.assertEqual([(row["kind"], row["session"], row["agent"], row["now"]) for row in rows], [ + ("radio", "s1", "Skills", "IAM"), + ("rename", "s1", "IAM", "IAM"), + ("radio", "s1", "IAM", "IAM"), + ]) + self.assertEqual(rows[1]["title"], "Skills heißt jetzt IAM") + self.assertAlmostEqual(rows[1]["ts"], now - 50, places=3) + for name in ("Skills", "IAM"): + with self.subTest(name=name): + self.assertEqual([row["text"] for row in self.events(f"kinds=radio&agent={name}")["events"]], ["before", "after"]) + snapshot = self.snapshot() + self.assertEqual([row["speech"] for row in snapshot["radio"]], ["Docs, Docs, this is Skills, before, over", "Docs, Docs, this is IAM, after, over"]) + self.assertEqual({row["voice"] for row in snapshot["radio"]}, {self.httpd.hub.speech.voice_of("s1")}) + live = {node["id"]: node for node in snapshot["graph"]["nodes"]} + self.assertEqual((live["s1"]["label"], live["s1"]["names"]), ("IAM", ["Skills", "IAM"])) + self.assertEqual([entry["name"] for entry in live["s1"]["timeline"]], ["Skills", "IAM"]) + for entry, since in zip(live["s1"]["timeline"], (now - 200, now - 50), strict=True): + self.assertAlmostEqual(entry["since"], since, places=3) + self.assertEqual(live["Operator"]["timeline"], [{"name": "Operator", "since": snapshot["until"]}]) + status, _, body = self.get(f"/api/state?from={int(now - 300)}&to={int(now - 80)}") + self.assertEqual(status, 200, body) + replay = {node["id"]: node for node in json.loads(body)["graph"]["nodes"]} + self.assertEqual(replay["s1"]["label"], "Skills") + self.assertEqual(replay["s1"]["timeline"], live["s1"]["timeline"]) + stored = json.loads((self.state / "names.json").read_text(encoding="utf-8"))["entries"] + self.assertEqual([(entry["session"], entry["name"], entry["source"]) for entry in stored], [("s1", "Skills", "claim"), ("s1", "IAM", "title")]) + + def test_birth_and_death_dates_follow_the_transcripts(self): + self.assertEqual((lifetimes.lifetime_stamp(1_700_000_000.0), lifetimes.lifetime_stamp(None)), ("2023-11-14T22:13Z", "")) + now = time.time() + self.claim("s1", "Skills") + main = self.projects / "-repo" / "s1.jsonl" + subagents = self.projects / "-repo" / "s1" / "subagents" + subagents.mkdir(parents=True) + + def line(ts: float) -> dict: + return {"type": "user", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "user", "content": "x"}} + + main.write_text(self.jsonl([line(now - 3 * 86400), line(now - 2 * 86400)]), encoding="utf-8") + for ident, finished in (("abc", True), ("def", False)): + (subagents / f"agent-{ident}.meta.json").write_text(json.dumps({"agentType": "Explore", "description": ident, "spawnDepth": 1, "finished": finished}), encoding="utf-8") + (subagents / f"agent-{ident}.jsonl").write_text(self.jsonl([line(now - 3 * 86400 + 60), line(now - 3 * 86400 + 120)]), encoding="utf-8") + + def life() -> dict: + status, _, body = self.get("/api/subagents?session=s1") + self.assertEqual(status, 200, body) + return json.loads(body) + + born, ended = lifetimes.lifetime_stamp(now - 3 * 86400), lifetimes.lifetime_stamp(now - 2 * 86400) + sub_born, sub_died = lifetimes.lifetime_stamp(now - 3 * 86400 + 60), lifetimes.lifetime_stamp(now - 3 * 86400 + 120) + data = life() + self.assertEqual((data["born"], data["died"], data["until"]), (born, "", None)) + self.assertAlmostEqual(data["since"], now - 3 * 86400, places=3) + self.assertEqual([(row["id"], row["born"], row["died"]) for row in data["subagents"]], [("abc", sub_born, sub_died), ("def", sub_born, "")]) + self.assertAlmostEqual(data["subagents"][0]["until"], now - 3 * 86400 + 120, places=3) + self.assertEqual(data["subagents"][1]["until"], None) + for row in data["subagents"]: + self.assertAlmostEqual(row["since"], now - 3 * 86400 + 60, places=3) + os.utime(main, (now - 2 * 86400, now - 2 * 86400)) + self.assertEqual(life()["died"], "") + self.write_council({"work/s1/claim.json": {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills", "since": iso(now - 4 * 86400), "beat": now - 2 * 86400, "work": "x"}}) + self.assertEqual((life()["born"], life()["died"]), (born, ended)) + self.assertAlmostEqual(life()["until"], now - 2 * 86400, places=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_inbox.py b/tests/test_hub_inbox.py new file mode 100644 index 0000000..76ef08b --- /dev/null +++ b/tests/test_hub_inbox.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import time +import unittest +from unittest import mock + +from hub_case import HubCase +from hub_support import ASK, PAGE, TREE, key, signing +from kvb_hub import batches + + +class TestHubInbox(HubCase): + def test_inbox_bulk_approval_waits_for_the_undo_window(self): + green = self.request("feat: green", "") + red = self.tool("review_request", {**self.arguments("feat: red", "", self.page("red.html", PAGE)), "gate": "fail"})["structuredContent"]["id"] + ask_id = self.create_ask(ASK) + risky = self.create_ask({**ASK, "command": "rm -rf build"}) + decision_id = self.decision("Merge now") + inbox = {item["id"]: item for item in self.snapshot()["inbox"]} + self.assertEqual([inbox[key]["green"] for key in (green, red, ask_id, risky, decision_id)], [True, False, True, False, True]) + self.assertEqual(inbox[risky]["warnings"], ["löscht rekursiv"]) + batch = [self.entry("review", green, "", ""), self.entry("ask", ask_id, "", ""), self.entry("decision", decision_id, "Merge now", "")] + signer = key(-7, "main") + self.assertEqual(self.approve([self.entry("review", green, "", ""), self.entry("review", red, "", "")], None)[0], 400) + self.assertEqual(self.approve([self.entry("decision", decision_id, "Later", "")], None)[0], 400) + self.assertEqual(self.approve([self.entry("review", green, "", ""), self.entry("review", green, "", "")], None)[0], 400) + self.assertEqual(self.approve([self.entry("review", "0123456789abcdef", "", "")], None)[0], 404) + self.assertEqual(self.post_json("/api/inbox/approve", {"items": batch, "proof": None}, self.client())[0], 403) + prepared = self.prepare(batch) + listed = [signing.item_hash(signing.review_item(green, "/repo/.git", "/repo/.worktrees/feature/x", "feature/x", TREE)), signing.item_hash(signing.ask_item(ask_id, "Bash", "make deploy", "/repo", "s1"))] + self.assertEqual((prepared["payload"]["items"], prepared["challenge"]), (sorted(listed), signing.challenge(prepared["payload"]))) + unsigned = [self.entry("decision", decision_id, "Merge now", ""), {**self.entry("ask", risky, "", ""), "verb": "reject"}] + self.assertIsNone(self.prepare(unsigned)["payload"]) + partial = signer.proof(self.prepare(batch[:1])["payload"], self.origin) + for proof in (None, partial, {"payload": prepared["payload"]}, {"payload": {**prepared["payload"], "expires": int(time.time()) - 1}, "assertion": {}}): + with self.subTest(proof=str(proof)[:80]): + self.assertEqual(self.approve(batch, proof)[0], 400) + self.assertEqual(self.approve(unsigned, partial)[0], 400) + proof = signer.proof(prepared["payload"], self.origin) + with mock.patch.object(batches, "UNDO", 0.5): + status, _, body = self.approve(batch, proof) + self.assertEqual(status, 202, body) + first = json.loads(body) + self.assertNotIn("proof", first) + self.assertEqual(self.approve([batch[0]], None)[0], 409) + self.assertTrue(all(item["held"] for item in self.snapshot()["inbox"] if item["id"] in (green, ask_id, decision_id))) + self.assertEqual(self.call("POST", f"/api/inbox/undo/{first['id']}", b"", self.guard(self.host))[0], 200) + time.sleep(0.8) + self.assertEqual((self.status(green), self.ask_status(ask_id, "0"), self.choice(decision_id)), ("pending", "pending", None)) + self.assertIsNone(json.loads(self.get(f"/api/reviews/{green}")[2])["proof"]) + second = json.loads(self.approve(batch, proof)[2]) + self.assertEqual(self.status(green), "pending") + self.assertIsNone(json.loads(self.get(f"/api/asks/{ask_id}?wait=0")[2])["proof"]) + self.settle(lambda: self.status(green) == "approved") + self.settle(lambda: self.choice(decision_id) == "Merge now") + self.assertEqual(self.ask_status(ask_id, "0"), "allowed") + self.assertEqual(json.loads(self.get(f"/api/asks/{ask_id}?wait=0")[2])["proof"], proof) + self.assertEqual(json.loads(self.get(f"/api/reviews/{green}")[2])["proof"], proof) + self.assertNotIn(b'"assertion"', self.get("/api/state")[2]) + self.assertEqual(self.call("POST", f"/api/inbox/undo/{second['id']}", b"", self.guard(self.host))[0], 409) + self.assertEqual(self.call("POST", "/api/inbox/undo/0123456789abcdef", b"", self.guard(self.host))[0], 404) + with mock.patch.object(batches, "UNDO", 0.05): + self.assertEqual(self.approve([self.entry("review", red, "", "")], self.signed([self.entry("review", red, "", "")], signer))[0], 202) + self.settle(lambda: self.status(red) == "approved") + until = time.time() + 3600 + self.assertEqual(self.post_json("/api/inbox/mark", {"kind": "ask", "id": risky, "until": until, "pinned": True}, self.guard(self.host))[0], 200) + top = self.snapshot()["inbox"][0] + self.assertEqual((top["id"], top["pinned"], round(top["until"])), (risky, True, round(until))) + for payload in ( + {"kind": "ask", "id": risky, "until": time.time() - 5, "pinned": False}, + {"kind": "ask", "id": risky, "until": time.time() + 9 * 86400, "pinned": False}, + {"kind": "ask", "id": risky, "until": 0, "pinned": "yes"}, + {"kind": "radio", "id": risky, "until": 0, "pinned": False}, + ): + with self.subTest(payload=payload): + self.assertEqual(self.post_json("/api/inbox/mark", payload, self.guard(self.host))[0], 400) + titles = [row["title"] for row in self.events("kinds=approval")["events"]] + self.assertIn("Freigabe review: feat: green", titles) + self.assertTrue(any(title.startswith("Rückgängig: ") for title in titles)) + + def test_the_sign_endpoint_and_the_page_offer_the_pinned_keys_only(self): + ask_id = self.create_ask(ASK) + prepared = self.prepare([self.entry("ask", ask_id, "", "")]) + self.assertEqual((prepared["credentials"], self.snapshot()["approvers"]), ([], [])) + self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.ask_item(ask_id, "Bash", "make deploy", "/repo", "s1"))]) + self.assertTrue(time.time() < prepared["payload"]["expires"] <= time.time() + signing.LIFETIME) + self.assertRegex(prepared["payload"]["nonce"], r"^[0-9a-f]{32}$") + self.assertNotEqual(prepared["payload"]["nonce"], self.prepare([self.entry("ask", ask_id, "", "")])["payload"]["nonce"]) + self.assertEqual(self.post_json("/api/sign", {"items": [self.entry("ask", ask_id, "", "")]}, self.client())[0], 403) + signer = self.pin() + key(-8, "main").pin(self.approvers, self.origin) + self.assertEqual(self.prepare([self.entry("ask", ask_id, "", "")])["credentials"], [signer.id, key(-8, "main").id]) + self.assertEqual(self.snapshot()["approvers"], [{"id": signer.id, "alg": "ES256"}, {"id": key(-8, "main").id, "alg": "EdDSA"}]) + self.approvers.chmod(0o666) + self.assertEqual(self.snapshot()["approvers"], []) + html = self.index() + for text in ("Passkey registrieren", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-hub/approvers.json", "nicht signiert"): + with self.subTest(text=text): + self.assertIn(text, html) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_mcp.py b/tests/test_hub_mcp.py new file mode 100644 index 0000000..ecc291a --- /dev/null +++ b/tests/test_hub_mcp.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import unittest + +from hub_case import HubCase +from hub_support import PAGE, SCORE, TREE +from kvb_hub import mcp + +TOOLS = {"review_request", "review_status", "review_wait", "review_list", "decision_request", "decision_wait", "operator_message"} + + +class TestHubMcp(HubCase): + def test_mcp_round_trip(self): + init = self.rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}) + self.assertEqual(init["result"]["protocolVersion"], "2025-06-18") + self.assertIn("tools", init["result"]["capabilities"]) + self.assertEqual(init["result"]["serverInfo"]["name"], "kvb-hub") + self.assertEqual(self.rpc("initialize", {"protocolVersion": "1999-01-01"})["result"]["protocolVersion"], mcp.PROTOCOLS[0]) + status, _, body = self.post_mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}, {}) + self.assertEqual((status, body), (202, b"")) + self.assertEqual(self.rpc("ping", {})["result"], {}) + tools = self.rpc("tools/list", {})["result"]["tools"] + self.assertEqual({t["name"] for t in tools}, TOOLS) + for entry in tools: + with self.subTest(tool=entry["name"]): + schema = entry["inputSchema"] + self.assertEqual((schema["type"], schema["additionalProperties"]), ("object", False)) + self.assertEqual(sorted(schema["required"]), sorted(schema["properties"])) + review_id = self.request("feat: hub", "table") + self.assertRegex(review_id, r"^[0-9a-f]{16}$") + state = self.tool("review_status", {"id": review_id}) + self.assertEqual(json.loads(state["content"][0]["text"]), state["structuredContent"]) + self.assertEqual(state["structuredContent"], {"id": review_id, "status": "pending", "tree": TREE, "decided": None}) + listed = self.tool("review_list", {})["structuredContent"]["reviews"] + self.assertEqual([(r["id"], r["title"], r["tree"]) for r in listed], [(review_id, "feat: hub", TREE)]) + self.assertEqual(self.rpc("resources/list", {})["error"]["code"], -32601) + self.assertEqual(self.rpc("tools/call", {"name": "review_approve", "arguments": {"id": review_id}})["error"]["code"], -32602) + status, _, body = self.call("POST", "/mcp", b"{not json", {"Host": self.host, "Content-Type": "application/json"}) + self.assertEqual((status, json.loads(body)["error"]["code"]), (400, -32700)) + + def test_tool_arguments_are_validated(self): + good = self.arguments("feat: x", "", self.page("bundle.html", PAGE)) + for name, arguments in ( + ("review_request", {k: v for k, v in good.items() if k != "notes"}), + ("review_request", {k: v for k, v in good.items() if k != "repo"}), + ("review_request", {**good, "repo": "relative/.git"}), + ("review_request", {**good, "extra": "1"}), + ("review_request", {**good, "tree": "HEAD"}), + ("review_request", {**good, "title": " "}), + ("review_request", {**good, "title": "two\nlines"}), + ("review_request", {**good, "worktree": "relative/path"}), + ("review_request", {**good, "branch": "has space"}), + ("review_request", {**good, "notes": 5}), + ("review_request", {k: v for k, v in good.items() if k != "gate"}), + ("review_request", {k: v for k, v in good.items() if k != "score"}), + ("review_request", {**good, "gate": "maybe"}), + ("review_request", {**good, "score": 5}), + ("review_request", {**good, "score": "x" * 501}), + ("review_status", {"id": "../../etc"}), + ("review_status", {"id": "0123456789abcdef"}), + ("review_wait", {"id": "0123456789abcdef", "timeout_seconds": 1}), + ("review_list", {"extra": 1}), + ): + with self.subTest(name=name, arguments=arguments): + self.assertTrue(self.tool(name, arguments)["isError"]) + review_id = self.request("feat: x", "") + for timeout in (-1, True, "5", None, float("nan"), float("inf")): + with self.subTest(timeout=timeout): + self.assertTrue(self.tool("review_wait", {"id": review_id, "timeout_seconds": timeout})["isError"]) + bad = self.rpc("tools/call", {"name": "review_status", "arguments": ["x"]}) + self.assertEqual(bad["error"]["code"], -32602) + + def test_mcp_rejects_foreign_origins_hosts_and_simple_requests(self): + ping = {"jsonrpc": "2.0", "id": 1, "method": "ping"} + self.assertEqual(self.post_mcp(ping, {"Origin": "http://evil.example"})[0], 403) + self.assertEqual(self.post_mcp(ping, {"Host": "evil.example"})[0], 403) + self.assertEqual(self.post_mcp(ping, {"Content-Type": "text/plain"})[0], 415) + self.assertEqual(self.post_mcp(ping, {"Origin": f"http://{self.host}"})[0], 200) + self.assertEqual(self.get("/mcp")[0], 405) + + def test_mcp_requires_gate_score_and_recommended(self): + tools = {entry["name"]: entry["inputSchema"] for entry in self.rpc("tools/list", {})["result"]["tools"]} + self.assertEqual(tools["review_request"]["properties"]["gate"]["enum"], ["pass", "fail"]) + self.assertTrue({"gate", "score"} <= set(tools["review_request"]["required"])) + self.assertIn("recommended", tools["decision_request"]["required"]) + review_id = self.request("feat: gated", "") + decision_id = self.decision("Wait") + inbox = {item["id"]: item for item in self.snapshot()["inbox"]} + self.assertEqual((inbox[review_id]["gate"], inbox[review_id]["score"], inbox[review_id]["green"]), ("pass", SCORE, True)) + self.assertEqual((inbox[decision_id]["recommended"], inbox[decision_id]["green"]), ("Wait", True)) + plain = self.decision("") + self.assertFalse(next(item for item in self.snapshot()["inbox"] if item["id"] == plain)["green"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_page.py b/tests/test_hub_page.py new file mode 100644 index 0000000..e91f45f --- /dev/null +++ b/tests/test_hub_page.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import re +import subprocess +import time +import unittest + +from hub_case import HubCase +from hub_support import AMBIENCE, SPACE, VOICE +from kvb_hub import exchange + + +class TestHubPage(HubCase): + def test_review_page_is_sandboxed_and_the_index_allows_only_its_own_inline_code(self): + review_id = self.request("feat: csp", "") + status, headers, _ = self.get(f"/reviews/{review_id}/page") + self.assertEqual(status, 200) + self.assertEqual(headers["content-security-policy"], "sandbox allow-scripts") + self.assertEqual(headers["x-content-type-options"], "nosniff") + status, headers, body = self.get("/") + csp = headers["content-security-policy"] + nonce = re.search(r"script-src 'nonce-([^']+)'", csp).group(1) + self.assertIn(f"style-src 'nonce-{nonce}'", csp) + self.assertNotIn("unsafe", csp) + self.assertIn("default-src 'none'", csp) + html = body.decode("utf-8") + tags = re.findall(r"<(script|style)\b([^>]*)>", html) + self.assertEqual(sorted({tag for tag, _ in tags}), ["script", "style"]) + self.assertGreaterEqual(len(tags), 3) + for tag, attributes in tags: + with self.subTest(tag=tag, attributes=attributes): + self.assertIn(f'nonce="{nonce}"', attributes) + self.assertNotIn(nonce, self.get("/")[1]["content-security-policy"]) + status, headers, _ = self.get(f"/reviews/{review_id}/files") + self.assertEqual((status, headers["content-type"]), (200, "application/json")) + self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP) + + def test_markup_from_agents_is_escaped(self): + self.request("", "") + register = {"work/x": {"title": "", "status": "running", "roles": {"One": "RA"}}} + news = {"ts": time.time(), "from": "", "to": [], "text": ""} + self.write_council({"rasci.json": register, "news.jsonl": self.jsonl([news])}) + html = self.index() + for n in range(1, 5): + with self.subTest(n=n): + self.assertNotIn(f"', html, re.DOTALL).group(1) + state = json.loads(embedded) + self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("", "")) + self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("", "")) + + def test_the_index_scripts_parse_and_embed_the_ambience_voice_and_space_modules(self): + html = self.index() + for module in (AMBIENCE, VOICE, SPACE): + with self.subTest(module=module.name): + self.assertIn(module.read_text(encoding="utf-8").strip(), html) + scripts = re.findall(r'', html, re.DOTALL) + self.assertEqual(len(scripts), 4) + page = self.state / "page.js" + page.write_text("\n;\n".join(scripts), encoding="utf-8") + done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + for module in (AMBIENCE, VOICE, SPACE): + with self.subTest(module=module.name): + done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_reviews.py b/tests/test_hub_reviews.py new file mode 100644 index 0000000..693ab44 --- /dev/null +++ b/tests/test_hub_reviews.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import json +import os +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +from hub_case import HubCase +from hub_support import PAGE, TREE, key +from kvb_hub import ledger, review_page, reviewing + +REVIEW_PAGE = """ +
+

Staged 2 files+2−1

+
+ +M +hub/server.py ++2−1 +Open ↗ + +
+ + + + + + + + +
@@ -1,2 +1,3 @@
11import os
2-if a < b:
2+if a <= b:
3+ return "<b>"
… 5 more lines not shown
+ +
+ +R +docs/old.md → docs/new.md +binary ++0−0 + +

No content changes.

+
+ +
+
abc1234 +
+
Askip.py
+
+
+
+ +""" + + +class TestHubReviews(HubCase): + def decide(self, review_id: str, action: str, headers: dict[str, str], body: bytes) -> int: + status, _, _ = self.call("POST", f"/reviews/{review_id}/{action}", body, headers) + return status + + def test_review_request_copies_the_page(self): + source = self.page("bundle.html", PAGE) + result = self.tool("review_request", self.arguments("feat: copy", "", source)) + review_id = result["structuredContent"]["id"] + self.assertEqual(result["structuredContent"]["url"], f"http://localhost:{self.port}/") + Path(source).write_text("

changed after the request

", encoding="utf-8") + status, _, body = self.get(f"/reviews/{review_id}/page") + self.assertEqual((status, body.decode("utf-8")), (200, PAGE)) + self.assertEqual((self.state / "pages" / f"{review_id}.html").read_text(encoding="utf-8"), PAGE) + + def test_review_request_rejects_pages_outside_the_pages_dir(self): + evil = self.outside / "evil.html" + evil.write_text("

evil

", encoding="utf-8") + (self.pages / "notes.txt").write_text("plain", encoding="utf-8") + (self.pages / "link.html").symlink_to(evil) + (self.pages / "dir.html").mkdir() + os.mkfifo(self.pages / "fifo.html") + for page in ( + str(evil), + f"{self.pages}/../outside/evil.html", + str(self.pages / "notes.txt"), + str(self.pages / "link.html"), + str(self.pages / "dir.html"), + str(self.pages / "fifo.html"), + str(self.pages / "missing.html"), + "bundle.html", + "", + ): + with self.subTest(page=page): + result = self.tool("review_request", self.arguments("feat: x", "", page)) + self.assertTrue(result["isError"], result) + self.assertEqual(self.tool("review_list", {})["structuredContent"]["reviews"], []) + self.assertEqual(list((self.state / "pages").iterdir()), []) + + def test_approve_flips_the_status_and_review_wait_returns_it(self): + review_id = self.request("feat: approve me", "") + results = [] + waiter = threading.Thread(target=lambda: results.append(self.tool("review_wait", {"id": review_id, "timeout_seconds": 30}))) + waiter.start() + time.sleep(0.3) + self.assertTrue(waiter.is_alive()) + signer = key(-7, "main") + for body in (b"", b'{"proof": null}', b'{"proof": {"payload": {}, "assertion": {}}}', b'{"proof": 1, "extra": 1}'): + with self.subTest(body=body): + self.assertEqual(self.decide(review_id, "approve", self.guard(self.host), body), 400) + self.assertTrue(waiter.is_alive()) + started = time.monotonic() + proven = self.proven([self.entry("review", review_id, "", "")], signer) + self.assertEqual(self.decide(review_id, "approve", self.guard(self.host), proven), 200) + waiter.join(10) + self.assertLess(time.monotonic() - started, 10) + decided = results[0]["structuredContent"] + self.assertEqual((decided["status"], decided["tree"]), ("approved", TREE)) + self.assertIsNotNone(decided["decided"]) + self.assertEqual(self.status(review_id), "approved") + status, _, body = self.get(f"/api/reviews/{review_id}") + self.assertEqual((status, json.loads(body)["proof"]), (200, json.loads(proven)["proof"])) + self.assertEqual(self.decide(review_id, "reject", self.guard(self.host), b""), 409) + self.assertEqual(self.status(review_id), "approved") + other = self.request("feat: reject me", "") + self.assertEqual(self.decide(other, "reject", self.guard(f"localhost:{self.port}"), b""), 200) + self.assertEqual(self.tool("review_wait", {"id": other, "timeout_seconds": 0})["structuredContent"]["status"], "rejected") + self.assertIsNone(json.loads(self.get(f"/api/reviews/{other}")[2])["proof"]) + self.assertEqual(self.tool("review_list", {})["structuredContent"]["reviews"], []) + self.assertEqual(self.decide("0123456789abcdef", "approve", self.guard(self.host), proven), 404) + self.assertEqual(self.get("/api/reviews/0123456789abcdef")[0], 404) + self.assertEqual(ledger.Reviews(self.state).get(review_id)["status"], "approved") + + def test_review_wait_times_out_at_the_cap(self): + review_id = self.request("feat: slow", "") + started = time.monotonic() + with mock.patch.object(reviewing, "WAIT_CAP", 0.2): + result = self.tool("review_wait", {"id": review_id, "timeout_seconds": 10**6}) + self.assertLess(time.monotonic() - started, 5) + self.assertEqual(result["structuredContent"]["status"], "pending") + + def test_cross_site_decisions_are_forbidden(self): + review_id = self.request("feat: guarded", "") + good = self.guard(self.host) + for headers in ( + {k: v for k, v in good.items() if k != "X-Kvb-Hub"}, + {**good, "X-Kvb-Hub": "0"}, + {k: v for k, v in good.items() if k != "Origin"}, + {**good, "Origin": "http://evil.example"}, + {**good, "Origin": "null"}, + {**good, "Origin": f"http://localhost:{self.port}"}, + {**good, "Host": f"evil.example:{self.port}", "Origin": f"http://evil.example:{self.port}"}, + {**good, "Host": "127.0.0.1:1", "Origin": "http://127.0.0.1:1"}, + ): + for action in ("approve", "reject"): + with self.subTest(headers=headers, action=action): + self.assertEqual(self.decide(review_id, action, headers, b""), 403) + self.assertEqual(self.status(review_id), "pending") + + def test_review_files_come_from_the_page(self): + files = review_page.review_files(REVIEW_PAGE.encode("utf-8")) + self.assertEqual([(f["section"], f["path"], f["old"], f["status"], f["added"], f["removed"]) for f in files], [ + ("staged", "hub/server.py", "", "modified", 2, 1), + ("staged", "docs/new.md", "docs/old.md", "renamed", 0, 0), + ]) + lines = [(line["kind"], line["old"], line["new"], line["text"]) for line in files[0]["lines"]] + self.assertEqual(lines, [ + ("hunk", "", "", "@@ -1,2 +1,3 @@"), + ("ctx", "1", "1", "import os"), + ("del", "2", "", "if a < b:"), + ("add", "", "2", "if a <= b:"), + ("add", "", "3", ' return ""'), + ("more", "", "", "… 5 more lines not shown"), + ]) + self.assertEqual((files[1]["tags"], files[1]["detail"], files[1]["lines"]), (["binary"], "No content changes.", [])) + review_id = self.tool("review_request", self.arguments("feat: tree", "", self.page("tree.html", REVIEW_PAGE)))["structuredContent"]["id"] + status, _, body = self.get(f"/reviews/{review_id}/files") + self.assertEqual((status, json.loads(body)), (200, {"files": files})) + self.assertEqual([(r["id"], r["files"]) for r in self.snapshot()["reviews"]], [(review_id, 2)]) + self.assertEqual(self.get(f"/reviews/{review_id}/page")[2].decode("utf-8"), REVIEW_PAGE) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_settings.py b/tests/test_hub_settings.py new file mode 100644 index 0000000..f302845 --- /dev/null +++ b/tests/test_hub_settings.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +import unittest + +from hub_case import HubCase +from hub_support import STREAMS +from kvb_hub import settings + + +class TestHubSettings(HubCase): + def settings_payload(self, streams: list[dict]) -> dict: + colors = {"top": "#101820", "bottom": "#405060", "accent": "#ffaa00"} + speech = {"on": True, "classes": ["direct", "pan-pan", "mayday"], "muted": ["name:Docs", "s1"]} + space = {"graph": {"axis": "time", "yaw": -45.5, "pitch": 12.25, "zoom": 1.5, "pan_x": -30.0, "pan_y": 12.0}, "sky": {"azimuth": 225.0, "altitude": 20.0, "fov": 120.0}} + return { + **settings.SETTINGS, "latitude": 52.52, "longitude": 13.405, "theme": "custom", "colors": colors, "music": {"on": True, "volume": 0.3}, "streams": streams, "speech": speech, + "space": space, + } + + def media_src(self) -> str: + csp = self.get("/")[1]["content-security-policy"] + return next(part.strip() for part in csp.split(";") if part.strip().startswith("media-src")) + + def test_settings_are_guarded_validated_and_persisted(self): + self.assertEqual(self.snapshot()["settings"], settings.SETTINGS) + payload = self.settings_payload(STREAMS) + good = self.guard(self.host) + for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}): + with self.subTest(headers=headers): + self.assertEqual(self.post_json("/api/settings", payload, headers)[0], 403) + self.assertFalse((self.state / "settings.json").exists()) + stream = STREAMS[0] + for broken in ( + {**payload, "latitude": 91}, + {**payload, "theme": "neon"}, + {**payload, "extra": 1}, + {**payload, "colors": {**payload["colors"], "accent": "red"}}, + {**payload, "music": {"on": "yes", "volume": 0.3}}, + {**payload, "streams": [{**stream, "url": "javascript:alert(1)"}]}, + {**payload, "streams": [{**stream, "url": "https://evil.example;script-src"}]}, + {**payload, "streams": [{**stream, "url": "https://a.example 'unsafe-inline'"}]}, + {**payload, "streams": [{**stream, "url": "https://user:pw@a.example/"}]}, + {**payload, "streams": [{**stream, "days": [7]}]}, + {**payload, "streams": [{**stream, "days": [1, 1]}]}, + {**payload, "streams": [{**stream, "from": "09:00", "to": "09:00"}]}, + {**payload, "streams": [{**stream, "to": "25:00"}]}, + *({**payload, "speech": {**payload["speech"], **change}} for change in ( + {"on": "yes"}, + {"classes": ["direct", "pan-pan"]}, + {"classes": ["mayday", "shout"]}, + {"classes": ["mayday", "mayday"]}, + {"classes": "mayday"}, + {"muted": "s1"}, + {"muted": ["s1", "s1"]}, + {"muted": ["two\nlines"]}, + {"muted": [""]}, + {"muted": [7]}, + {"muted": [f"s{n}" for n in range(settings.MUTED_LIMIT + 1)]}, + {"extra": 1}, + )), + {**payload, "speech": {"on": True, "classes": ["mayday"]}}, + {key: value for key, value in payload.items() if key != "speech"}, + *({**payload, "space": change} for change in ( + "flat", + {"graph": payload["space"]["graph"]}, + {**payload["space"], "extra": 1}, + {**payload["space"], "sky": [180, 0, 180]}, + )), + *({**payload, "space": {**payload["space"], "graph": {**payload["space"]["graph"], **change}}} for change in ( + {"axis": "depth"}, + {"axis": 1}, + {"yaw": 180.5}, + {"pitch": "up"}, + {"zoom": True}, + {"zoom": 0.2}, + {"pan_x": 2001}, + {"pan_y": float("nan")}, + {"roll": 0}, + )), + {**payload, "space": {**payload["space"], "graph": {key: value for key, value in payload["space"]["graph"].items() if key != "pan_y"}}}, + *({**payload, "space": {**payload["space"], "sky": {**payload["space"]["sky"], **change}}} for change in ( + {"azimuth": -1}, + {"azimuth": 360.5}, + {"altitude": 86}, + {"fov": 10}, + {"fov": None}, + {"fov": float("inf")}, + {"zoom": 1}, + )), + {key: value for key, value in payload.items() if key != "space"}, + ): + with self.subTest(broken=broken): + self.assertEqual(self.post_json("/api/settings", broken, good)[0], 400) + status, _, body = self.post_json("/api/settings", payload, good) + self.assertEqual(status, 200, body) + self.assertEqual(json.loads(body), payload) + self.assertEqual(json.loads((self.state / "settings.json").read_text(encoding="utf-8")), payload) + self.assertEqual(self.snapshot()["settings"], payload) + self.assertEqual(settings.Settings(self.state / "settings.json").get(), payload) + self.assertEqual(self.get("/api/settings")[0], 405) + shuffled = {**payload, "speech": {"on": False, "classes": ["mayday", "broadcast", "security"], "muted": ["s1", "Operator", "name:Docs"]}} + status, _, body = self.post_json("/api/settings", shuffled, good) + self.assertEqual(status, 200, body) + self.assertEqual(json.loads(body)["speech"], {"on": False, "classes": ["broadcast", "security", "mayday"], "muted": ["Operator", "name:Docs", "s1"]}) + + def test_settings_written_before_the_radio_filter_keep_their_values(self): + older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "speech"} + (self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8") + loaded = settings.Settings(self.state / "settings.json").get() + self.assertEqual(loaded, {**older, "speech": settings.SETTINGS["speech"]}) + flat = {key: value for key, value in older.items() if key != "space"} + (self.state / "settings.json").write_text(json.dumps(flat), encoding="utf-8") + self.assertEqual(settings.Settings(self.state / "settings.json").get(), {**flat, "speech": settings.SETTINGS["speech"], "space": settings.SETTINGS["space"]}) + (self.state / "settings.json").write_text(json.dumps({**older, "unknown": 1}), encoding="utf-8") + self.assertEqual(settings.Settings(self.state / "settings.json").get(), settings.SETTINGS) + + def test_csp_media_src_lists_exactly_the_stream_origins(self): + self.assertEqual(self.media_src(), "media-src 'self'") + streams = [*STREAMS, {**STREAMS[0], "name": "Gleich", "url": "https://Radio.Example:443/zweiter?x=1"}] + status, _, body = self.post_json("/api/settings", self.settings_payload(streams), self.guard(self.host)) + self.assertEqual(status, 200, body) + origins = ["http://10.0.0.5:8000", "https://other.example:8443", "https://radio.example"] + self.assertEqual(self.media_src(), " ".join(["media-src 'self'", *origins])) + csp = self.get("/")[1]["content-security-policy"] + self.assertIn("connect-src 'self';", csp) + self.assertEqual(csp.count("radio.example"), 1) + self.assertEqual(self.snapshot()["media"], origins) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_space.py b/tests/test_hub_space.py new file mode 100644 index 0000000..dd62842 --- /dev/null +++ b/tests/test_hub_space.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import itertools +import json +import math +import subprocess +import unittest + +from hub_support import SPACE +from kvb_hub import settings + +SPACE_NOW = 1_800_000_000 +SPACE_LOW = SPACE_NOW - 86400 +SPACE_PROBE = """ +const S = require(process.argv[1]); +const now = Number(process.argv[2]); +const low = Number(process.argv[3]); +let seed = 7; +const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 4294967296; +}; +const median = (list) => [...list].sort((a, b) => a - b)[Math.floor(list.length / 2)]; +const clock = (run) => { + const began = process.hrtime.bigint(); + const value = run(); + return [Number(process.hrtime.bigint() - began) / 1e6, value]; +}; +const sessions = Array.from({length: 30}, (_, index) => `s${index}`); +const keys = ["name:Maintainer", ...sessions]; +const nodes = [ + {id: "Operator", kind: "operator", parent: null, label: "Operator", timeline: [{name: "Operator", since: now}]}, + {id: "name:Maintainer", kind: "chair", parent: null, label: "Maintainer", timeline: [{name: "Maintainer", since: low}]}, + ...sessions.map((id, index) => ({id, kind: "session", parent: null, label: `Renamed ${index}`, timeline: [{name: `Agent ${index}`, since: low - 10}, {name: `Renamed ${index}`, since: low + 43200}]})), + {id: "subagent x @ s1", kind: "subagent", parent: "s1", label: "subagent x", timeline: [{name: "subagent x", since: now}]}, +]; +const track = Array.from({length: 13}, (_, step) => ({ts: low + 7200 * step, orbit: Object.fromEntries(keys.map((key, at) => [key, {angle: -Math.PI / 2 + at * 0.2 + step * 0.05, radius: 150 + ((at * 37 + step * 11) % 150)}]))})); +const classes = ["direct", "direct", "direct", "broadcast", "security", "mayday", "pan-pan"]; +const radio = Array.from({length: 5000}, (_, index) => { + const cls = classes[Math.floor(next() * classes.length)]; + return {ts: low + 17.28 * index, source: keys[Math.floor(next() * keys.length)], targets: cls === "broadcast" ? [] : [keys[Math.floor(next() * keys.length)]], class: cls, text: "x"}; +}); +const grants = Array.from({length: 50}, (_, index) => ({ts: low + 1700 * index, target: sessions[index % sessions.length]})); +const subagents = {s1: [ + {id: "root", depth: 1, since: low + 3600, until: low + 7200, started: low + 3600, type: "Explore", description: "scan", finished: true}, + {id: "leaf", depth: 2, since: low + 4000, until: null, started: low + 4000, type: "Plan", description: "plan", finished: false}, + {id: "late", depth: 1, since: null, until: null, started: low + 80000, type: "", description: "late", finished: false}, +]}; +const spots = Object.fromEntries(keys.map((key, at) => [key, {x: 200 * Math.cos(at), y: 200 * Math.sin(at)}])); +const input = {axis: "time", range: [low, now], span: [low, now], cursor: now, nodes, track, now: spots, lives: {s2: [low + 600, low + 1200]}, subagents, radio, grants, chair: "name:Maintainer", cap: S.ARC_CAP}; +const built = S.scene(input); +const spawnOf = (scene, sub) => scene.arcs.find((arc) => arc.kind === "spawn" && arc.sub === sub); +const flat = (view) => S.camera({yaw: 0, pitch: 0, zoom: 1, pan_x: 0, pan_y: 0, ...view}, 860, 860); +const spawn = S.scene({...input, axis: "spawn"}); +const calls = {count: 0}; +const ctx = Object.fromEntries(["clearRect", "beginPath", "moveTo", "lineTo", "quadraticCurveTo", "closePath", "stroke", "fill", "arc", "strokeText", "fillText"].map((name) => [name, () => { + calls.count += 1; +}])); +const colors = Object.fromEntries(["line", "accent", "muted", "violet", "direct", "broadcast", "alarm", "security", "grant", "panel", "text"].map((role) => [role, "#123456"])); +const style = {colors, font: "12px sans", strong: "bold 12px sans", level: "Tiefe", cursor: "Stand", selected: "s3", format: String}; +const builds = Array.from({length: 15}, () => clock(() => S.scene(input))[0]); +let stats = null; +const paints = Array.from({length: 30}, (_, frame) => { + const [spent, result] = clock(() => S.paint(ctx, built, S.camera({...S.DEFAULTS.graph, yaw: frame * 12}, 1280, 760), style)); + stats = result; + return spent; +}); +const mini = {...input, nodes: nodes.slice(0, 4), track: [], subagents: {}, lives: {}, grants: [], radio: [{ts: low + 43200, source: "s0", targets: ["s1"], class: "direct", text: "x"}], now: {s0: {x: 200, y: 0}, s1: {x: -200, y: 0}, "name:Maintainer": {x: 0, y: -200}}}; +const small = S.scene(mini); +const cam = S.camera(S.DEFAULTS.graph, 860, 860); +const wire = small.arcs[0]; +const [a, b, c] = [wire.from, wire.via, wire.to].map((entry) => S.project(cam, entry)); +const tail = S.project(cam, small.lines.find((entry) => entry.node === "s0").points[0]); +const hit = (x, y) => { + const found = S.pick(small, cam, x, y, 10); + return found ? `${found.kind}:${found.kind === "line" ? found.line.node : found.arc.kind}` : null; +}; +const sight = S.lens(S.DEFAULTS.sky, 1600, 900); +const synthetic = Array.from({length: 5000}, (_, index) => ({tone: ["direct", "broadcast", "alarm", "security", "grant", "spawn"][index % 6], ts: index})); +const capped = S.capArcs(synthetic, 2000); +console.log(JSON.stringify({ + defaults: S.DEFAULTS, limits: S.LIMITS, axes: S.AXES, cap: S.ARC_CAP, + matrices: [[0, 0], [30, 20], [-135, 60], [90, -90], [179.5, 45], [12.3, -77.7]].map(([yaw, pitch]) => S.rotation(yaw, pitch)), + projected: [ + [flat({}), {x: 0, y: 0, z: 0}], [flat({}), {x: 100, y: 0, z: 0}], [flat({}), {x: 100, y: 50, z: 400}], [flat({yaw: 90}), {x: 100, y: 0, z: 0}], + [flat({pitch: 90}), {x: 0, y: 100, z: 0}], [S.camera({yaw: 0, pitch: 0, zoom: 2, pan_x: 10, pan_y: -20}, 1720, 860), {x: 100, y: 0, z: 0}], [flat({}), {x: 0, y: 0, z: -1500}], + ].map(([view, point]) => S.project(view, point)), + sorted: S.depthSort([{id: "a", depth: 5}, {id: "b", depth: 50}, {id: "c", depth: -20}, {id: "d", depth: 50}]).map((item) => item.id), + axis: { + time: [low, (low + now) / 2, now, low - 100, now + 100].map((ts) => S.depthOf("time", {ts, level: 0}, [low, now])), + still: S.depthOf("time", {ts: now, level: 0}, [now, now]), + spawn: [0, 1, 2, 4, 9].map((level) => S.depthOf("spawn", {ts: now, level}, [low, now])), + none: [S.depthOf("none", {ts: low, level: 3}, [low, now]), S.depthOf("none", {ts: now, level: 0}, [low, now])], + }, + same: [S.scene(input), S.scene(JSON.parse(JSON.stringify(input)))].every((other) => JSON.stringify(other) === JSON.stringify(built)), + lines: Object.fromEntries(built.lines.map((entry) => [entry.sub || entry.node, {kind: entry.kind, level: entry.level, alive: entry.alive, stamps: entry.points.map((point) => point.ts), first: entry.points[0], name: entry.timeline[0].name}])), + labels: built.labels.filter((label) => built.lines[label.line].node === "s5").map((label) => [label.at.ts, label.text]), + spawns: Object.fromEntries(["root", "leaf"].map((sub) => [sub, spawnOf(built, sub)])), + plane: built.plane, guides: built.guides, total: built.total, dropped: built.dropped, arcs: built.arcs.length, + alarms: built.arcs.filter((arc) => arc.tone === "alarm").length, + spawned: { + points: spawn.lines.map((entry) => entry.points.length), leaf: spawn.lines.find((entry) => entry.sub === "leaf").points[0].z, session: spawn.lines.find((entry) => entry.node === "s1").points[0].z, + arc: spawnOf(spawn, "leaf"), plane: spawn.plane, messages: spawn.arcs.filter((arc) => arc.kind !== "spawn").length, + }, + capped: {kept: capped.kept.map((arc) => arc.ts), dropped: capped.dropped, small: S.capArcs(synthetic.slice(0, 100), 1500).kept.length}, + frame: {build: median(builds), paint: median(paints), stats, calls: calls.count / 30}, + picks: [hit(tail.x, tail.y), hit(0.25 * a.x + 0.5 * b.x + 0.25 * c.x, 0.25 * a.y + 0.5 * b.y + 0.25 * c.y), hit(3, 3)], + names: [5, 10, 15, 20, 99].map((ts) => S.nameAt([{name: "A", since: 10}, {name: "B", since: 20}], ts)), + tree: S.tree([{id: "x", depth: 1, since: 10, until: 50, started: 10}, {id: "y", depth: 1, since: 20, until: 30, started: 20}, {id: "z", depth: 2, since: 40, until: null, started: 40}, {id: "w", depth: 2, since: 25, until: null, started: 25}]).parents, + clamped: [S.clampView("graph", {axis: "time", yaw: 190, pitch: 100, zoom: 9, pan_x: -5000, pan_y: 1}), S.clampView("sky", {azimuth: -10, altitude: 99, fov: 5})], + tweened: S.tween("graph", {axis: "time", yaw: 170, pitch: 0, zoom: 1, pan_x: 0, pan_y: 0}, {axis: "spawn", yaw: -170, pitch: 10, zoom: 2, pan_x: 0, pan_y: 0}, 0.5), + tidy: S.tidy({axis: "time", yaw: 12.3456, zoom: 0.999}), + sky: { + south: [[30, 135], [0, 90], [0, 270], [45, 180]].map(([alt, az]) => S.place(sight, alt, az)), + horizon: S.horizon(sight, 8).map((point) => point.y), + east: S.place(S.lens({azimuth: 90, altitude: 0, fov: 180}, 1600, 900), 0, 90), + up: S.place(S.lens({azimuth: 180, altitude: 30, fov: 180}, 1600, 900), 30, 180), + narrow: S.place(S.lens({azimuth: 180, altitude: 0, fov: 90}, 1600, 900), 0, 225), + }, + compass: [0, 22.4, 22.6, 180, 359, -90, 404].map(S.compass), +})); +""" + + +class TestSpaceModule(unittest.TestCase): + @classmethod + def setUpClass(cls): + command = ["node", "-e", SPACE_PROBE, str(SPACE), str(SPACE_NOW), str(SPACE_LOW)] + done = subprocess.run(command, capture_output=True, text=True, timeout=120, check=True) + cls.data = json.loads(done.stdout) + + def assert_close(self, actual: dict, expected: dict) -> None: + for key, value in expected.items(): + with self.subTest(key=key): + self.assertAlmostEqual(actual[key], value, places=6) + + def test_the_defaults_limits_and_axes_match_the_server_validation(self): + self.assertEqual(self.data["defaults"], settings.SETTINGS["space"]) + self.assertEqual(self.data["limits"], {view: {key: list(pair) for key, pair in limits.items()} for view, limits in settings.SPACE_LIMITS.items()}) + self.assertEqual(tuple(self.data["axes"]), settings.SPACE_AXES) + + def test_rotation_matrices_are_orthonormal(self): + self.assertEqual(len(self.data["matrices"]), 6) + for matrix in self.data["matrices"]: + rows = [matrix[0:3], matrix[3:6], matrix[6:9]] + with self.subTest(matrix=matrix): + for first, second in itertools.product(range(3), repeat=2): + self.assertAlmostEqual(sum(rows[first][k] * rows[second][k] for k in range(3)), float(first == second), places=12) + self.assertAlmostEqual(sum(rows[k][first] * rows[k][second] for k in range(3)), float(first == second), places=12) + (a, b, c), (d, e, f), (g, h, i) = rows + self.assertAlmostEqual(a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g), 1.0, places=12) + + def test_known_points_project_to_the_expected_screen_coordinates(self): + expected = [ + {"x": 430, "y": 430, "depth": 0, "size": 1}, + {"x": 530, "y": 430, "depth": 0, "size": 1}, + {"x": 510, "y": 470, "depth": 400, "size": 0.8}, + {"x": 430, "y": 430, "depth": -100, "size": 1600 / 1500}, + {"x": 430, "y": 430, "depth": 100, "size": 1600 / 1700}, + {"x": 1080, "y": 390, "depth": 0, "size": 2}, + ] + projected = self.data["projected"] + for actual, wanted in zip(projected[:-1], expected, strict=True): + self.assert_close(actual, wanted) + self.assertIsNone(projected[-1]) + + def test_depth_sort_draws_far_before_near_and_keeps_ties_in_order(self): + self.assertEqual(self.data["sorted"], ["b", "d", "a", "c"]) + + def test_the_axis_maps_time_spawn_depth_or_nothing(self): + axis = self.data["axis"] + self.assertEqual(axis["time"], [300, 0, -300, 300, -300]) + self.assertEqual(axis["still"], -300) + self.assertEqual(axis["spawn"], [-300, -150, 0, 300, 300]) + self.assertEqual(axis["none"], [0, 0]) + + def test_world_lines_are_deterministic_and_follow_the_layout_of_their_time(self): + self.assertTrue(self.data["same"]) + lines = self.data["lines"] + self.assertLessEqual({"Operator", "name:Maintainer", "subagent x @ s1", *(f"s{n}" for n in range(30))}, set(lines)) + root, leaf, late = lines["root"], lines["leaf"], lines["late"] + self.assertEqual((root["kind"], root["level"], root["alive"], root["stamps"]), ("twig", 1, False, [SPACE_LOW + 3600, SPACE_LOW + 7200])) + self.assertEqual((leaf["level"], leaf["alive"], leaf["stamps"][0], leaf["stamps"][-1]), (2, True, SPACE_LOW + 4000, SPACE_NOW)) + self.assertEqual((late["stamps"], late["name"]), ([SPACE_LOW + 80000, SPACE_NOW], "Subagent")) + self.assertEqual((lines["s2"]["stamps"], lines["s2"]["alive"]), ([SPACE_LOW + 600, SPACE_LOW + 1200], False)) + self.assertEqual(lines["s5"]["stamps"], [SPACE_LOW + 7200 * step for step in range(13)]) + self.assertEqual((lines["subagent x @ s1"]["level"], lines["subagent x @ s1"]["kind"]), (1, "subagent")) + angle, radius = -math.pi / 2 + 0.425, 229.5 + born = self.data["spawns"]["root"] + self.assert_close(born["from"], {"x": radius * math.cos(angle), "y": radius * math.sin(angle), "z": 275, "ts": SPACE_LOW + 3600}) + for sub in ("root", "leaf"): + with self.subTest(sub=sub): + self.assertEqual(self.data["spawns"][sub]["to"], lines[sub]["first"]) + self.assertEqual(self.data["spawns"][sub]["from"]["z"], lines[sub]["first"]["z"]) + self.assertEqual(self.data["labels"], [[SPACE_NOW, "Renamed 5"], [SPACE_LOW + 43200, "Renamed 5"], [SPACE_LOW, "Agent 5"]]) + self.assertEqual(self.data["plane"], {"z": -300, "ts": SPACE_NOW}) + self.assertEqual([(guide["z"], guide["ts"]) for guide in self.data["guides"]], [(300, SPACE_LOW), (-300, SPACE_NOW)]) + spawned = self.data["spawned"] + self.assertEqual(set(spawned["points"]), {1}) + self.assertEqual((spawned["session"], spawned["leaf"], spawned["plane"]), (-300, 0, None)) + self.assertEqual((spawned["arc"]["from"]["z"], spawned["arc"]["to"]["z"]), (-150, 0)) + self.assertLessEqual(spawned["messages"], self.data["cap"]) + + def test_arcs_are_capped_by_urgency_then_recency(self): + self.assertEqual((self.data["arcs"], self.data["dropped"]), (self.data["cap"], self.data["total"] - self.data["cap"])) + self.assertGreater(self.data["total"], self.data["cap"]) + capped = self.data["capped"] + kept = capped["kept"] + self.assertEqual((len(kept), capped["dropped"], capped["small"]), (2000, 3000, 100)) + self.assertEqual(kept, sorted(kept)) + tones = ["direct", "broadcast", "alarm", "security", "grant", "spawn"] + by_tone = {tone: [ts for ts in kept if tones[ts % 6] == tone] for tone in tones} + self.assertEqual({tone: len(found) for tone, found in by_tone.items()}, {"direct": 0, "broadcast": 0, "alarm": 833, "security": 334, "grant": 0, "spawn": 833}) + self.assertEqual(by_tone["security"], [ts for ts in range(5000) if ts % 6 == 3][-334:]) + + def test_a_frame_of_5000_events_draws_capped_batched_arcs_inside_the_frame_budget(self): + frame = self.data["frame"] + self.assertLessEqual(frame["stats"]["arcs"], self.data["cap"]) + self.assertLess(frame["stats"]["batches"], 400) + self.assertLess(frame["paint"], 1000 / 60) + + def test_picking_finds_the_world_line_the_arc_or_nothing(self): + self.assertEqual(self.data["picks"], ["line:s0", "arc:message", None]) + + def test_names_trees_and_views_follow_their_rules(self): + self.assertEqual(self.data["names"], ["A", "A", "A", "B", "B"]) + self.assertEqual(self.data["tree"], {"x": "", "y": "", "w": "y", "z": "x"}) + self.assertEqual(self.data["clamped"], [{"axis": "time", "yaw": -170, "pitch": 90, "zoom": 4, "pan_x": -2000, "pan_y": 1}, {"azimuth": 350, "altitude": 85, "fov": 20}]) + self.assert_close(self.data["tweened"], {"pitch": 5, "zoom": 1.5, "pan_x": 0, "pan_y": 0}) + self.assertAlmostEqual(abs(self.data["tweened"]["yaw"]), 180, places=6) + self.assertEqual((self.data["tweened"]["axis"], self.data["tidy"]), ("spawn", {"axis": "time", "yaw": 12.35, "zoom": 1})) + + def test_the_sky_faces_south_by_default_and_turns_with_the_view(self): + sky = self.data["sky"] + width, height = 1600, 900 + horizon = 0.86 * height + today = [{"x": width / 2 + ((az - 180) / 180) * width, "y": horizon - (alt / 90) * horizon * 0.94} for alt, az in ((30, 135), (0, 90), (0, 270), (45, 180))] + for actual, wanted in zip(sky["south"], today, strict=True): + self.assert_close(actual, wanted) + for y in sky["horizon"]: + self.assertAlmostEqual(y, horizon, places=6) + for name, wanted in (("east", {"x": 800, "y": horizon}), ("up", {"x": 800, "y": horizon}), ("narrow", {"x": width, "y": horizon})): + with self.subTest(name=name): + self.assert_close(sky[name], wanted) + self.assertEqual(self.data["compass"], [0, 0, 1, 4, 0, 6, 1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_speech.py b/tests/test_hub_speech.py new file mode 100644 index 0000000..61b0192 --- /dev/null +++ b/tests/test_hub_speech.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import dataclasses +import hashlib +import io +import json +import os +import subprocess +import sys +import unittest +import wave +from unittest import mock + +from hub_case import HubCase +from hub_engines import OPERATOR_VOICE, VOICES, log_rows, wav_bytes +from hub_support import HOST_STATE, HUB, server +from kvb_hub import council, engines, exchange, handler, phrasing + + +class TestHubSpeech(HubCase): + def test_voices_come_from_the_models_and_stick_to_their_session(self): + speech = self.httpd.hub.speech + self.assertEqual(list(speech.voices), VOICES) + self.assertEqual((speech.voices["de_DE-solo-medium:0"], speech.voices["en_US-test-high:3"]), (("de_DE-solo-medium", None), ("en_US-test-high", 3))) + pool = [voice for voice in VOICES if voice != OPERATOR_VOICE] + self.assertEqual((speech.pool, speech.operator), (pool, OPERATOR_VOICE)) + keys = [f"session-{n}" for n in range(40)] + ["s1", "name:Docs"] + picked = [engines.voice_of(key, pool, OPERATOR_VOICE) for key in keys] + self.assertEqual(picked, [pool[int(hashlib.sha256(key.encode()).hexdigest(), 16) % len(pool)] for key in keys]) + self.assertEqual(picked, [speech.voice_of(key) for key in keys]) + self.assertEqual(picked, [engines.voice_of(key, list(pool), OPERATOR_VOICE) for key in keys]) + self.assertEqual(set(picked), set(pool)) + self.assertEqual(speech.voice_of("Operator"), OPERATOR_VOICE) + self.assertEqual(engines.voice_of("s1", [], OPERATOR_VOICE), OPERATOR_VOICE) + self.assertEqual(engines.voice_of("s1", [], ""), "") + with self.assertRaises(server.ToolError): + engines.Speech(dataclasses.replace(self.engines, operator_voice="en_US-test-high:4"), self.state / "other") + silent = engines.Speech(dataclasses.replace(self.engines, voices=self.outside, operator_voice="anything"), self.state / "silent") + self.assertEqual((silent.speaks(), silent.voice_of("s1"), silent.describe()["operator"], silent.describe()["tts"]), (False, "", "", False)) + deaf = engines.Speech(dataclasses.replace(self.engines, whisper=self.outside / "missing"), self.state / "deaf") + self.assertFalse(deaf.hears()) + + def test_speech_output_is_guarded_whitelisted_capped_and_cached(self): + good = self.guard(self.host) + calls = self.engines.scratch / "piper.jsonl" + cache = self.state / "tts" + request = {"text": "all stations, this is Docs, board, out", "voice": "en_US-test-high:2"} + for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}): + with self.subTest(headers=headers): + self.assertEqual(self.post_json("/api/tts", request, headers)[0], 403) + for broken in ( + {**request, "voice": "en_US-test-high:4"}, + {**request, "voice": "zero:0"}, + {**request, "voice": "bad name!:0"}, + {**request, "voice": "broken:0"}, + {**request, "voice": ["en_US-test-high:2"]}, + {**request, "text": "x" * (phrasing.SPEECH_LIMIT + 1)}, + {**request, "text": " \n\t "}, + {**request, "text": "bell \x07 here"}, + {**request, "text": 7}, + {**request, "extra": 1}, + {"text": request["text"]}, + ): + with self.subTest(broken=broken): + self.assertEqual(self.post_json("/api/tts", broken, good)[0], 400) + self.assertEqual(log_rows(calls), []) + status, headers, body = self.post_json("/api/tts", request, good) + self.assertEqual((status, headers["content-type"], headers["content-security-policy"]), (200, "audio/wav", exchange.PLAIN_CSP), body) + with wave.open(io.BytesIO(body)) as reader: + self.assertEqual((reader.getnchannels(), reader.getframerate(), reader.getnframes()), (1, 22050, len(request["text"]) + 1)) + key = hashlib.sha256(f"{request['voice']}\n{request['text']}".encode()).hexdigest() + self.assertEqual(sorted(path.name for path in cache.iterdir()), [f"{key}.wav"]) + self.assertEqual(self.post_json("/api/tts", {**request, "text": " all stations,\nthis is Docs, board, out "}, good)[2], body) + self.assertEqual(self.post_json("/api/tts", {**request, "text": "x" * phrasing.SPEECH_LIMIT}, good)[0], 200) + rows = log_rows(calls) + model = self.engines.voices / "en_US-test-high.onnx" + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["argv"][:5], ["--model", str(model), "--config", f"{model}.json", "--output-file"]) + self.assertEqual(rows[0]["argv"][6:], ["--speaker", "2"]) + self.assertTrue(rows[0]["argv"][5].startswith(f"{cache}/.{key}-")) + self.assertEqual((rows[0]["text"], rows[0]["tmp"], rows[0]["cwd"]), (f"{request['text']}\n", str(self.engines.scratch), str(self.engines.scratch))) + self.assertEqual(self.post_json("/api/tts", {"text": "solo", "voice": "de_DE-solo-medium:0"}, good)[0], 200) + self.assertNotIn("--speaker", log_rows(calls)[-1]["argv"]) + status, _, body = self.post_json("/api/tts", {**request, "text": "FAIL now"}, good) + self.assertEqual(status, 502, body) + self.assertIn(b"exit code 3", body) + self.assertEqual([path.name for path in cache.iterdir() if path.name.startswith(".")], []) + with mock.patch.object(engines, "TTS_KEEP", 2): + for number in range(3): + self.assertEqual(self.post_json("/api/tts", {**request, "text": f"line {number}"}, good)[0], 200) + kept = {hashlib.sha256(f"{request['voice']}\nline {number}".encode()).hexdigest() + ".wav" for number in (1, 2)} + self.assertEqual({path.name for path in cache.iterdir()}, kept) + with mock.patch.object(self.httpd.hub.speech, "voices", {}): + self.assertEqual(self.post_json("/api/tts", request, good)[0], 503) + self.assertEqual(self.scripts.calls, []) + + def test_speech_input_checks_the_guard_type_size_and_format(self): + good = self.guard(self.host) + calls = self.engines.scratch / "whisper.jsonl" + clip = wav_bytes(1, 2, engines.STT_RATE, b"\x01\x00" * engines.STT_RATE) + + def post(body: bytes, headers: dict[str, str]) -> tuple[int, dict, bytes]: + return self.call("POST", "/api/stt", body, {**headers, "Content-Type": "audio/wav"}) + + for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}): + with self.subTest(headers=headers): + self.assertEqual(post(clip, headers)[0], 403) + for content_type in ("application/octet-stream", "audio/x-wav", "text/plain", "application/json"): + with self.subTest(content_type=content_type): + self.assertEqual(self.call("POST", "/api/stt", clip, {**good, "Content-Type": content_type})[0], 415) + self.assertEqual(self.call("POST", "/api/stt", clip, good)[0], 415) + self.assertEqual(self.call("POST", "/api/stt", b"RIFF", {**good, "Content-Type": "audio/wav", "Content-Length": str(engines.STT_LIMIT + 1)})[0], 413) + for broken in ( + b"not a wav", + wav_bytes(1, 2, 44100, b"\x00\x00" * 100), + wav_bytes(2, 2, engines.STT_RATE, b"\x00\x00" * 200), + wav_bytes(1, 1, engines.STT_RATE, b"\x00" * 100), + wav_bytes(1, 2, engines.STT_RATE, b""), + ): + with self.subTest(broken=broken[:40]): + self.assertEqual(post(broken, good)[0], 400) + with mock.patch.object(engines, "STT_SECONDS", 1): + self.assertEqual(post(wav_bytes(1, 2, engines.STT_RATE, b"\x00\x00" * (engines.STT_RATE + 1)), good)[0], 400) + self.assertEqual(log_rows(calls), []) + status, headers, body = post(clip, good) + self.assertEqual((status, headers["content-type"]), (200, "application/json"), body) + self.assertEqual(json.loads(body), {"text": "hello from the mic second line"}) + argv = ["--model", str(self.engines.whisper_model), "--file", "-", "--output-txt", "--language", "auto", "--no-prints", "--suppress-nst"] + self.assertEqual(log_rows(calls), [{"argv": argv, "sha256": hashlib.sha256(clip).hexdigest()}]) + long = wav_bytes(1, 2, engines.STT_RATE, b"\x00\x00" * engines.STT_RATE * 40) + self.assertGreater(len(long), handler.BODY_LIMIT) + self.assertEqual(post(long, good)[0], 200) + status, _, body = post(wav_bytes(1, 2, engines.STT_RATE, b"FAIL" * 100), good) + self.assertEqual(status, 502, body) + with mock.patch.object(self.httpd.hub.speech, "hears", return_value=False): + self.assertEqual(post(clip, good)[0], 503) + self.assertEqual(self.scripts.calls, []) + self.assertEqual(self.get("/api/stt")[0], 405) + + def test_the_server_requires_every_engine_argument(self): + args = { + "--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state), + "--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"), "--repos": str(self.repos), + "--worktrees": str(self.worktrees), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "", + "--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper), + "--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers), + } + + def run(given: dict[str, str]) -> subprocess.CompletedProcess: + argv = [part for pair in given.items() for part in pair] + return subprocess.run([sys.executable, str(HUB / "server.py"), *argv], capture_output=True, text=True, timeout=60, check=False) + + for name in ("--piper", "--voices", "--operator-voice", "--whisper", "--whisper-model", "--scratch", "--approvers"): + with self.subTest(name=name): + done = run({key: value for key, value in args.items() if key != name}) + self.assertEqual(done.returncode, 2, done.stderr) + self.assertIn(f"required: {name}", done.stderr) + done = run(args) + self.assertEqual(done.returncode, 2, done.stderr) + self.assertIn("the operator voice 'en_US-test-high:9' is not installed", done.stderr) + + +class TestPhraseology(unittest.TestCase): + def test_every_class_speaks_in_maritime_phraseology(self): + cases = ( + (("mayday", "Docs", [], "MAYDAY disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"), + (("mayday", "Docs", ["Skills"], "mayday, mayday: disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"), + (("pan-pan", "Docs", ["Skills"], "PAN-PAN: slow"), "PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over"), + (("security", "Maintainer", ["Skills", "Docs"], "SECURITE rotate"), "SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over"), + (("direct", "Skills", ["Maintainer"], "ready"), "Maintainer, Maintainer, this is Skills, ready, over"), + (("direct", "Skills", ["Alpha", "Beta"], "ready"), "Alpha and Beta, Alpha and Beta, this is Skills, ready, over"), + (("direct", "Docs", ["Ops"], "MAYDAY is only a word here"), "Ops, Ops, this is Docs, MAYDAY is only a word here, over"), + (("direct", "Skills", [], "lost target"), "all stations, this is Skills, lost target, out"), + (("broadcast", "Maintainer", [], "board"), "all stations, this is Maintainer, board, out"), + (("broadcast", "Operator", [], "[kvb-hub msg 0123456789abcdef] deploy now file: /home/op/x.txt"), "all stations, this is Operator, deploy now, out"), + (("broadcast", "Docs", [], " two\n lines\t here "), "all stations, this is Docs, two lines here, out"), + (("broadcast", "", [], "-"), "all stations, this is unknown station, out"), + ) + for arguments, expected in cases: + with self.subTest(arguments=arguments): + self.assertEqual(phrasing.phrase(*arguments), expected) + + def test_a_phrase_fits_the_speech_limit_on_one_line(self): + for cls in council.CLASSES: + with self.subTest(cls=cls): + spoken = phrasing.phrase(cls, "n" * 900, ["a\nb " * 300] * 9, "word\n" * 9000) + self.assertLessEqual(len(spoken), phrasing.SPEECH_LIMIT) + self.assertTrue(phrasing.SPOKEN_LINE.fullmatch(spoken)) + self.assertTrue(phrasing.phrase("broadcast", "Docs", [], "word " * 300).endswith("word, out")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_state.py b/tests/test_hub_state.py new file mode 100644 index 0000000..be750f3 --- /dev/null +++ b/tests/test_hub_state.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import json +import time +import unittest +from unittest import mock + +from hub_case import HubCase +from hub_engines import OPERATOR_VOICE, VOICES +from hub_support import ASK +from kvb_hub import council, engines, exchange, phrasing, view + + +class TestHubState(HubCase): + def test_state_follows_the_time_window(self): + now = time.time() + self.write_council({"news.jsonl": self.jsonl([ + {"ts": now - 10800, "from": "Old", "to": [], "text": "three hours"}, + {"ts": now - 3600, "from": "Skills", "to": ["Docs"], "text": "one hour"}, + {"ts": now - 60, "from": "Docs", "to": [], "text": "a minute"}, + ])}) + self.request("feat: fresh", "") + self.create_ask(ASK) + default = self.snapshot() + self.assertEqual([row["text"] for row in default["radio"]], ["three hours", "one hour", "a minute"]) + self.assertAlmostEqual(default["first"], now - 10800, delta=1) + status, _, body = self.get(f"/api/state?from={now - 7200}&to={now - 1800}") + self.assertEqual(status, 200, body) + past = json.loads(body) + self.assertEqual([row["text"] for row in past["radio"]], ["one hour"]) + self.assertEqual((past["since"], past["until"]), (now - 7200, now - 1800)) + self.assertEqual((past["reviews"], past["asks"]), ([], [])) + self.assertEqual({(edge["source"], edge["target"]) for edge in past["graph"]["edges"]}, {("name:Skills", "name:Docs")}) + future = json.loads(self.get(f"/api/state?from=0&to={now + 86400}")[2]) + self.assertLessEqual(future["until"], time.time()) + self.assertEqual(len(future["radio"]), 3) + for query in (f"from={now}&to={now - 60}", "from=soon", "to=-1", "from=1&from=2", "from=nan"): + with self.subTest(query=query): + self.assertEqual(self.get(f"/api/state?{query}")[0], 400) + + def test_get_never_changes_state(self): + review_id = self.request("feat: read only", "") + before = (self.state / "reviews.json").read_bytes() + for path, expected in ( + ("/", 200), + ("/?approve=1", 200), + ("/api/state", 200), + (f"/reviews/{review_id}/page", 200), + (f"/reviews/{review_id}/files", 200), + ("/reviews/0123456789abcdef/files", 404), + (f"/reviews/{review_id}/approve", 405), + (f"/reviews/{review_id}/reject", 405), + (f"/api/reviews/{review_id}", 200), + ("/api/reviews/0123456789abcdef", 404), + ("/api/sign", 405), + ("/api/rules/sign", 405), + ("/mcp", 405), + ("/api/tts", 405), + ("/api/stt", 405), + ("/reviews/0123456789abcdef/page", 404), + ("/nope", 404), + ): + with self.subTest(path=path): + status, _, _ = self.call("GET", path, None, self.guard(self.host)) + self.assertEqual(status, expected) + self.assertEqual(self.call("GET", "/", None, {"Host": "evil.example"})[0], 403) + self.assertEqual((self.state / "reviews.json").read_bytes(), before) + self.assertEqual(self.status(review_id), "pending") + + def test_state_is_empty_without_council_files(self): + empty = {"reviews": [], "operator": [], "register": [], "radio": [], "claims": [], "grants": []} + state = self.snapshot() + self.assertEqual({key: state[key] for key in empty}, empty) + self.assertEqual([(node["id"], node["kind"]) for node in state["graph"]["nodes"]], [("Operator", "operator"), ("name:Maintainer", "chair")]) + self.assertEqual(state["graph"]["edges"], []) + self.index() + self.write_council({"rasci.json": "{not json", "queue-history.jsonl": "[" * 100000 + "\n", "work/x/claim.json": "[1]"}) + (self.council / "news.jsonl").write_bytes(b"\xff\xfe" + b"[" * 100000 + b"\n") + state = self.snapshot() + self.assertEqual({key: state[key] for key in empty}, empty) + self.write_council({"rasci.json": "[1, 2]", "news.jsonl": "[" * 100000 + "\n"}) + self.assertEqual(self.snapshot()["radio"], []) + self.index() + + def test_state_filters_the_register_and_bounds_the_radio(self): + register = { + "operator/push-skills": {"title": "git-sign-push", "workdir": "/repo", "description": "needs the key", "check": "pushed", "status": "queued", "roles": {"Operator": "R", "One": "A"}}, + "operator/old": {"title": "old command", "status": "done", "roles": {}}, + "work/hub": {"title": "Build the hub", "status": "running", "worker": "One", "objective": "Reviews in the browser", "roles": {"One": "RA", "Two": "C"}, "key_results": [{"text": "a", "met": True}, {"text": "b", "met": False}]}, + "work/finished": {"title": "Finished work", "status": "done", "roles": {}}, + "work/stopped": {"title": "Stopped work", "status": "stopped", "roles": {}}, + "work/bare": {"roles": {"Two": "R"}}, + "broken": "not a row", + } + now = time.time() + rows = [{"ts": now - 100 + n, "from": "One", "to": [], "text": f"item {n:02d}"} for n in range(34)] + rows.append({"ts": now, "from": "Two", "to": [], "text": "disk full", "class": "mayday"}) + lines = [json.dumps(row) for row in rows[:20]] + ["not json", json.dumps({"text": "no ts"})] + [json.dumps(row) for row in rows[20:]] + self.write_council({"rasci.json": register, "news.jsonl": "\n".join(lines) + "\n"}) + with mock.patch.object(council, "RADIO_LIMIT", 30): + state = self.snapshot() + operator = state["operator"] + self.assertEqual([(row["task"], row["command"], row["why"], row["status"]) for row in operator], [("operator/push-skills", "git-sign-push", "needs the key", "queued")]) + self.assertEqual([row["task"] for row in state["register"]], ["work/bare", "work/hub"]) + hub = state["register"][1] + self.assertEqual((hub["title"], hub["responsible"], hub["accountable"], hub["met"], hub["total"]), ("Build the hub", "One", "One", 1, 2)) + self.assertEqual(state["register"][0]["status"], "queued") + texts = [row["text"] for row in state["radio"]] + self.assertEqual(len(texts), 30) + self.assertEqual((texts[0], texts[-2], texts[-1]), ("item 05", "item 33", "disk full")) + self.assertEqual(state["radio"][-1]["class"], "mayday") + + def test_state_collects_radio_claims_grants_and_the_graph(self): + now = time.time() + register = { + "operator/push": {"title": "git-sign-push", "workdir": "/repo", "status": "blocked", "roles": {"Operator": "R", "Skills session": "A"}}, + "work/hub": {"title": "Build the hub", "status": "running", "worker": "Skills session", "workdir": "/repo/.worktrees/hub", "roles": {"Skills session": "R", "Maintainer": "A"}}, + "work/sub": {"title": "Helper", "status": "running", "worker": "subagent general-purpose (default)", "roles": {"Skills": "A"}}, + "work/old": {"title": "Old", "status": "done", "worker": "Ghost session", "roles": {}}, + } + news = [ + {"ts": now - 50, "from": "Skills session", "to": ["Maintainer"], "text": "ready"}, + {"ts": now - 40, "from": "Skills", "to": ["Maintainer"], "text": "again", "class": "direct"}, + {"ts": now - 30, "from": "Maintainer", "to": [], "text": "board"}, + {"ts": now - 20, "from": "Docs", "to": [], "text": "MAYDAY disk full"}, + {"ts": now - 10, "from": "Docs", "to": ["Skills"], "text": "PAN-PAN slow"}, + {"ts": now - 5, "from": "Maintainer", "to": ["Skills", "Docs"], "text": "rotate", "class": "security"}, + {"ts": now - 2 * 86400, "from": "Old", "to": [], "text": "too old"}, + ] + claim = {"office": "work/hub", "session": "s1", "runtime": "claude", "name": "Skills", "since": "2026-11-05T10:00:00+00:00", "beat": now, "work": "build the hub"} + gate = {"office": "cluster/gate", "session": "s2", "runtime": "codex", "name": "Gatekeeper", "since": "2026-11-05T09:00:00+00:00", "beat": now, "work": "gate"} + history = [ + {"id": "q1", "kind": "gate", "signature": "make test", "workdir": "/repo/.worktrees/hub/sub", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2}, + {"id": "q2", "kind": "deploy", "signature": "make deploy", "workdir": "/elsewhere", "start": now - 15, "end": now - 5, "exit": 0, "minutes": 0.2}, + {"id": "q3", "kind": "gate", "signature": "make test", "workdir": "/repo/.worktrees/hub", "start": now - 2 * 86400, "end": now - 2 * 86400 + 60, "exit": 0, "minutes": 1}, + ] + self.write_council({ + "rasci.json": register, + "news.jsonl": self.jsonl(news), + "work/hub/claim.json": claim, + "cluster/gate/claim.json": gate, + "work/broken/claim.json": "not json", + "queue-history.jsonl": self.jsonl(history), + }) + state = self.snapshot() + self.assertEqual( + set(state), + { + "now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator", + "register", "priority", "radio", "claims", "grants", "night", "silence", "graph", + "inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech", + }, + ) + self.assertEqual([row["speech"] for row in state["radio"]], [ + "Maintainer, Maintainer, this is Skills, ready, over", + "Maintainer, Maintainer, this is Skills, again, over", + "all stations, this is Maintainer, board, out", + "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over", + "PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over", + "SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over", + ]) + speech = self.httpd.hub.speech + self.assertEqual([row["voice"] for row in state["radio"]], [speech.voice_of(row["source"]) for row in state["radio"]]) + self.assertTrue(all(row["voice"] in VOICES and row["voice"] != OPERATOR_VOICE for row in state["radio"])) + self.assertEqual(state["speech"], { + "tts": True, "stt": True, "voices": len(VOICES), "operator": OPERATOR_VOICE, "sample": "all stations, this is Operator, radio check, out", + "limit": phrasing.SPEECH_LIMIT, "rate": engines.STT_RATE, "seconds": engines.STT_SECONDS, + }) + self.assertAlmostEqual(state["now"] - state["since"], view.RADIO_WINDOW) + self.assertEqual((state["night"], state["silence"], state["priority"]), ("day", None, ["Skills session", "subagent general-purpose (default)"])) + radio = [(row["from"], row["to"], row["class"], row["text"]) for row in state["radio"]] + self.assertEqual(radio, [ + ("Skills", ["Maintainer"], "direct", "ready"), + ("Skills", ["Maintainer"], "direct", "again"), + ("Maintainer", [], "broadcast", "board"), + ("Docs", [], "mayday", "MAYDAY disk full"), + ("Docs", ["Skills"], "pan-pan", "PAN-PAN slow"), + ("Maintainer", ["Skills", "Docs"], "security", "rotate"), + ]) + self.assertEqual([(row["office"], row["agent"], row["runtime"]) for row in state["claims"]], [("cluster/gate", "Gatekeeper", "codex"), ("work/hub", "Skills", "claude")]) + self.assertEqual([(row["to"], row["kind"], row["id"]) for row in state["grants"]], [("Skills", "gate", "q1")]) + self.assertEqual([row["task"] for row in state["operator"]], ["operator/push"]) + nodes = {node["id"]: node for node in state["graph"]["nodes"]} + moon = "subagent general-purpose (default) @ s1" + self.assertEqual(list(nodes), ["Operator", "name:Maintainer", "name:Docs", "s2", "s1", moon]) + self.assertEqual([nodes[key]["kind"] for key in nodes], ["operator", "chair", "session", "session", "session", "subagent"]) + self.assertEqual([nodes[key]["label"] for key in nodes][:5], ["Operator", "Maintainer", "Docs", "Gatekeeper", "Skills"]) + self.assertEqual((nodes[moon]["parent"], nodes[moon]["label"]), ("s1", "subagent general-purpose (default)")) + skills = [(task["task"], task["worker"], task["letters"]) for task in nodes["s1"]["tasks"]] + self.assertEqual(skills, [("operator/push", False, "A"), ("work/hub", True, "R"), ("work/sub", False, "A")]) + self.assertEqual([(task["task"], task["letters"]) for task in nodes["Operator"]["tasks"]], [("operator/push", "R")]) + self.assertEqual([task["task"] for task in nodes[moon]["tasks"]], ["work/sub"]) + self.assertEqual([claim["office"] for claim in nodes["s1"]["claims"]], ["work/hub"]) + self.assertEqual((state["people"]["s1"]["now"], state["radio"][0]["source"], state["grants"][0]["target"]), ("Skills", "s1", "s1")) + edges = {(edge["source"], edge["target"], edge["class"]): edge["count"] for edge in state["graph"]["edges"]} + self.assertEqual(edges, { + ("name:Docs", "*", "mayday"): 1, + ("name:Docs", "s1", "pan-pan"): 1, + ("name:Maintainer", "*", "broadcast"): 1, + ("name:Maintainer", "name:Docs", "security"): 1, + ("name:Maintainer", "s1", "grant"): 1, + ("name:Maintainer", "s1", "security"): 1, + ("s1", "name:Maintainer", "direct"): 2, + }) + + def test_state_needs_the_host(self): + for host in ("evil.example", f"evil.example:{self.port}", "127.0.0.1:1"): + with self.subTest(host=host): + self.assertEqual(self.call("GET", "/api/state", None, {"Host": host})[0], 403) + status, headers, _ = self.get("/api/state") + self.assertEqual(status, 200) + self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP) + self.assertEqual(self.call("POST", "/api/state", b"", self.guard(self.host))[0], 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_stream.py b/tests/test_hub_stream.py new file mode 100644 index 0000000..c4b2542 --- /dev/null +++ b/tests/test_hub_stream.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import os +import time +import unittest +from unittest import mock + +from hub_case import HubCase +from hub_support import ASK, iso +from kvb_hub import events, transcripts + + +class TestHubStream(HubCase): + def test_stream_reads_transcripts_with_filters_and_agent_names(self): + now = time.time() + self.claim("s1", "Skills session") + main = self.projects / "-repo" / "s1.jsonl" + sub = self.projects / "-repo" / "s1" / "subagents" / "agent-abc.jsonl" + other = self.projects / "-other" / "s2.jsonl" + old = self.projects / "-other" / "s3.jsonl" + for path in (main.parent, sub.parent, other.parent): + path.mkdir(parents=True, exist_ok=True) + main.write_text(self.jsonl([ + {"type": "user", "sessionId": "s1", "timestamp": iso(now - 50), "message": {"role": "user", "content": "hello agent"}}, + {"type": "assistant", "sessionId": "s1", "timestamp": iso(now - 40), "message": {"role": "assistant", "content": [ + {"type": "thinking", "thinking": "hidden"}, {"type": "text", "text": "on it"}, {"type": "tool_use", "name": "Bash", "input": {"command": "ls"}}]}}, + {"type": "user", "sessionId": "s1", "timestamp": iso(now - 30), "message": {"role": "user", "content": [{"type": "tool_result", "content": "x" * 2500}]}}, + {"type": "system", "sessionId": "s1", "timestamp": iso(now - 20), "content": "compacted"}, + {"type": "attachment", "sessionId": "s1", "timestamp": iso(now - 20), "attachment": {}}, + ]) + "not json\n", encoding="utf-8") + sub.write_text(self.jsonl([{"type": "user", "sessionId": "s1", "agentId": "abc", "timestamp": iso(now - 10), "message": {"role": "user", "content": "SUB task"}}]), encoding="utf-8") + other.write_text(self.jsonl([{"type": "user", "sessionId": "s2", "timestamp": iso(now - 5), "message": {"role": "user", "content": "other"}}]), encoding="utf-8") + old.write_text(self.jsonl([{"type": "user", "sessionId": "s3", "timestamp": iso(now - 9 * 86400), "message": {"role": "user", "content": "ancient"}}]), encoding="utf-8") + os.utime(old, (now - 9 * 86400, now - 9 * 86400)) + + def entries(query: str) -> list[dict]: + status, _, body = self.get(f"/api/stream?{query}") + self.assertEqual(status, 200, body) + return json.loads(body)["entries"] + + with mock.patch.object(transcripts, "STREAM_SCAN", 0): + rows = entries("") + self.assertEqual([(row["kind"], row["agent"], row["sub"], row["text"][:11]) for row in rows], [ + ("user", "Skills", "", "hello agent"), + ("assistant", "Skills", "", "on it"), + ("tool_call", "Skills", "", 'Bash {"comm'), + ("tool_result", "Skills", "", "xxxxxxxxxxx"), + ("system", "Skills", "", "compacted"), + ("user", "Skills", "abc", "SUB task"), + ("user", "", "", "other"), + ]) + result = rows[3] + self.assertEqual((len(result["text"]), result["truncated"]), (2000, True)) + status, _, body = self.get(f"/api/stream/{result['id']}") + self.assertEqual((status, len(json.loads(body)["text"])), (200, 2500)) + self.assertEqual(self.get("/api/stream/999999")[0], 404) + self.assertEqual(len(entries("agent=Skills")), 6) + self.assertEqual([row["text"] for row in entries("session=s2")], ["other"]) + self.assertEqual([row["kind"] for row in entries("kind=tool_call,system")], ["tool_call", "system"]) + self.assertEqual([row["text"] for row in entries("q=sub")], ["SUB task"]) + self.assertEqual([row["text"] for row in entries(f"from={now - 45}&to={now - 15}")][1:], ["Bash {\"command\": \"ls\"}", "x" * 2000, "compacted"]) + self.assertEqual(len(entries("limit=2")), 2) + for query in ("kind=bogus", "from=soon", "after=-1", "limit=x"): + with self.subTest(query=query): + self.assertEqual(self.get(f"/api/stream?{query}")[0], 400) + status, _, body = self.get("/api/stream") + last = json.loads(body)["last"] + with main.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"type": "user", "sessionId": "s1", "timestamp": iso(now), "message": {"role": "user", "content": "fresh"}}) + "\n") + handle.write('{"type": "user", "partial') + self.assertEqual([row["text"] for row in entries(f"after={last}")], ["fresh"]) + self.assertNotIn("ancient", json.dumps(entries(""))) + + def test_saved_views_filter_the_event_stream(self): + now = time.time() + self.claim("s1", "Skills session") + register = { + "operator/push": {"title": "git-sign-push", "workdir": "/repo", "status": "queued", "check": "manual", "roles": {}}, + "work/a": {"title": "A", "status": "queued", "roles": {}}, + } + news = [{"ts": now - 30, "from": "Docs", "to": ["Skills"], "text": "hello"}, {"ts": now - 20, "from": "Docs", "to": [], "text": "MAYDAY disk full"}] + self.write_council({"rasci.json": register, "news.jsonl": self.jsonl(news)}) + self.request("feat: view", "") + self.create_ask(ASK) + self.decision("") + + def kinds(query: str) -> list[str]: + return [row["kind"] for row in self.events(query)["events"]] + + self.assertEqual(sorted(set(kinds("view=fuer-dich"))), ["ask", "decision", "operator", "radio", "review"]) + self.assertEqual([row["text"] for row in self.events("view=fuer-dich")["events"] if row["kind"] == "radio"], ["MAYDAY disk full"]) + self.assertEqual(kinds("view=reviews"), ["review"]) + self.assertEqual(kinds("view=radio"), ["radio", "radio"]) + self.assertEqual(kinds("view=operator"), ["operator"]) + self.assertEqual(kinds("view=entscheidungen"), ["decision"]) + self.assertEqual(kinds("kinds=radio&q=mayday"), ["radio"]) + self.assertEqual(kinds("kinds=ask,decision&waiting=1"), ["ask", "decision"]) + self.assertEqual(kinds("kinds=radio&agent=Docs"), ["radio", "radio"]) + self.assertEqual(kinds("view=register"), []) + self.write_council({"rasci.json": {**register, "work/a": {**register["work/a"], "status": "running"}}}) + self.assertEqual([(row["title"], row["text"]) for row in self.events("view=register")["events"]], [("work/a", "status: queued → running")]) + for query in ("view=bogus", "kinds=bogus", "parts=bogus", "after=x"): + with self.subTest(query=query): + self.assertEqual(self.get(f"/api/events?{query}")[0], 400) + sample = events.event({"id": "x", "ts": now, "kind": "review", "title": "feat: x", "waiting": True}) + filters = events.event_filters({"view": ["reviews"]}) + self.assertTrue(events.wanted(sample, filters)) + self.assertFalse(events.wanted({**sample, "kind": "radio"}, filters)) + self.assertFalse(events.wanted({**sample, "waiting": False}, events.event_filters({"view": ["fuer-dich"]}))) + + def test_crons_come_from_transcripts_timers_and_the_queue(self): + now = time.time() + self.claim("s1", "Skills session") + main = self.projects / "-repo" / "s1.jsonl" + main.parent.mkdir(parents=True) + + def use(ts: float, ident: str, name: str, data: dict) -> dict: + return {"type": "assistant", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "assistant", "content": [{"type": "tool_use", "id": ident, "name": name, "input": data}]}} + + def result(ts: float, ident: str, text: str) -> dict: + return {"type": "user", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": ident, "content": text}]}} + + main.write_text(self.jsonl([ + use(now - 60, "t1", "CronCreate", {"cron": "*/5 * * * *", "prompt": "check deploy", "recurring": True}), + result(now - 59, "t1", "Scheduled recurring job c0ac4c97 (Every 5 minutes). Session-only."), + use(now - 50, "t2", "CronCreate", {"cron": "7 * * * *", "prompt": "iterate", "recurring": True}), + result(now - 49, "t2", "Scheduled recurring job 0a929f2d (Every hour at :07)."), + use(now - 40, "t3", "CronDelete", {"id": "c0ac4c97"}), + use(now - 35, "t4", "ScheduleWakeup", {"delaySeconds": 60, "reason": "first", "prompt": "/loop"}), + use(now - 30, "t5", "ScheduleWakeup", {"delaySeconds": 1200, "reason": "fallback", "prompt": "/loop"}), + use(now - 20, "t6", "Bash", {"command": "make test", "run_in_background": True, "description": "gate"}), + use(now - 10, "t7", "Bash", {"command": "ls"}), + ]), encoding="utf-8") + (self.timers / "kvb-dream-notify.timer").write_text("[Unit]\nDescription=Daily dream\n[Timer]\nOnCalendar=*-*-* 08:00:00\n", encoding="utf-8") + (self.timers / "timers.target.wants").mkdir() + (self.timers / "timers.target.wants" / "kvb-dream-notify.timer").write_text("", encoding="utf-8") + (self.timers / "old.timer").write_text("[Timer]\nOnUnitActiveSec=1h\n", encoding="utf-8") + entry = {"id": "gate-1", "kind": "gate", "task": "work/hub", "status": "active", "enqueued": now - 5, "cmd": "make test", "name": "Skills session", "session": "s1"} + self.write_council({"queue.json": {"seq": 1, "entries": [entry, "broken"]}}) + + def crons() -> dict: + with mock.patch.object(transcripts, "STREAM_SCAN", 0): + rows = self.events("view=crons")["events"] + return {row["title"]: (row["ref"]["active"], row["agent"], row["text"]) for row in rows} + + self.assertEqual(crons(), { + "Cron: */5 * * * *": (False, "Skills", "check deploy"), + "Cron: 7 * * * *": (True, "Skills", "iterate"), + "Wakeup: in 60 s": (False, "Skills", "/loop\nfirst"), + "Wakeup: in 1200 s": (True, "Skills", "/loop\nfallback"), + "Hintergrund: gate": (True, "Skills", "make test"), + "Timer: *-*-* 08:00:00": (True, "", "Daily dream\nkvb-dream-notify.timer"), + "Timer: 1h": (False, "", "old.timer"), + "Queue: gate · läuft": (True, "Skills", "make test\nwork/hub"), + }) + self.write_council({"work/s1/claim.json": {"office": "work/s1", "session": "s1", "runtime": "claude", "name": "Skills session", "since": "2026-11-05T08:00:00+00:00", "beat": now - 3600, "work": "x"}}) + self.assertEqual(crons()["Cron: 7 * * * *"][0], False) + subagents = self.projects / "-repo" / "s1" / "subagents" + subagents.mkdir(parents=True) + (subagents / "agent-abc.meta.json").write_text(json.dumps({"agentType": "general-purpose", "description": "review", "spawnDepth": 1, "finished": True, "toolUseId": "t8"}), encoding="utf-8") + (subagents / "agent-def.meta.json").write_text(json.dumps({"agentType": "Explore", "description": "find", "spawnDepth": 2, "finished": False, "toolUseId": "t9"}), encoding="utf-8") + status, _, body = self.get("/api/subagents?session=s1") + self.assertEqual(status, 200, body) + self.assertEqual([(row["id"], row["type"], row["depth"], row["finished"]) for row in json.loads(body)["subagents"]], [("abc", "general-purpose", 1, True), ("def", "Explore", 2, False)]) + self.assertEqual(self.get("/api/subagents?session=../x")[0], 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_tree.py b/tests/test_hub_tree.py new file mode 100644 index 0000000..cacb22f --- /dev/null +++ b/tests/test_hub_tree.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json +import unittest + +from hub_case import HubCase +from hub_support import PAGE + + +class TestHubTree(HubCase): + def test_tree_lists_repos_branches_and_worktrees(self): + origin = self.outside / "origin.git" + self.git(self.outside, "init", "--quiet", "--bare", "--initial-branch=main", str(origin)) + repo = self.repos / "github.com" / "me" / "demo" + repo.mkdir(parents=True) + self.git(repo, "init", "--quiet", "--initial-branch=main") + (repo / "a.txt").write_text("a", encoding="utf-8") + self.git(repo, "add", "a.txt") + self.git(repo, "commit", "--quiet", "-m", "a") + self.git(repo, "remote", "add", "origin", str(origin)) + self.git(repo, "push", "--quiet", "-u", "origin", "main") + (repo / "b.txt").write_text("b", encoding="utf-8") + self.git(repo, "add", "b.txt") + self.git(repo, "commit", "--quiet", "-m", "b") + feature = self.worktrees / "github.com" / "me" / "demo" / "feature" + self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(feature)) + (feature / "dirty.txt").write_text("x", encoding="utf-8") + other = self.outside / "elsewhere" + other.mkdir() + self.git(other, "init", "--quiet", "--initial-branch=main") + (other / "c.txt").write_text("c", encoding="utf-8") + self.git(other, "add", "c.txt") + self.git(other, "commit", "--quiet", "-m", "c") + lone = self.worktrees / "x" / "y" / "lone" + self.git(other, "worktree", "add", "--quiet", "-b", "lone", str(lone)) + feature, repo, other, lone = (path.resolve() for path in (feature, repo, other, lone)) + self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "worker": "Skills session", "workdir": str(feature)}}}) + self.tool("review_request", {**self.arguments("feat: tree", "", self.page("tree.html", PAGE)), "worktree": str(feature), "branch": "feature"}) + status, _, body = self.get("/api/tree") + self.assertEqual(status, 200, body) + data = json.loads(body) + repos = {entry["name"]: entry for entry in data["repos"]} + self.assertEqual(set(repos), {"github.com/me/demo", str(other)}) + demo = {entry["name"]: entry for entry in repos["github.com/me/demo"]["branches"]} + self.assertEqual((demo["main"]["upstream"], demo["main"]["ahead"], demo["main"]["behind"]), ("origin/main", 1, 0)) + trees = [(tree["path"], tree["dirty"], tree["workers"], tree["review"]) for tree in demo["main"]["worktrees"] + demo["feature"]["worktrees"]] + self.assertEqual(trees, [(str(repo), False, [], None), (str(feature), True, ["Skills"], {"status": "pending", "gate": "pass", "title": "feat: tree"})]) + self.assertEqual([tree["path"] for tree in {entry["name"]: entry for entry in repos[str(other)]["branches"]}["lone"]["worktrees"]], [str(lone)]) + self.assertEqual(json.loads(self.get("/api/tree")[2])["scanned"], data["scanned"]) + + def test_tree_runs_no_command_a_repo_config_names(self): + marker = self.outside / "ran" + trap, filtered = (self.repos / "github.com" / "me" / name for name in ("trap", "filtered")) + for repo in (trap, filtered): + repo.mkdir(parents=True) + self.git(repo, "init", "--quiet", "--initial-branch=main") + (repo / "a.txt").write_text("a", encoding="utf-8") + self.git(repo, "add", "a.txt") + self.git(repo, "commit", "--quiet", "-m", "a") + self.git(trap, "config", "core.fsmonitor", f"touch {marker}") + self.git(filtered, "config", "filter.x.clean", f"touch {marker}") + (filtered / ".gitattributes").write_text("* filter=x\n", encoding="utf-8") + (filtered / "a.txt").write_text("b", encoding="utf-8") + status, _, body = self.get("/api/tree") + self.assertEqual(status, 200, body) + dirty = {entry["name"]: entry["branches"][0]["worktrees"][0]["dirty"] for entry in json.loads(body)["repos"]} + self.assertEqual(dirty, {"github.com/me/trap": False, "github.com/me/filtered": None}) + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_voice.py b/tests/test_hub_voice.py new file mode 100644 index 0000000..5f42171 --- /dev/null +++ b/tests/test_hub_voice.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import base64 +import io +import json +import struct +import subprocess +import unittest +import wave + +from hub_support import VOICE +from kvb_hub import engines, settings + +VOICE_PROBE = """ +const V = require(process.argv[1]); +const settings = JSON.parse(process.argv[2]); +const rows = [ + {class: "mayday", to: [], source: "s1", speech: "m", voice: "v"}, + {class: "pan-pan", to: ["Operator"], source: "s1", speech: "p", voice: "v"}, + {class: "security", to: [], source: "s2", speech: "s", voice: "v"}, + {class: "direct", to: ["Operator"], source: "s2", speech: "d", voice: "v"}, + {class: "direct", to: ["Docs"], source: "s3", speech: "o", voice: "v"}, + {class: "broadcast", to: [], source: "s3", speech: "b", voice: "v"}, + {class: "broadcast", to: [], source: "s3", speech: "", voice: "v"}, +]; +const filters = [ + {on: false, classes: ["broadcast", "direct", "security", "pan-pan", "mayday"], muted: []}, + {on: true, classes: ["mayday"], muted: ["s1"]}, + {on: true, classes: ["direct", "mayday"], muted: ["s2"]}, + {on: true, classes: ["broadcast", "direct", "security", "pan-pan", "mayday"], muted: []}, +]; +let pending = []; +for (const cls of ["broadcast", "direct", "security", "mayday", "broadcast", "pan-pan", "mayday", "direct"]) { + pending = V.enqueue(pending, {cls, text: `${cls}-${pending.length}`}, 6); +} +console.log(JSON.stringify({ + tones: rows.map((row) => V.toneOf(row, "Operator")), + spoken: filters.map((filter) => rows.map((row) => V.speakable(row, filter))), + queue: pending.map((item) => item.cls), + loud: settings.map((one) => Object.fromEntries(["mayday", "review", "direct", "radio"].map((tone) => [tone, V.loudness(tone, one)]))), + frames: [V.frames(1.5, 16000, 120), V.frames(130, 16000, 120), V.frames(0, 16000, 120)], + wav: Buffer.from(V.wav(Float32Array.from([0, 0.5, -0.5, 1, -1, 2, -2]), 16000)).toString("base64"), +})); +""" + + +class TestVoiceModule(unittest.TestCase): + @classmethod + def setUpClass(cls): + quiet = {name: {"on": False, "volume": 0.1} for name in settings.TONES} + cls.settings = [ + settings.SETTINGS, + {**settings.SETTINGS, "music": {"on": True, "volume": 1.0}, "sounds": quiet}, + {**settings.SETTINGS, "sounds": {**settings.SETTINGS["sounds"], "mayday": {"on": True, "volume": 0.0}, "radio": {"on": True, "volume": 0.8}}}, + ] + command = ["node", "-e", VOICE_PROBE, str(VOICE), json.dumps(cls.settings)] + done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True) + cls.data = json.loads(done.stdout) + + def test_urgency_picks_the_tone_and_mayday_is_never_quieter(self): + self.assertEqual(self.data["tones"], ["mayday", "mayday", "direct", "direct", "radio", "radio", "radio"]) + self.assertEqual(len(self.data["loud"]), len(self.settings)) + for loud in self.data["loud"]: + with self.subTest(loud=loud): + self.assertEqual(loud["mayday"], max(loud.values())) + self.assertEqual([loud["mayday"] for loud in self.data["loud"]], [0.9, 1.0, 0.8]) + + def test_the_radio_filter_picks_classes_and_stations(self): + self.assertEqual(self.data["spoken"], [ + [False] * 7, + [True, False, False, False, False, False, False], + [True, False, False, False, True, False, False], + [True, True, True, True, True, True, False], + ]) + + def test_the_queue_puts_alarms_first_and_drops_routine_traffic(self): + self.assertEqual(self.data["queue"], ["mayday", "mayday", "pan-pan", "security", "broadcast", "direct"]) + + def test_the_recording_is_encoded_as_16_khz_mono_pcm(self): + body = base64.b64decode(self.data["wav"]) + self.assertEqual(engines.wav_seconds(body), 7 / engines.STT_RATE) + with wave.open(io.BytesIO(body)) as reader: + self.assertEqual((reader.getnchannels(), reader.getsampwidth(), reader.getframerate(), reader.getnframes()), (1, 2, 16000, 7)) + self.assertEqual(struct.unpack("<7h", reader.readframes(7)), (0, 16383, -16384, 32767, -32768, 32767, -32768)) + self.assertEqual(self.data["frames"], [24000, 16000 * 120, 0]) + + +if __name__ == "__main__": + unittest.main()