feat(orchestrator)!: S0 English everywhere, Sécurité, NATO hashes, ICAO digits
Requirement 001 phase 6, S0: - Speech: the security class shows as "Sécurité" and is spoken as in maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more characters with a letter and a digit (commit, session, message ids) is spelled in the NATO alphabet; every other digit is spoken on its own in ICAO pronunciation (42305: four two tree zero fife). The spelling runs inside clip() before the length cut, so a phrase still fits SPEECH_LIMIT; the displayed text keeps its digits and hashes. - English: every orchestrator label, tooltip, message, error, script output, document and test string is English (Regeln -> Firewall, Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...); the page declares lang="en" and formats dates as en-GB. Task filter ids are English. German stays only in the skill trigger phrases that quote the operator, and as identifiers: the view keys (view=) and the leitstand id. - tests/test_english.py scans every tracked text file for umlauts and German words, with an allowlist of the quoted trigger phrases. BREAKING CHANGE: the Stream search tokens art: and seit: are now kind: and since:; an old art: or seit: token counts as a plain word. Evidence: make test 657 OK, ruff and markdownlint clean (/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log); MEDUSA 86 files, 24 findings on reworded strings accepted after an independent skeptic (21 path:line, 0 rejected, no behaviour change in signing, approving, rules, denying, offices, events), rescan 0 open. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a388c6ef77
commit
c035373518
86 files changed
+1027
-930
No files matched your search
@@ -405,17 +405,17 @@ Both commands also patch the target's `.claude/settings.local.json` — the mach
|
||||
|
||||
The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:<port>/`, 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, 82 MB). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`. Its Tree reads only the file `pkgmgr branch tree` writes: the install clones `PKGMGR_REPO` (default the GitHub repository, never the sandbox-writable local checkout), builds the newest `v*` tag a fully trusted GPG key signed into `~/.local/share/kvb-orchestrator/pkgmgr`, and enables the `kvb-orchestrator-tree.timer` user timer, which writes `~/.local/state/pkgmgr/tree/branch-tree.json` a minute after its previous run while the hub page polls, on at most half a core; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off.
|
||||
|
||||
Stream → Radio (`#stream/radio`) is the hub's radio interface. Its station list shows one line per station, the full name in the tooltip, with its state and a play or stop button; when the browser blocks autoplay, the stream fails or the CSP refuses its origin, the row and a toast say so, and one click starts the station. New stations play daily around the clock. While the hub reads a message aloud, the speaking agent pulses there and in the Orbit and Chord views, under the caption "<Agent> spricht" with the message's first line; under `prefers-reduced-motion` the ring stays still. The composer sends on all seven channels: post and call to one agent, newsletter, broadcast, pan-pan, security and mayday to all. "Aufnehmen" records with MediaRecorder; the hub transcribes the recording with whisper.cpp off the request thread (at most 120 s of audio, a 120 s timeout, four at a time), keeps it for replay in `~/.local/state/kvb-orchestrator/voice` (the newest 50) and puts the transcript into the field to edit; the message goes out as text, with the recording's host path and sha256 only when "Aufnahme anhängen" is ticked. The hub transcribes at most six recordings a minute and 600 s of audio an hour. Every operator message is passkey-signed, and the hub verifies the signature against the pinned keys, each once, before it relays; `operator_message(id, text, to)` answers `match` only for the signed text and recipient; for a denial the signature covers the recipient the inbox resolved at signing, and the answer's text is only the signed reason, never the agent's own words the radio row quotes. "Mayday aufheben", Abend, Sonnenuntergang, Sonnenaufgang and a settings save that adds a stream URL take the passkey the same way.
|
||||
Stream → Radio (`#stream/radio`) is the hub's radio interface. Its station list shows one line per station, the full name in the tooltip, with its state and a play or stop button; when the browser blocks autoplay, the stream fails or the CSP refuses its origin, the row and a toast say so, and one click starts the station. New stations play daily around the clock. While the hub reads a message aloud, the speaking agent pulses there and in the Orbit and Chord views, under the caption "<Agent> speaking" with the message's first line; under `prefers-reduced-motion` the ring stays still. The composer sends on all seven channels: post and call to one agent, newsletter, broadcast, pan-pan, security and mayday to all. "Record" records with MediaRecorder; the hub transcribes the recording with whisper.cpp off the request thread (at most 120 s of audio, a 120 s timeout, four at a time), keeps it for replay in `~/.local/state/kvb-orchestrator/voice` (the newest 50) and puts the transcript into the field to edit; the message goes out as text, with the recording's host path and sha256 only when "Attach recording" is ticked. The hub transcribes at most six recordings a minute and 600 s of audio an hour. Every operator message is passkey-signed, and the hub verifies the signature against the pinned keys, each once, before it relays; `operator_message(id, text, to)` answers `match` only for the signed text and recipient; for a denial the signature covers the recipient the inbox resolved at signing, and the answer's text is only the signed reason, never the agent's own words the radio row quotes. "Cancel mayday", Evening, Sunset, Sunrise and a settings save that adds a stream URL take the passkey the same way.
|
||||
|
||||
The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Löschen" on a `merged-clean` branch without skip reason, or "Alle gemergten löschen", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch <name>` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`.
|
||||
The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Delete" on a `merged-clean` branch without skip reason, or "Delete all merged", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch <name>` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`.
|
||||
|
||||
The Agents view "Hierarchie" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/<slug>`) and presidents (`work/<slug>`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim <office>` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin.
|
||||
The Agents view "Hierarchy" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/<slug>`) and presidents (`work/<slug>`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim <office>` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin.
|
||||
|
||||
The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "wartet auf Freigabe", and the filter "Wartet auf Operator" is the inbox, which the Inbox entry under Stream opens. A task's sheet pins its priority (`rasci.py set <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar.
|
||||
The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "waiting for a grant", and the filter "Waiting for operator" is the inbox, which the Inbox entry under Stream opens. A task's sheet pins its priority (`rasci.py set <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar.
|
||||
|
||||
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
||||
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Register passkey" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
||||
|
||||
For approvals the hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. In the inbox, an operator command run with "Ausführen" shows the runner's output in a console under its row until "Schließen". "Ablehnen" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Annehmen" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and review 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 (decision choices, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks/<id>/deny`, console dismissals) 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.
|
||||
For approvals the hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Register passkey" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. In the inbox, an operator command run with "Run" shows the runner's output in a console under its row until "Close". "Deny" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Accept" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and review 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 (decision choices, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks/<id>/deny`, console dismissals) 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.
|
||||
|
||||
|
||||
@@ -47,27 +47,27 @@ from three views down to any agent, branch or artefact.
|
||||
(directory, module, container, systemd unit, MCP server, environment
|
||||
`KVB_ORCHESTRATOR_*`, make targets). Rejected: visible name only (two names
|
||||
for one thing).
|
||||
10. **Navigation**: Stream (sub-items Inbox and Regeln) · Agents (ex Graph) ·
|
||||
10. **Navigation**: Stream (sub-items Inbox and Firewall) · Agents (ex Graph) ·
|
||||
Artifacts (ex Tree). Agents and Artifacts each offer Orbit, Chord,
|
||||
Swimlane, Tree and 3D.
|
||||
11. **Header**: title · ⌘K · ⚙ only. The status pills become navigation
|
||||
badges (Inbox: waiting items, Agents: active agents, Artifacts: runner
|
||||
jobs); Mayday shows only as a red banner with "aufheben" while active; the
|
||||
jobs); Mayday shows only as a red banner with "cancel" while active; the
|
||||
background shows day and night. Rejected: one status dot with a popover
|
||||
(an extra click), a status block in a side bar, a "Lage" screen (effort),
|
||||
no header at all.
|
||||
12. **Tools**: Steuerung (Funk an alle, Mayday aufheben, Priorität, Tag/Nacht)
|
||||
moves to the Agents toolbar, Regeln under Inbox, Himmel, Musik,
|
||||
Leitstand and Benachrichtigungen to ⚙ "Ambiente"; ⌘K reaches every one.
|
||||
(an extra click), a status block in a side bar, a "Situation" screen
|
||||
(effort), no header at all.
|
||||
12. **Tools**: Controls (Radio to all, Cancel mayday, Priority, Day/Night)
|
||||
moves to the Agents toolbar, Firewall under Inbox, Sky, Music,
|
||||
Control room and Notifications to ⚙ "Ambience"; ⌘K reaches every one.
|
||||
Rejected: one ⋯ menu (hides instead of ordering).
|
||||
13. **Stream**: four category chips Für dich · Funk · Arbeit · Transkripte;
|
||||
one search line with `agent:`, `art:`, `seit:`; sub-filters, saved views
|
||||
13. **Stream**: four category chips For you · Radio · Work · Transcripts;
|
||||
one search line with `agent:`, `kind:`, `since:`; sub-filters, saved views
|
||||
and a custom range under "Filter ▾"; the time bar becomes "24 h ▾" with
|
||||
the replay inside; transcripts are off by default. Rejected: a
|
||||
self-grouping stream, a Kibana-like query language (effort), preset
|
||||
chips only.
|
||||
14. **3D**: `3d-force-graph` 1.80.1 on `three` 0.186.1, vendored as in
|
||||
meta.infinito.nexus; the Canvas depth axes Zeit and Spawn-Tiefe become 3D
|
||||
meta.infinito.nexus; the Canvas depth axes Time and Spawn depth become 3D
|
||||
options. Rejected: keeping the Orbit-only Canvas depth.
|
||||
15. **Artifacts scope**: git (repositories → branches → worktrees with their
|
||||
agents and reviews) plus the kvb-org-rasci register artefacts (images,
|
||||
@@ -99,8 +99,7 @@ from three views down to any agent, branch or artefact.
|
||||
locked, stale, protected and the gc skip reason. The Artifacts Tree shows
|
||||
it as icons. Phase 3 builds the data, phase 4 the icons.
|
||||
21. **Delete from the tree**: merged, orphaned branches are deleted by click
|
||||
in the Artifacts Tree (phase 4), per item and as "Alle gemergten
|
||||
löschen":
|
||||
in the Artifacts Tree (phase 4), per item and as "Delete all merged":
|
||||
- **Credentials**: only while the Secret Service hands pkgmgr the GitHub
|
||||
token, which proves the merge and the missing open pull request; no
|
||||
secret passes the orchestrator container. Rejected: the master password
|
||||
@@ -115,7 +114,7 @@ from three views down to any agent, branch or artefact.
|
||||
`pkgmgr branch gc --apply` deletes today (remote branch and
|
||||
`compose down -v`).
|
||||
- **Confirmation**: the click shows the gc dry-run lines of the chosen
|
||||
items, and "Löschen" needs the operator signature (`/api/sign`).
|
||||
items, and "Delete" needs the operator signature (`/api/sign`).
|
||||
Rejected: a second click without signature, one click without preview.
|
||||
- **Execution**: the orchestrator writes a signed job into a host
|
||||
directory; a systemd user path unit runs `pkgmgr branch cleanup --apply
|
||||
@@ -125,19 +124,19 @@ from three views down to any agent, branch or artefact.
|
||||
and client which skills changed and how to use them, as one
|
||||
`newsletter` per update; phase 1 announces the radio change through it.
|
||||
23. **Phase 5** (23 to 43 operator-confirmed 2026-11-07): the console
|
||||
output shows under "Ausführen" (Run); a deny picks its channel (default
|
||||
output shows under "Run"; a deny picks its channel (default
|
||||
`post`); agents request auto-approval rules through the
|
||||
inbox; the rules form and the Ort fields never overlap; the radio plays
|
||||
inbox; the rules form and the Location fields never overlap; the radio plays
|
||||
with one line per station and highlights the speaking agent; the
|
||||
operator composer sends text or voice, recorded, transcribed locally with
|
||||
whisper in the container and kept as audio, to a recipient by `post` or
|
||||
`call`; a Hierarchie view shows the council offices with appoint and
|
||||
`call`; a Hierarchy view shows the council offices with appoint and
|
||||
recall; the agent order (drag list) is separate from task priority (WSJF
|
||||
plus the operator's pin) and only breaks ties; one Tasks tab holds list,
|
||||
board and RASCI matrix, the inbox as its "waits on Operator" filter and
|
||||
the queue state; the merge flow, a pkgmgr-managed YAML per worktree with
|
||||
several pull sources and push targets, shows in Artifacts and Tasks.
|
||||
24. **Firewall**: the tab Regeln becomes Firewall and holds every form of
|
||||
24. **Firewall**: the rules tab becomes Firewall and holds every form of
|
||||
allow-listing: the hub's auto-approval rules (ask, review, decision),
|
||||
allow and deny command patterns, and the Claude settings permissions
|
||||
(`allow`, `ask`, `deny`) and sandbox entries. Rejected: a separate
|
||||
@@ -168,7 +167,7 @@ from three views down to any agent, branch or artefact.
|
||||
`kvb-org-evening` → `kvb-org-drain`, `kvb-org-sunset` → `kvb-org-suspend`,
|
||||
`kvb-org-sunrise` → `kvb-org-resume`, `kvb-agent-relax` →
|
||||
`kvb-agent-drain`, `kvb-agent-sleep` → `kvb-agent-suspend`,
|
||||
`kvb-agent-wakeup` → `kvb-agent-resume`; `night.py` and the Steuerung
|
||||
`kvb-agent-wakeup` → `kvb-agent-resume`; `night.py` and the Controls
|
||||
menu follow, `kvb-skill-cleanup` removes the old names. Rejected:
|
||||
sleep / hibernate / wakeup, pause / park / resume.
|
||||
30. **Agent popup**: a click on an agent opens a fading popup with a simple,
|
||||
@@ -196,7 +195,7 @@ from three views down to any agent, branch or artefact.
|
||||
36. **Tree cause**: the tree timer builds pkgmgr from the newest signed tag,
|
||||
and v2.3.0 has no `branch tree`, so no tree file exists. The Tree names
|
||||
that cause and the operator step (the pkgmgr release) instead of a bare
|
||||
"Kein Tree"; with pkgmgr 2.4.0 released the timer writes the file.
|
||||
"No tree"; with pkgmgr 2.4.0 released the timer writes the file.
|
||||
37. **Resources**: a tab Resources shows charts on the shared time axis:
|
||||
tokens per agent and accumulated (from the usage fields of the session
|
||||
transcripts), and CPU, RAM, disk and load of the host, sampled by a host
|
||||
@@ -324,7 +323,7 @@ Phases 5 and 6 (decisions 23 to 42):
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph W["🧩 5 · Inbox, UI, Hierarchie, Tasks"]
|
||||
subgraph W["🧩 5 · Inbox, UI, Hierarchy, Tasks"]
|
||||
W1["🛠️ W1 to W4 in own worktrees"] --> W2["🕵️ Skeptic round, fix round per worktree"]
|
||||
W2 --> W3{"🧪 Suite, e2e, MEDUSA, score green?"}
|
||||
W3 -- "🔴 no: fix" --> W2
|
||||
@@ -422,7 +421,7 @@ flowchart TD
|
||||
`kvb-orchestrator` names. A second run changes nothing (scratch-HOME
|
||||
test).
|
||||
- [ ] The navigation has exactly three top-level entries Stream, Agents and
|
||||
Artifacts; Stream has the sub-items Inbox and Regeln.
|
||||
Artifacts; Stream has the sub-items Inbox and Firewall.
|
||||
- [ ] Every agent appears once under its current name: a register worker
|
||||
such as "Stage (this session)" or "infinito-nexus-core-c8 (Production)"
|
||||
maps to the agent named before the bracket.
|
||||
@@ -438,18 +437,18 @@ flowchart TD
|
||||
- [ ] Agents shows the agents with a heartbeat in the last 900 s.
|
||||
- [ ] Artifacts shows the runner jobs that are queued or running.
|
||||
- [ ] A badge at 0 is hidden.
|
||||
- [ ] A red Mayday banner with "aufheben" exists if and only if a mayday is
|
||||
active; "aufheben" runs `radio.py clear`.
|
||||
- [ ] A red Mayday banner with "cancel" exists if and only if a mayday is
|
||||
active; "cancel" runs `radio.py clear`.
|
||||
- [ ] The Agents toolbar holds:
|
||||
- [ ] Funk an alle, with the class choice Newsletter, Broadcast, Pan-Pan,
|
||||
- [ ] Radio to all, with the class choice Newsletter, Broadcast, Pan-Pan,
|
||||
Security, Mayday and Newsletter preselected.
|
||||
- [ ] Mayday aufheben, Priorität and Tag/Nacht.
|
||||
- [ ] Cancel mayday, Priority and Day/Night.
|
||||
- [ ] The agent panel sends with the choice Post (preselected) or Call.
|
||||
- [ ] ⚙ "Ambiente" holds Himmel, Musik, Leitstand and Benachrichtigungen;
|
||||
- [ ] ⚙ "Ambience" holds Sky, Music, Control room and Notifications;
|
||||
⌘K lists every former header tool.
|
||||
- [ ] The Stream shows exactly four category chips Für dich, Funk, Arbeit and
|
||||
Transkripte, and one search line that filters by `agent:`, `art:` and
|
||||
`seit:`.
|
||||
- [ ] The Stream shows exactly four category chips For you, Radio, Work and
|
||||
Transcripts, and one search line that filters by `agent:`, `kind:` and
|
||||
`since:`.
|
||||
- [ ] "Filter ▾" holds the kind sub-filters, the transcript parts, the saved
|
||||
views and the custom range.
|
||||
- [ ] The time control reads "24 h ▾" and holds the replay.
|
||||
@@ -515,7 +514,7 @@ flowchart TD
|
||||
- [ ] 3D in both tabs runs on the vendored `3d-force-graph` 1.80.1 and
|
||||
`three` 0.186.1, with their MIT licence texts beside them and no CDN
|
||||
request (CSP unchanged).
|
||||
- [ ] 3D offers the layouts Kraft, Zeit and Spawn-Tiefe.
|
||||
- [ ] 3D offers the layouts Force, Time and Spawn depth.
|
||||
- [ ] A click on a 3D node opens the same panel as in 2D.
|
||||
- [ ] 3D respects `prefers-reduced-motion`.
|
||||
|
||||
@@ -525,10 +524,10 @@ flowchart TD
|
||||
merged (with what merged it), clean, uncommitted changes (with counts),
|
||||
unpushed commits, gone upstream, locked, stale and protected.
|
||||
- [ ] A `merged-clean` item without a gc skip reason shows a delete button,
|
||||
and the Tree header shows "Alle gemergten löschen"; both are disabled,
|
||||
and the Tree header shows "Delete all merged"; both are disabled,
|
||||
with the reason as tooltip, while `delete_ready` is false.
|
||||
- [ ] A click shows the gc dry-run lines of exactly the chosen items;
|
||||
"Löschen" requires the operator signature through `/api/sign`.
|
||||
"Delete" requires the operator signature through `/api/sign`.
|
||||
- [ ] The orchestrator writes the signed job (repository, branch, worktree
|
||||
and tip sha per item, no command) into a host-mounted job directory and
|
||||
holds no git, token or key.
|
||||
@@ -555,7 +554,7 @@ flowchart TD
|
||||
- [ ] Every icon element has a `title` and an `aria-label` that carry the
|
||||
former text (DOM test over every page).
|
||||
|
||||
### Phase 5: inbox, UI, Hierarchie, Tasks
|
||||
### Phase 5: inbox, UI, Hierarchy, Tasks
|
||||
|
||||
- [ ] A deny is refused by the hub unless its passkey proof verifies; the
|
||||
PermissionRequest hook quotes a deny reason only when it verifies.
|
||||
@@ -570,12 +569,12 @@ flowchart TD
|
||||
|
||||
### Phase 6: English and radio speech (S0)
|
||||
|
||||
- [ ] The text handed to the speech engine for a `security` row starts with
|
||||
- [x] The text handed to the speech engine for a `security` row starts with
|
||||
"say-cure-ee-tay" three times; the page shows "Sécurité".
|
||||
- [ ] `8e180be` is spoken "eight echo one eight zero bravo echo" and
|
||||
- [x] `8e180be` is spoken "eight echo one eight zero bravo echo" and
|
||||
`42305` "four two tree zero fife" (unit test on the pronunciation step);
|
||||
the displayed line is unchanged.
|
||||
- [ ] A scan of the tree finds no German UI, message or document text
|
||||
- [x] A scan of the tree finds no German UI, message or document text
|
||||
outside quoted trigger phrases (test with an allowlist of those
|
||||
phrases).
|
||||
|
||||
@@ -615,7 +614,7 @@ flowchart TD
|
||||
`kvb-agent-drain`, `kvb-agent-suspend` and `kvb-agent-resume` exist;
|
||||
the old six names exist nowhere in the tree except in the migration
|
||||
and `kvb-skill-cleanup` history (grep test).
|
||||
- [ ] The Steuerung menu shows Drain, Suspend and Resume and each needs the
|
||||
- [ ] The Controls menu shows Drain, Suspend and Resume and each needs the
|
||||
passkey, like the board actions today.
|
||||
|
||||
### Phase 6: Tasks with asks and choices (S5)
|
||||
@@ -716,7 +715,7 @@ flowchart TD
|
||||
them.
|
||||
- [ ] `make test` green on the commit of each phase.
|
||||
- **E2E**
|
||||
- [ ] A headless Chromium and Firefox run opens Stream, Inbox, Regeln and
|
||||
- [ ] A headless Chromium and Firefox run opens Stream, Inbox, Firewall and
|
||||
every view of Agents and Artifacts at 1920, 1366 and 390 px wide and
|
||||
saves the screenshots shown at each operator checkpoint.
|
||||
- [ ] It sends a Call from the agent panel and sees its receipt in the
|
||||
|
||||
@@ -30,13 +30,13 @@ COMMANDS = "operator/"
|
||||
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")),
|
||||
("deletes recursively", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")),
|
||||
("forces", re.compile(r"--force\b|\bpush\b.*\s-f\b")),
|
||||
("pushes", 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)")),
|
||||
("pipes into a shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")),
|
||||
("discards changes", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")),
|
||||
("Docker deletes", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class Approving:
|
||||
try:
|
||||
verifier.check(proof, item, "", trust, time.time())
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"nicht signiert: {error}") from None
|
||||
raise ToolError(f"not signed: {error}") from None
|
||||
return proof
|
||||
|
||||
def still_signed(self, proof: dict, item: dict) -> None:
|
||||
@@ -94,7 +94,7 @@ class Approving:
|
||||
raise signing.Refused("the signature does not list this item")
|
||||
passkey.verify_assertion(proof["assertion"], proof["payload"], verifier.pinned(self.approvers), signing.origin_of(self.public_port))
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"nicht signiert: {error}") from None
|
||||
raise ToolError(f"not signed: {error}") from None
|
||||
|
||||
def covered(self, proof: object, signed: list[dict], now: float) -> dict | None:
|
||||
"""Args:
|
||||
@@ -112,7 +112,7 @@ class Approving:
|
||||
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")
|
||||
raise ToolError("not signed: 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)
|
||||
@@ -136,15 +136,15 @@ class Approving:
|
||||
item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed, "note": None}
|
||||
batch = self.batches.add([item], f"rule:{rule['id']}", rule.get("proof"), self.apply)
|
||||
words = f"{rule['kind']} {rule['tool']} {rule['pattern']}".replace(" ", " ")
|
||||
self.log.add({"kind": "rule", "title": f"Regel gibt frei: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}})
|
||||
self.log.add({"kind": "rule", "title": f"Rule grants: {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"
|
||||
outcome = f"failed: {error}"
|
||||
verb = "Grant" if item["verb"] == "approve" else "Denial"
|
||||
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})
|
||||
@@ -158,15 +158,15 @@ class Approving:
|
||||
if item["kind"] == "ask":
|
||||
record = self.asks.get(item["id"])
|
||||
if record is None or ask_state(record, time.time())["status"] == "expired":
|
||||
return "abgelaufen"
|
||||
return "expired"
|
||||
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']}"
|
||||
return "unknown"
|
||||
return record["status"] if changed else f"already {record['status']}"
|
||||
|
||||
def requester(self, raw: dict, who: str) -> str:
|
||||
"""Returns:
|
||||
@@ -216,7 +216,7 @@ class Approving:
|
||||
raise ToolError("a decision is approved with one of its option labels")
|
||||
label = raw["label"]
|
||||
if kind_name == "rule":
|
||||
return inbox_item(raw, "", f"Regel {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"]))
|
||||
return inbox_item(raw, "", f"Rule {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"]))
|
||||
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
|
||||
subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else None
|
||||
return inbox_item(raw, label, title, record.get("gate", ""), subject, self.requester(raw, record["session"] if kind_name == "ask" else ""))
|
||||
@@ -259,5 +259,5 @@ class Approving:
|
||||
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}})
|
||||
self.log.add({"kind": "approval", "title": f"Undone: {titles}", "text": batch["source"], "ref": {"batch": batch_id}})
|
||||
return {"id": batch_id, "status": "undone"}
|
||||
@@ -71,7 +71,7 @@ class Cleaning:
|
||||
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
|
||||
scan = self.tree.get()
|
||||
if scan.get("delete_ready") is not True:
|
||||
raise ToolError(f"löschen gesperrt: {scan.get('delete_reason') or scan.get('error') or 'delete_ready ist false'}")
|
||||
raise ToolError(f"delete blocked: {scan.get('delete_reason') or scan.get('error') or 'delete_ready is false'}")
|
||||
known = offered(scan)
|
||||
chosen = []
|
||||
for entry in raw:
|
||||
@@ -94,7 +94,7 @@ class Cleaning:
|
||||
job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{key: item[key] for key in JOB_KEYS} for item in items], "proof": proof}
|
||||
self.spool.put(job_id, job)
|
||||
names = ", ".join(item["branch"] for item in items)
|
||||
self.log.add({"kind": "run", "title": f"Löschen signiert: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}})
|
||||
self.log.add({"kind": "run", "title": f"Delete signed: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}})
|
||||
return {"id": job_id, "status": "queued", "items": job["items"]}
|
||||
|
||||
def cleanup_state(self, job_id: str) -> dict:
|
||||
|
||||
@@ -9,7 +9,7 @@ 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"}
|
||||
CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Background", "timer": "Timer", "queue": "Queue"}
|
||||
|
||||
|
||||
def cron_state(cron: dict, beats: dict[str, float], now: float) -> dict:
|
||||
@@ -59,7 +59,7 @@ def queue_entries(council: Path) -> list[tuple[dict, float]]:
|
||||
def queue_rows(council: Path) -> list[dict]:
|
||||
rows = []
|
||||
for entry, created in queue_entries(council):
|
||||
running = "läuft" if entry.get("status") == "active" else "wartet"
|
||||
running = "running" if entry.get("status") == "active" else "waiting"
|
||||
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}",
|
||||
|
||||
@@ -76,28 +76,28 @@ class Denying:
|
||||
tasks = read_register(self.council / REGISTER)
|
||||
row = next((row for row in operator_rows(tasks) if row["task"] == item["id"]), None)
|
||||
if row is None or signing.denial_item(operator_signed(row), note["channel"], note["reason"], note["to"]) != item["signed"]:
|
||||
return "verweigert: die Zeile hat sich seit der Ablehnung geändert"
|
||||
return "refused: the row changed since the denial"
|
||||
if item["id"] in self.open_jobs():
|
||||
return "läuft schon"
|
||||
return "already running"
|
||||
run = self.scripts.run(RASCI, ["set", item["id"], "--status", "stopped"])
|
||||
if run["code"] != 0:
|
||||
return f"fehlgeschlagen: {run['command']} → {run['code']}"
|
||||
return f"failed: {run['command']} → {run['code']}"
|
||||
command, directory = row["command"][:200], row["directory"]
|
||||
return f"gestoppt · {self.tell(item, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}"
|
||||
return f"stopped · {self.tell(item, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}"
|
||||
ledger = self.asks if item["kind"] == "ask" else self.rule_requests
|
||||
record = ledger.get(item["id"])
|
||||
if record is None:
|
||||
return "unbekannt"
|
||||
return "unknown"
|
||||
now = time.time()
|
||||
if (ask_state(record, now)["status"] if item["kind"] == "ask" else request_status(record, now)) == "expired":
|
||||
return "abgelaufen"
|
||||
return "expired"
|
||||
record, changed = ledger.decide(item["id"], {"status": "denied", "proof": proof, **note})
|
||||
if not changed:
|
||||
return f"schon {record['status']}"
|
||||
return f"already {record['status']}"
|
||||
if item["kind"] == "ask":
|
||||
tool, command = record["tool"], record["command"][:200]
|
||||
return f"abgelehnt · {self.tell(item, proof, record['session'], f'ask {tool}: {command}')}"
|
||||
return f"abgelehnt · {self.tell(item, proof, record['agent'], f'rule request {request_words(record)}')}"
|
||||
return f"denied · {self.tell(item, proof, record['session'], f'ask {tool}: {command}')}"
|
||||
return f"denied · {self.tell(item, proof, record['agent'], f'rule request {request_words(record)}')}"
|
||||
|
||||
def addressee(self, who: str) -> tuple[str, str]:
|
||||
return requester_of(read_claims(self.council), who)
|
||||
@@ -120,11 +120,11 @@ class Denying:
|
||||
direct = channel in DIRECT
|
||||
address = self.addressee(note["to"])[1] if direct else EVERYONE
|
||||
if direct and not address:
|
||||
return f"kein Empfänger für {channel}"
|
||||
return f"no recipient for {channel}"
|
||||
text = denial_text(what, self.addressee(who)[0], note["reason"])
|
||||
where = f"{channel} an {address}" if direct else f"{channel} an alle"
|
||||
where = f"{channel} to {address}" if direct else f"{channel} to all"
|
||||
fields = {"to": note["to"], "address": address, "text": text, "upload": None, "voice": None, "voice_sha256": "", "class": channel}
|
||||
record = self.messages.add({**fields, "denial": item["signed"], "proof": proof, "created_ts": time.time()})
|
||||
targets = ["--to", address] if direct else []
|
||||
run = self.scripts.run(RADIO, [*DENY_CHANNELS[channel], *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"])
|
||||
return where if run["code"] == 0 else f"{where} fehlgeschlagen ({run['code']})"
|
||||
return where if run["code"] == 0 else f"{where} failed ({run['code']})"
|
||||
@@ -17,18 +17,18 @@ from .register import status_of
|
||||
from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS
|
||||
|
||||
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"),
|
||||
("radio", "Radio"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Decisions"), ("operator", "Operator"), ("register", "Register"),
|
||||
("rename", "Names"), ("cron", "Crons"), ("rule", "Firewall"), ("approval", "Grants"), ("run", "Runner"), ("transcript", "Transcripts"),
|
||||
)
|
||||
EVENT_KINDS = tuple(name for name, _ in KINDS)
|
||||
WORK = [name for name in EVENT_KINDS if name not in ("radio", "transcript")]
|
||||
VIEWS = {
|
||||
"fuer-dich": {"label": "Für dich", "kinds": ["radio", *WORK], "waiting": True},
|
||||
"funk": {"label": "Funk", "kinds": ["radio"], "waiting": False},
|
||||
"arbeit": {"label": "Arbeit", "kinds": WORK, "waiting": False},
|
||||
"transkripte": {"label": "Transkripte", "kinds": ["transcript"], "waiting": False},
|
||||
"fuer-dich": {"label": "For you", "kinds": ["radio", *WORK], "waiting": True},
|
||||
"funk": {"label": "Radio", "kinds": ["radio"], "waiting": False},
|
||||
"arbeit": {"label": "Work", "kinds": WORK, "waiting": False},
|
||||
"transkripte": {"label": "Transcripts", "kinds": ["transcript"], "waiting": False},
|
||||
}
|
||||
ART = {word: name for name, label in KINDS for word in (name, label.lower())}
|
||||
KIND_WORDS = {word: name for name, label in KINDS for word in (name, label.lower())}
|
||||
SPAN = re.compile(r"(\d{1,4})([mhd])")
|
||||
UNITS = {"m": 60, "h": 3600, "d": 86400}
|
||||
|
||||
@@ -44,14 +44,14 @@ def since_of(word: str, now: float) -> float:
|
||||
try:
|
||||
return datetime.datetime.fromisoformat(word).timestamp()
|
||||
except ValueError:
|
||||
raise ToolError("seit: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None
|
||||
raise ToolError("since: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None
|
||||
|
||||
|
||||
def search(text: str, now: float) -> dict:
|
||||
"""Args:
|
||||
text: the search line: words, `agent:<name>`, `art:<kind or label>`
|
||||
and `seit:<span or date>`.
|
||||
now: the clock `seit:` spans count back from.
|
||||
text: the search line: words, `agent:<name>`, `kind:<kind or label>`
|
||||
and `since:<span or date>`.
|
||||
now: the clock `since:` spans count back from.
|
||||
|
||||
Returns:
|
||||
The plain words as `q`, plus `agent`, `kinds` and `since` for each
|
||||
@@ -60,16 +60,16 @@ def search(text: str, now: float) -> dict:
|
||||
found: dict = {"q": []}
|
||||
for word in text.split():
|
||||
key, colon, value = word.partition(":")
|
||||
if not colon or key not in ("agent", "art", "seit"):
|
||||
if not colon or key not in ("agent", "kind", "since"):
|
||||
found["q"].append(word)
|
||||
elif not value:
|
||||
raise ToolError(f"{key}: needs a value")
|
||||
elif key == "agent":
|
||||
found["agent"] = value
|
||||
elif key == "art":
|
||||
if value.lower() not in ART:
|
||||
raise ToolError(f"art: takes one of {', '.join(EVENT_KINDS)}")
|
||||
found.setdefault("kinds", set()).add(ART[value.lower()])
|
||||
elif key == "kind":
|
||||
if value.lower() not in KIND_WORDS:
|
||||
raise ToolError(f"kind: takes one of {', '.join(EVENT_KINDS)}")
|
||||
found.setdefault("kinds", set()).add(KIND_WORDS[value.lower()])
|
||||
else:
|
||||
found["since"] = since_of(value, now)
|
||||
found["q"] = " ".join(found["q"]).lower()
|
||||
@@ -128,7 +128,7 @@ def register_changes(before: dict[str, dict], after: dict[str, dict], now: float
|
||||
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"
|
||||
change = f"new: {shown(row.get('title'))}" if old is None else "removed"
|
||||
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)
|
||||
|
||||
@@ -54,12 +54,12 @@ class Jobs:
|
||||
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"
|
||||
return "refused: the row changed since the grant"
|
||||
if item["id"] in self.open_jobs():
|
||||
return "läuft schon"
|
||||
return "already running"
|
||||
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, "dismissed": False})
|
||||
return "an den Runner übergeben"
|
||||
return "handed to the runner"
|
||||
|
||||
def runner_jobs(self) -> dict:
|
||||
return {"jobs": [runner_job(job) for job in self.jobs.listed() if job["status"] == "queued"]}
|
||||
|
||||
@@ -87,36 +87,36 @@ def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict:
|
||||
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"
|
||||
verb = "Grant" if name == "approve" else "Deny"
|
||||
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"])]}}
|
||||
return {"name": name, "summary": f"Decide: {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"
|
||||
words = f"Snooze ({hours} h)" if name == "snooze" else "Pin"
|
||||
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"]}}
|
||||
return {"name": name, "summary": f"View: {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"]}}
|
||||
return {"name": name, "summary": f"Open: {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}}
|
||||
return {"name": name, "summary": f"Radio to {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}}
|
||||
query = bounded(arguments, "q", 200)
|
||||
return {"name": name, "summary": f"Suchen: {query}", "args": {"q": query}}
|
||||
return {"name": name, "summary": f"Search: {query}", "args": {"q": query}}
|
||||
|
||||
|
||||
def nl_actions(reply: str, items: list[dict], agents: list[str]) -> dict:
|
||||
|
||||
@@ -75,8 +75,8 @@ class Directory:
|
||||
if 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"]},
|
||||
"now": self.now(session), "session": session, "title": f"{before['name']} is now called {after['name']}",
|
||||
"text": f"Source: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]},
|
||||
}))
|
||||
return found
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from .signed import signing
|
||||
|
||||
ITEM_LIMIT = 20
|
||||
JOB_VERSION = 1
|
||||
LABELS = {"appoint": "besetzt", "takeover": "übergeben", "recall": "abberufen"}
|
||||
LABELS = {"appoint": "appointed", "takeover": "handed over", "recall": "recalled"}
|
||||
|
||||
|
||||
def office_request(raw: object) -> bool:
|
||||
@@ -77,7 +77,7 @@ class Offices:
|
||||
self.office_spool.put(job_id, job)
|
||||
names = ", ".join(f"{item['office']} {LABELS[item['action']]}" for item in items)
|
||||
lines = "\n".join(f"{item['action']} {item['office']}: {item['holder'] or '-'} -> {item['session'] or '-'}" for item in items)
|
||||
self.log.add({"kind": "run", "title": f"Amt signiert: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}})
|
||||
self.log.add({"kind": "run", "title": f"Office signed: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}})
|
||||
return {"id": job_id, "status": "queued", "items": job["items"]}
|
||||
|
||||
def office_state(self, job_id: str) -> dict:
|
||||
|
||||
@@ -8,16 +8,38 @@ SPEECH_LIMIT = 1000
|
||||
SPOKEN_TEXT = 500
|
||||
SPOKEN_NAME = 60
|
||||
SPOKEN_GROUP = 120
|
||||
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"}
|
||||
ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "say-cure-ee-tay"}
|
||||
ADDRESSED = ("post", "call", "receipt")
|
||||
CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE)
|
||||
HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]")
|
||||
HUB_FILE = re.compile(r"\s+file: /\S*$")
|
||||
SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT))
|
||||
HASH = re.compile(r"\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,}\b|[0-9]", re.IGNORECASE)
|
||||
NATO = dict(zip("abcdef", ("alfa", "bravo", "charlie", "delta", "echo", "foxtrot")))
|
||||
ICAO = dict(zip("0123456789", ("zero", "one", "two", "tree", "four", "fife", "six", "seven", "eight", "niner")))
|
||||
|
||||
|
||||
def pronounced(text: str) -> str:
|
||||
"""Args:
|
||||
text: a radio text as displayed.
|
||||
|
||||
Returns:
|
||||
The text with each hex run of 7 or more characters holding a letter
|
||||
and a digit spelled in the NATO alphabet and every other digit
|
||||
spoken on its own in ICAO pronunciation.
|
||||
"""
|
||||
return HASH.sub(spelled, text)
|
||||
|
||||
|
||||
def spelled(found: re.Match) -> str:
|
||||
start, end = found.span()
|
||||
before = " " if start and found.string[start - 1].isalnum() else ""
|
||||
after = " " if end < len(found.string) and found.string[end].isalnum() else ""
|
||||
return before + " ".join(NATO.get(char) or ICAO[char] for char in found[0].lower()) + after
|
||||
|
||||
|
||||
def clip(text: str, limit: int) -> str:
|
||||
words = " ".join(text.split())
|
||||
words = " ".join(pronounced(text).split())
|
||||
if len(words) <= limit:
|
||||
return words
|
||||
return words[:limit].rsplit(" ", 1)[0] or words[:limit]
|
||||
|
||||
@@ -49,7 +49,7 @@ class Requesting:
|
||||
raise ToolError(f"{name} already has {AGENT_CAP} pending rule requests; wait for their answer with rule_request_wait")
|
||||
|
||||
record = self.rule_requests.add_checked({**fields, "agent": name, "why": why, "status": "pending", "rule": None, "reason": "", "channel": "", "proof": None, "decided": None, "created_ts": now}, capped)
|
||||
self.log.add({"kind": "rule", "title": f"Regelwunsch von {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}})
|
||||
self.log.add({"kind": "rule", "title": f"Rule request from {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}})
|
||||
return {"id": record["id"], "status": record["status"], "url": self.url()}
|
||||
|
||||
def rule_request_wait(self, arguments: dict) -> dict:
|
||||
@@ -78,7 +78,7 @@ class Requesting:
|
||||
items = []
|
||||
for record in self.open_requests(now):
|
||||
rule = {key: record[key] for key in RULE_FIELDS}
|
||||
fields = {"title": f"Regel: {request_words(record)}", "agent": record["agent"], "to": requester_of(claims, record["agent"])[0], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True}
|
||||
fields = {"title": f"Rule: {request_words(record)}", "agent": record["agent"], "to": requester_of(claims, record["agent"])[0], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True}
|
||||
items.append({**base, "kind": "rule", "id": record["id"], "ts": record["created_ts"], **fields})
|
||||
return items
|
||||
|
||||
@@ -113,5 +113,5 @@ class Requesting:
|
||||
if not changed:
|
||||
raise Conflict(request_state(decided, now))
|
||||
self.store_rule(rule, fields, proof, True, now)
|
||||
self.log.add({"kind": "rule", "title": f"Regelwunsch angenommen: {request_words(record)}", "text": f"von {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}})
|
||||
self.log.add({"kind": "rule", "title": f"Rule request accepted: {request_words(record)}", "text": f"from {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}})
|
||||
return request_state(decided, now)
|
||||
@@ -72,7 +72,7 @@ class Rules:
|
||||
try:
|
||||
passkey.verify_assertion(assertion, rule, verifier.pinned(self.approvers), signing.origin_of(self.public_port))
|
||||
except signing.Refused as error:
|
||||
raise ToolError(f"nicht signiert: {error}") from None
|
||||
raise ToolError(f"not signed: {error}") from None
|
||||
return {"payload": rule, "assertion": assertion}
|
||||
|
||||
def store_rule(self, rule: dict, fields: dict, proof: dict, enabled: bool, now: float) -> dict:
|
||||
|
||||
@@ -66,5 +66,5 @@ class Tasking:
|
||||
fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof}
|
||||
ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"])
|
||||
commands = [shown_command(item["argv"]) for item in items]
|
||||
self.log.add({"kind": "run", "title": f"Aufgabe signiert geändert: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
|
||||
self.log.add({"kind": "run", "title": f"Task change signed: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
|
||||
return {"jobs": ids, "status": "queued"}
|
||||
@@ -53,7 +53,7 @@ class Timeline:
|
||||
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'}"
|
||||
title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'all'}"
|
||||
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"]]}
|
||||
|
||||
@@ -73,19 +73,19 @@ def dry_run(item: dict) -> list[str]:
|
||||
always stays.
|
||||
"""
|
||||
tip = (item["local_sha"] or item["head"] or "")[:12]
|
||||
merged = f", gemergt durch {item['merged_by']}" if item["merged_by"] else ""
|
||||
merged = f", merged by {item['merged_by']}" if item["merged_by"] else ""
|
||||
lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"]
|
||||
for action in item["actions"]:
|
||||
if action == "worktree":
|
||||
lines.append(f"entfernt den Worktree {item['worktree']}")
|
||||
lines.append(f"removes the worktree {item['worktree']}")
|
||||
elif action == "local-branch":
|
||||
lines.append(f"löscht den lokalen Branch {item['branch']}")
|
||||
lines.append(f"deletes the local branch {item['branch']}")
|
||||
elif action.startswith("remote"):
|
||||
lines.append(f"bleibt: {action} (nur lokal)")
|
||||
lines.append(f"stays: {action} (local only)")
|
||||
else:
|
||||
lines.append(action)
|
||||
lines += [f"stoppt den Compose-Stack {name} ohne -v" for name in item["compose_projects"]]
|
||||
lines += [f"entfernt das Image {name}" for name in item["images"]]
|
||||
lines += [f"stops the Compose stack {name} without -v" for name in item["compose_projects"]]
|
||||
lines += [f"removes the image {name}" for name in item["images"]]
|
||||
return lines
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -426,8 +426,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<header class="top">
|
||||
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
|
||||
<div class="tools">
|
||||
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Commands (Ctrl+K)" aria-label="Commands"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Settings and ambience" aria-label="Settings"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="navbar">
|
||||
@@ -445,7 +445,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<div class="sheet" id="sheet" hidden></div>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
<div class="palette" id="palette" hidden></div>
|
||||
<div class="sky-panel" id="sky-panel" role="group" aria-label="Blick in den Himmel" hidden></div>
|
||||
<div class="sky-panel" id="sky-panel" role="group" aria-label="View of the sky" hidden></div>
|
||||
<script nonce="{{ nonce }}" id="state" type="application/json">{{ state | tojson }}</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{% include "ambience.js" %}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const Nav = (() => {
|
||||
"use strict";
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]};
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const TASK_VIEWS = [["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
|
||||
const ICONS = {
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
}
|
||||
|
||||
function orderItem(name, index) {
|
||||
const up = officeBtn("bi-arrow-up", `${name} nach oben`, `${name} nach oben: höhere Priorität`, () => moveAgent(name, -1), `up:${name}`);
|
||||
const down = officeBtn("bi-arrow-down", `${name} nach unten`, `${name} nach unten: niedrigere Priorität`, () => moveAgent(name, 1), `down:${name}`);
|
||||
const up = officeBtn("bi-arrow-up", `Move ${name} up`, `Move ${name} up: higher priority`, () => moveAgent(name, -1), `up:${name}`);
|
||||
const down = officeBtn("bi-arrow-down", `Move ${name} down`, `Move ${name} down: lower priority`, () => moveAgent(name, 1), `down:${name}`);
|
||||
up.disabled = index === 0;
|
||||
down.disabled = index === ui.order.length - 1;
|
||||
const item = h("li", {draggable: "true", "data-name": name}, icon("bi-grip-vertical", "grip"), h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name), h("span", {class: "spacer"}), up, down);
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
async function saveAgentOrder() {
|
||||
if (await act("order", "Priorität", {agents: ui.order})) {
|
||||
if (await act("order", "Priority", {agents: ui.order})) {
|
||||
ui.order = null;
|
||||
drawAgentOrder();
|
||||
}
|
||||
@@ -85,14 +85,14 @@
|
||||
orderDom.focus = null;
|
||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.agent_order);
|
||||
box.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Priorität der Agenten", counter(ui.order.length))),
|
||||
h("p", {class: "muted"}, "Ordnet die Freigaben des Chairs und entscheidet nur bei gleicher Aufgabenpriorität; die --prio-Pins der Aufgaben bleiben unverändert."),
|
||||
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Reihenfolge der Agenten"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "Kein Agent mit Amt oder offener Aufgabe."),
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Agent priority", counter(ui.order.length))),
|
||||
h("p", {class: "muted"}, "Orders the chair's grants and decides only between equal task priorities; the tasks' --prio pins stay unchanged."),
|
||||
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Agent order"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "No agent with an office or an open task."),
|
||||
h("div", {class: "actions"},
|
||||
h("span", {class: "muted"}, "Ziehen oder Pfeile zum Umsortieren"),
|
||||
h("span", {class: "muted"}, "Drag or use the arrows to reorder"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Zurücksetzen"),
|
||||
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Reihenfolge speichern")));
|
||||
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Reset"),
|
||||
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Save order")));
|
||||
const target = key && [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
|
||||
if (target && target.disabled) {
|
||||
refocus(box, key.startsWith("up:") ? `down:${key.slice(3)}` : key.startsWith("down:") ? `up:${key.slice(5)}` : "order:title");
|
||||
|
||||
@@ -15,25 +15,25 @@
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
drawerDom.info.replaceChildren(
|
||||
h("h3", {}, "Aufgaben", counter(tasks.length)),
|
||||
h("h3", {}, "Tasks", 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("span", {class: "roles"}, [task.worker ? "Worker" : "", task.letters ? `Roles ${task.letters}` : ""].filter(Boolean).join(" · "))))) : h("p", {class: "muted"}, "No open tasks."),
|
||||
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("div", {class: "row"}, h("code", {}, claim.office), claim.beat === null ? null : h("span", {class: "muted"}, "Pulse ", 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."));
|
||||
h("span", {class: "muted"}, `${dash(claim.runtime)} · since ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "No 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, voiceReplay(row.voice))))
|
||||
: h("p", {class: "muted"}, "Kein Verlauf im gewählten Zeitraum."));
|
||||
: h("p", {class: "muted"}, "No history in the chosen time range."));
|
||||
}
|
||||
|
||||
async function loadSubagents(key, holder, life) {
|
||||
if (!SESSION_ID.test(key)) {
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Keine Session bekannt."));
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "No session known."));
|
||||
return;
|
||||
}
|
||||
let data = null;
|
||||
@@ -44,30 +44,30 @@
|
||||
data = null;
|
||||
}
|
||||
if (!data) {
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Subagenten nicht lesbar."));
|
||||
holder.replaceChildren(h("p", {class: "muted"}, "Subagents unreadable."));
|
||||
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)}` : ""));
|
||||
h("div", {class: "row"}, h("button", {type: "button", class: "linkish", title: "Show transcript in the stream", on: {click: () => focusSub(row.session, row.id)}}, `${row.type}: ${row.description}`), row.finished ? pill("finished", "neutral") : pill("running", "ok")),
|
||||
h("span", {class: "muted"}, `Depth ${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."));
|
||||
})) : h("p", {class: "muted"}, "No subagents."));
|
||||
}
|
||||
|
||||
async function deliver(text, file, cls) {
|
||||
let upload = "";
|
||||
if (file) {
|
||||
if (file.size > UPLOAD_LIMIT) {
|
||||
toast("Datei zu groß", [`${file.name}: höchstens 50 MiB`], false);
|
||||
toast("File too large", [`${file.name}: at most 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);
|
||||
toast("Upload failed", [`${stored.status} ${stored.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
upload = stored.data.id;
|
||||
@@ -84,12 +84,12 @@
|
||||
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 icon-only", ...named("Senden")}, icon("bi-send"));
|
||||
const text = h("textarea", {"aria-label": `Message to ${name}`, placeholder: `Message to ${name} …`});
|
||||
const file = h("input", {type: "file", class: "field", "aria-label": "Attach file"});
|
||||
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Send")}, icon("bi-send"));
|
||||
const classes = choice("to-one", TO_ONE, "post");
|
||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; dein Passkey signiert, prüfbar mit operator_message."),
|
||||
h("p", {class: "muted"}, "Post arrives at the end of the turn, Call at once with a read receipt; your passkey signs, verifiable with operator_message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
@@ -104,14 +104,14 @@
|
||||
button.disabled = false;
|
||||
});
|
||||
const info = h("div", {});
|
||||
const subs = h("div", {}, h("p", {class: "muted"}, "Lade Subagenten …"));
|
||||
const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …"));
|
||||
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"}),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeAgent)),
|
||||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
|
||||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||||
iconBtn("bi-x-lg", "Close", "btn small ghost", closeAgent)),
|
||||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||
form);
|
||||
drawer.hidden = false;
|
||||
drawerDom = {info, history: log};
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
return;
|
||||
}
|
||||
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
||||
const labels = {granted: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
|
||||
const labels = {granted: "Notifications on", denied: "Notifications blocked", default: "Enable notifications", unsupported: "Browser has no notifications"};
|
||||
relabel(button, "bi-bell", labels[permission] || labels.default);
|
||||
button.disabled = permission !== "default";
|
||||
}
|
||||
@@ -55,13 +55,13 @@
|
||||
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"});
|
||||
new Notification(`${row.class === "mayday" ? "Mayday" : "Pan-Pan"} from ${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"});
|
||||
new Notification(`${ITEM_LABELS[item.kind]} waiting`, {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}`});
|
||||
new Notification(`Radio from ${dash(row.from)}`, {body: row.text.slice(0, 200), tag: `direct-${row.ts}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@
|
||||
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";
|
||||
const source = batch.source.startsWith("rule:") ? "Rule" : "Grant";
|
||||
undoToast(batch, `${source}: ${batch.items.map((item) => short(item.title)).join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const art = {repo: "", node: null};
|
||||
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artefakt", "bi-box-seam"], task: ["Aufgabe", "bi-card-checklist"]};
|
||||
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artifact", "bi-box-seam"], task: ["Task", "bi-card-checklist"]};
|
||||
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
|
||||
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
|
||||
const tail = (path, parts) => String(path).split("/").filter(Boolean).slice(-parts).join("/");
|
||||
@@ -101,7 +101,7 @@
|
||||
const toneOf = (branch) => (branch.state === "merged-clean" ? "ok" : branch.state === "dirty" ? "warn" : branch.state === "protected" ? "violet" : branch.state === "stale" ? "warn" : branch.gone ? "bad" : "info");
|
||||
|
||||
function artOrbit(repo, model) {
|
||||
const svg = chartFrame(420, `Orbit: ${repo.name} als Sonne, Branches als Planeten (Größe = Commits voraus), Worktrees als Monde, Agenten als Satelliten`);
|
||||
const svg = chartFrame(420, `Orbit: ${repo.name} as the sun, branches as planets (size = commits ahead), worktrees as moons, agents as satellites`);
|
||||
const rings = repo.branches.length > 16 ? [200, 310] : [250];
|
||||
svg.append(...rings.map((r) => s("circle", {class: "ring", r})));
|
||||
const layer = s("g", {});
|
||||
@@ -132,14 +132,14 @@
|
||||
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(branches.length, 1);
|
||||
return [branch.name, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
|
||||
}));
|
||||
const svg = chartFrame(440, `Chord: Überlappung der Branches von ${repo.name}; Band = gemeinsam geänderte Dateien, gestrichelt = Merge`);
|
||||
const svg = chartFrame(440, `Chord: branch overlap in ${repo.name}; band = files changed by both, dashed = merge`);
|
||||
const arcs = s("g", {});
|
||||
for (const overlap of repo.overlaps) {
|
||||
const from = spots.get(overlap.a);
|
||||
const to = spots.get(overlap.b);
|
||||
if (from && to) {
|
||||
arcs.append(s("path", {class: "edge t-alarm", d: `M${fixed(from.x)},${fixed(from.y)} Q0,0 ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": fixed(width(overlap.count))},
|
||||
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} gemeinsame Dateien (Konfliktrisiko)\n${overlap.files.join("\n")}`)));
|
||||
s("title", {}, `${overlap.a} ↔ ${overlap.b}: ${overlap.count} shared files (conflict risk)\n${overlap.files.join("\n")}`)));
|
||||
}
|
||||
}
|
||||
for (const merge of repo.merges) {
|
||||
@@ -148,7 +148,7 @@
|
||||
if (from && to && from !== to) {
|
||||
const control = {x: (from.x + to.x) * 0.3, y: (from.y + to.y) * 0.3};
|
||||
arcs.append(s("path", {class: "edge t-grant merge", d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-grant)"},
|
||||
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
|
||||
s("title", {}, `${merge.from} → ${merge.into}: merged by ${merge.by}`)));
|
||||
}
|
||||
}
|
||||
for (const edge of flowEdges(repo)) {
|
||||
@@ -156,7 +156,7 @@
|
||||
const to = spots.get(edge.into);
|
||||
const bend = edge.side === "pull" ? 0.55 : 0.15;
|
||||
arcs.append(s("path", {class: "edge t-direct flow", "data-side": edge.side, d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed((from.x + to.x) * bend)},${fixed((from.y + to.y) * bend)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-direct)"},
|
||||
s("title", {}, `${edge.from} → ${edge.into}: Merge-Flow, ${edge.side === "pull" ? `${edge.into} zieht ${edge.ref} vor dem Gate` : `${edge.from} mergt fertig in ${edge.ref}`}`)));
|
||||
s("title", {}, `${edge.from} → ${edge.into}: merge flow, ${edge.side === "pull" ? `${edge.into} pulls ${edge.ref} before the gate` : `${edge.from} merges into ${edge.ref} when done`}`)));
|
||||
}
|
||||
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
|
||||
const labels = branches.map((branch) => {
|
||||
@@ -175,7 +175,7 @@
|
||||
const [left, right, top, lane, full] = [200, 24, 40, 34, 1120];
|
||||
const height = top + lanes.length * lane + 12;
|
||||
const x = (ts) => left + ((Math.min(Math.max(ts, begin), end) - begin) / (end - begin)) * (full - left - right);
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: Commits, Reviews, Gates und Pushes je Branch von ${repo.name}`}, defs());
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: commits, reviews, gates and pushes per branch of ${repo.name}`}, defs());
|
||||
lanes.forEach((branch, 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", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
|
||||
@@ -201,9 +201,9 @@
|
||||
|
||||
function artLegend(view) {
|
||||
const words = {
|
||||
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
|
||||
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation · gepunktet = Merge-Flow des Worktrees",
|
||||
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
|
||||
orbit: "Sun = repository · planet = branch, size = commits ahead · moon = worktree · satellite = agent or artifact",
|
||||
chord: "Band = files both branches change (from the reviews in the hub, known only there) · dashed = merge per gc classification · dotted = merge flow of the worktree",
|
||||
swimlane: "Commit = approved review · Gate = review gate and queue runs · Push = runner command with push · Time range = 24 h ▾",
|
||||
};
|
||||
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
|
||||
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
|
||||
@@ -212,33 +212,33 @@
|
||||
function artPanel(model) {
|
||||
const node = model.byId.get(art.node);
|
||||
if (!node) {
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf ein Repository, einen Branch, einen Worktree, einen Agenten oder ein Artefakt.")];
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Click a repository, a branch, a worktree, an agent or an artifact.")];
|
||||
}
|
||||
const [kindName, kindIcon] = ART_KINDS[node.kind];
|
||||
const data = node.data;
|
||||
const body = [];
|
||||
if (node.kind === "repo") {
|
||||
body.push(meta([["Pfad", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["Stand", data.scanned_at]]),
|
||||
body.push(meta([["Path", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["As of", data.scanned_at]]),
|
||||
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
|
||||
} else if (node.kind === "branch") {
|
||||
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
|
||||
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]),
|
||||
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
|
||||
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
|
||||
h("h3", {}, "Dry run"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
|
||||
} else if (node.kind === "worktree") {
|
||||
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Path", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||
flowDetails(data.flow, data.branch),
|
||||
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
|
||||
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "No agent works here."),
|
||||
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
|
||||
} else if (node.kind === "agent") {
|
||||
body.push(h("div", {class: "actions"}, btn("Agent öffnen", "btn small soft", () => openAgent(data.name))));
|
||||
body.push(h("div", {class: "actions"}, btn("Open agent", "btn small soft", () => openAgent(data.name))));
|
||||
} else if (node.kind === "artefact") {
|
||||
body.push(meta([["Ref", data.ref], ["Art", data.kind], ["Aufgabe", data.task], ["Seit", data.added]]), data.ended ? pill("Aufgabe beendet: aufräumen", "warn") : null);
|
||||
body.push(meta([["Ref", data.ref], ["Kind", data.kind], ["Task", data.task], ["Since", data.added]]), data.ended ? pill("Task ended: clean up", "warn") : null);
|
||||
} else {
|
||||
body.push(meta([["Titel", data.title], ["Status", data.status]]));
|
||||
body.push(meta([["Title", data.title], ["Status", data.status]]));
|
||||
}
|
||||
return [
|
||||
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => artSelect(node.id))),
|
||||
iconBtn("bi-x-lg", "Close", "btn small ghost", () => artSelect(node.id))),
|
||||
...body,
|
||||
];
|
||||
}
|
||||
@@ -251,12 +251,12 @@
|
||||
drawArtifacts();
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const reload = iconBtn("bi-arrow-clockwise", "Neu laden", "btn small soft", fetchTree, "Neu laden: den Tree-Stand von pkgmgr lesen");
|
||||
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchTree, "Reload: read the tree state from pkgmgr");
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const legendBox = h("div", {});
|
||||
const list = h("div", {class: "repos"});
|
||||
const search = view === "tree" ? h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
|
||||
const search = view === "tree" ? h("input", {type: "search", placeholder: "Filter repo, branch, path or agent …", value: tree.q, "aria-label": "Filter tree", on: {input: (event) => {
|
||||
tree.q = event.target.value;
|
||||
drawArtifacts();
|
||||
}}}) : null;
|
||||
@@ -283,11 +283,11 @@
|
||||
tree.data = await response.json();
|
||||
tree.model = artifactModel(tree.data);
|
||||
} else {
|
||||
tree.data = {repos: [], scanned: null, error: `/api/tree antwortet ${response.status}`};
|
||||
tree.data = {repos: [], scanned: null, error: `/api/tree answered ${response.status}`};
|
||||
}
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
tree.data = tree.data || {repos: [], scanned: null, error: "Hub nicht erreichbar"};
|
||||
tree.data = tree.data || {repos: [], scanned: null, error: "Hub unreachable"};
|
||||
}
|
||||
tree.busy = false;
|
||||
drawArtifacts();
|
||||
@@ -302,9 +302,9 @@
|
||||
return;
|
||||
}
|
||||
const empty = !tree.data || tree.data.error;
|
||||
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
if (empty) {
|
||||
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
|
||||
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
|
||||
tree.dom.aside.replaceChildren();
|
||||
return;
|
||||
}
|
||||
@@ -320,5 +320,5 @@
|
||||
const repo = chosenRepo();
|
||||
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
|
||||
tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
|
||||
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("Kein Repository", "pkgmgr nennt kein Repository."));
|
||||
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("No repository", "pkgmgr names no repository."));
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
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);
|
||||
toast(`${label}: ${result.data.ok ? "done" : "failed"}`, runLines(result.data), result.data.ok);
|
||||
} else {
|
||||
toast(`${label}: fehlgeschlagen`, [`${result.status} ${result.text}`.trim()], false);
|
||||
toast(`${label}: failed`, [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
return result.ok && result.data && result.data.ok;
|
||||
@@ -23,14 +23,14 @@
|
||||
}
|
||||
|
||||
function choice(name, options, picked) {
|
||||
return h("div", {class: "classes", role: "radiogroup", "aria-label": "Klasse"}, options.map(([id, label]) => h("label", {class: "check"},
|
||||
return h("div", {class: "classes", role: "radiogroup", "aria-label": "Class"}, options.map(([id, label]) => h("label", {class: "check"},
|
||||
h("input", {type: "radio", name, value: id, checked: id === picked}), label)));
|
||||
}
|
||||
|
||||
const picked = (group) => group.querySelector("input:checked").value;
|
||||
|
||||
function toAll() {
|
||||
const text = h("textarea", {"aria-label": "Funk an alle", placeholder: "Nachricht an alle Agenten …", on: {input: (event) => {
|
||||
const text = h("textarea", {"aria-label": "Radio to all", placeholder: "Message to all agents …", on: {input: (event) => {
|
||||
ui.draft = event.target.value;
|
||||
}}});
|
||||
text.value = ui.draft;
|
||||
@@ -45,9 +45,9 @@
|
||||
text.value = "";
|
||||
}
|
||||
};
|
||||
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Funk an alle"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort. Dein Passkey signiert jede Nachricht."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Senden", "btn soft", send, "Senden an alle"))));
|
||||
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Radio to all"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
|
||||
text, classes, h("p", {class: "muted"}, "Newsletter arrives as post at the end of the turn; Broadcast, Pan-Pan, Security and Mayday at once. Your passkey signs every message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Send", "btn soft", send, "Send to all"))));
|
||||
}
|
||||
|
||||
function openToAll() {
|
||||
@@ -62,12 +62,12 @@
|
||||
}
|
||||
|
||||
function agentsToolbar() {
|
||||
const clear = iconBtn("bi-life-preserver", "Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
|
||||
const clear = iconBtn("bi-life-preserver", "Cancel mayday", "btn small danger", () => confirmAct("clear-mayday"));
|
||||
const mode = h("span", {});
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("p", {class: "muted"}, "Evening: agents finish their task. Sunset: they sleep. Sunrise: all wake up."),
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", openAgentOrder, "Priorität: Reihenfolge der Agenten in der Hierarchie"), night), clear, mode};
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, toAll(), clear, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night), clear, mode};
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const CLEANUP_POLL = 2000;
|
||||
const CLEANUP_PATIENCE = 900000;
|
||||
const RESULT_LABELS = {done: ["gelöscht", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||
const RESULT_LABELS = {done: ["deleted", "ok"], refused: ["refused", "bad"], failed: ["failed", "bad"]};
|
||||
|
||||
function planOf(item) {
|
||||
const repo = (tree.data.repos || []).find((entry) => entry.path === item.repository);
|
||||
@@ -19,37 +19,37 @@
|
||||
h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
|
||||
});
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger", ...named("Löschen", "Löschen: mit deiner Passkey-Signatur an den Host übergeben")}, icon("bi-trash3"), "Löschen");
|
||||
const confirm = h("button", {type: "button", class: "btn danger", ...named("Delete", "Delete: hand over to the host with your passkey signature")}, icon("bi-trash3"), "Delete");
|
||||
confirm.addEventListener("click", async () => {
|
||||
confirm.disabled = true;
|
||||
if (!await removeSigned(items, outcome)) {
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
openSheet(items.length > 1 ? `${items.length} gemergte Branches löschen` : `${items[0].branch} löschen`, [
|
||||
h("p", {class: "muted"}, "Trockenlauf von pkgmgr branch cleanup --local für genau diese Einträge: Worktree, lokaler Branch, Compose-Stack ohne -v und seine Images. Kein Remote-Branch. Der Host prüft deine Signatur und verweigert jeden Eintrag, dessen Stand sich seither geändert hat."),
|
||||
h("ul", {class: "mini", "aria-label": "Trockenlauf"}, rows),
|
||||
openSheet(items.length > 1 ? `Delete ${items.length} merged branches` : `Delete ${items[0].branch}`, [
|
||||
h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup --local for exactly these entries: worktree, local branch, Compose stack without -v and its images. No remote branch. The host checks your signature and refuses every entry whose state has changed since."),
|
||||
h("ul", {class: "mini", "aria-label": "Dry run"}, rows),
|
||||
outcome,
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
|
||||
], false);
|
||||
}
|
||||
|
||||
async function removeSigned(items, outcome) {
|
||||
const signed = await proofFor(items, "Löschen");
|
||||
const signed = await proofFor(items, "Delete");
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Not signed: nothing was handed over."));
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/cleanup", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast("Löschen: nicht möglich", fail(result), false);
|
||||
toast("Delete: not possible", fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signed and handed to the host (job ${result.data.id}); the path unit deletes now.`));
|
||||
followJob(`/api/cleanup/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||
const {good, lines} = showJob(data, outcome, "branch", RESULT_LABELS);
|
||||
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, lines, good);
|
||||
toast(`Delete: ${good ? "done" : "not everything deleted"}`, lines, good);
|
||||
fetchTree();
|
||||
});
|
||||
return true;
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
function showJob(data, outcome, key, labels) {
|
||||
const lines = data.error ? [data.error] : data.items.map((item) => `${item[key]}: ${(labels[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
||||
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Result"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||
const [label, tone] = labels[item.status] || [item.status, "neutral"];
|
||||
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item[key]), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
||||
})));
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
const CHANNELS = [...TO_ONE, ...TO_ALL];
|
||||
const CHANNEL_HINTS = {
|
||||
post: "Post kommt am Ende des Zuges an.",
|
||||
call: "Call kommt sofort, mit Lesebestätigung.",
|
||||
newsletter: "Newsletter kommt als Post an alle, am Ende ihres Zuges.",
|
||||
broadcast: "Broadcast erreicht alle sofort.",
|
||||
"pan-pan": "PAN-PAN: dringend, an alle, sofort.",
|
||||
security: "SECURITE: Sicherheitshinweis an alle, sofort.",
|
||||
mayday: "MAYDAY: alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst.",
|
||||
post: "Post arrives at the end of the turn.",
|
||||
call: "Call arrives at once, with a read receipt.",
|
||||
newsletter: "Newsletter arrives as post to all, at the end of their turn.",
|
||||
broadcast: "Broadcast reaches all at once.",
|
||||
"pan-pan": "PAN-PAN: urgent, to all, at once.",
|
||||
security: "Sécurité: safety notice to all stations, now.",
|
||||
mayday: "MAYDAY: all agents pause writing steps until you cancel it.",
|
||||
};
|
||||
const VOICE_POLL = 700;
|
||||
const composer = {voice: "", recorder: null, timer: 0, started: 0};
|
||||
|
||||
function confirmMayday() {
|
||||
return window.confirm("Mayday an alle senden? Alle Agenten pausieren schreibende Schritte, bis du ihn aufhebst.");
|
||||
return window.confirm("Send a mayday to all? All agents pause writing steps until you cancel it.");
|
||||
}
|
||||
|
||||
async function radioSend(message) {
|
||||
const label = `${CLASSES[message.cls] || message.cls} an ${message.to === "*" ? "alle" : message.to}`;
|
||||
const label = `${CLASSES[message.cls] || message.cls} to ${message.to === "*" ? "all" : message.to}`;
|
||||
const fields = {to: message.to, text: message.text, upload: message.upload, class: message.cls, voice: message.voice};
|
||||
const signed = await signedProof("/api/messages/sign", fields, label);
|
||||
if (!signed) {
|
||||
@@ -27,10 +27,10 @@
|
||||
? await sendJson("/api/board/broadcast", {text: fields.text, class: fields.class, voice: fields.voice, proof})
|
||||
: await sendJson("/api/messages", {...fields, proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label} fehlgeschlagen`, fail(result), false);
|
||||
toast(`${label} failed`, fail(result), false);
|
||||
return false;
|
||||
}
|
||||
toast(`${label}: ${result.data.ok ? "gesendet" : "gespeichert, Senden fehlgeschlagen"}`, [...(result.data.id ? [`kvb-orchestrator msg ${result.data.id}`] : []), ...runLines(result.data)], result.data.ok);
|
||||
toast(`${label}: ${result.data.ok ? "sent" : "stored, sending failed"}`, [...(result.data.id ? [`kvb-orchestrator msg ${result.data.id}`] : []), ...runLines(result.data)], result.data.ok);
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
composer.voice = note;
|
||||
parts.attach.checked = false;
|
||||
parts.replay.hidden = !note;
|
||||
parts.replay.replaceChildren(...(note ? [icon("bi-soundwave"), h("span", {class: "muted"}, "Sprachnachricht"), voiceReplay(note), h("label", {class: "check"}, parts.attach, "Aufnahme anhängen"), iconBtn("bi-trash", "Aufnahme entfernen", "btn small ghost", () => attachVoice(parts, ""))] : []));
|
||||
parts.replay.replaceChildren(...(note ? [icon("bi-soundwave"), h("span", {class: "muted"}, "Voice message"), voiceReplay(note), h("label", {class: "check"}, parts.attach, "Attach recording"), iconBtn("bi-trash", "Remove recording", "btn small ghost", () => attachVoice(parts, ""))] : []));
|
||||
}
|
||||
|
||||
async function heardNote(note) {
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, VOICE_POLL));
|
||||
}
|
||||
return {status: "failed", error: "keine Mitschrift in der erwarteten Zeit"};
|
||||
return {status: "failed", error: "no transcript in the expected time"};
|
||||
}
|
||||
|
||||
async function keepRecording(blob, parts) {
|
||||
@@ -60,28 +60,28 @@
|
||||
try {
|
||||
const sound = context();
|
||||
if (!sound || !blob.size) {
|
||||
throw new Error("keine Aufnahme");
|
||||
throw new Error("no recording");
|
||||
}
|
||||
parts.status.textContent = "Wandelt um …";
|
||||
parts.status.textContent = "Converting …";
|
||||
const stored = await send("/api/voice", await wavOf(blob, sound), "audio/wav");
|
||||
if (!stored.ok || !stored.data) {
|
||||
throw new Error(`${stored.status} ${stored.text}`.trim());
|
||||
}
|
||||
attachVoice(parts, stored.data.id);
|
||||
parts.status.textContent = "Schreibt mit …";
|
||||
parts.status.textContent = "Transcribing …";
|
||||
const note = await heardNote(stored.data.id);
|
||||
if (note.status === "failed") {
|
||||
parts.status.textContent = "Ohne Mitschrift: schreib den Text selbst.";
|
||||
toast("Mitschrift fehlgeschlagen", [note.error], false);
|
||||
parts.status.textContent = "No transcript: write the text yourself.";
|
||||
toast("Transcript failed", [note.error], false);
|
||||
return;
|
||||
}
|
||||
parts.text.value = [parts.text.value.trim(), note.text].filter(Boolean).join(" ");
|
||||
parts.text.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
parts.text.focus();
|
||||
parts.status.textContent = note.text ? "Text prüfen, dann senden." : "Nichts verstanden: schreib den Text selbst.";
|
||||
parts.status.textContent = note.text ? "Check the text, then send." : "Nothing understood: write the text yourself.";
|
||||
} catch (error) {
|
||||
parts.status.textContent = "";
|
||||
toast("Aufnahme fehlgeschlagen", [String(error.message || error)], false);
|
||||
toast("Recording failed", [String(error.message || error)], false);
|
||||
} finally {
|
||||
parts.record.disabled = false;
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
function recordLabel(parts, on) {
|
||||
parts.record.setAttribute("aria-pressed", String(on));
|
||||
parts.record.replaceChildren(icon(on ? "bi-stop-fill" : "bi-record-circle"), on ? "Stopp" : "Aufnehmen");
|
||||
parts.record.replaceChildren(icon(on ? "bi-stop-fill" : "bi-record-circle"), on ? "Stop" : "Record");
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
@@ -103,7 +103,7 @@
|
||||
try {
|
||||
media = await navigator.mediaDevices.getUserMedia({audio: {channelCount: 1, echoCancellation: true, noiseSuppression: true}});
|
||||
} catch (error) {
|
||||
toast("Mikrofon nicht verfügbar", [String(error.message || error)], false);
|
||||
toast("Microphone unavailable", [String(error.message || error)], false);
|
||||
return;
|
||||
}
|
||||
const recorder = new MediaRecorder(media);
|
||||
@@ -125,7 +125,7 @@
|
||||
recordLabel(parts, true);
|
||||
composer.timer = window.setInterval(() => {
|
||||
const seconds = Math.floor((Date.now() - composer.started) / 1000);
|
||||
parts.status.textContent = `Nimmt auf ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} von höchstens ${state.speech.seconds} s`;
|
||||
parts.status.textContent = `Recording ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} of at most ${state.speech.seconds} s`;
|
||||
if (seconds >= state.speech.seconds) {
|
||||
stopRecording();
|
||||
}
|
||||
@@ -135,36 +135,36 @@
|
||||
function composerForm() {
|
||||
const channel = h("select", {class: "field"}, CHANNELS.map(([id, label]) => h("option", {value: id}, label)));
|
||||
const recipient = h("select", {class: "field"});
|
||||
const toField = h("label", {}, "An", recipient);
|
||||
const text = h("textarea", {"aria-label": "Nachricht", placeholder: "Nachricht an die Agenten …", maxlength: 20000});
|
||||
const toField = h("label", {}, "To", recipient);
|
||||
const text = h("textarea", {"aria-label": "Message", placeholder: "Message to the agents …", maxlength: 20000});
|
||||
const replay = h("div", {class: "voice-note", hidden: true});
|
||||
const hint = h("p", {class: "muted"});
|
||||
const status = h("span", {class: "muted composer-status", "aria-live": "polite"});
|
||||
const record = h("button", {type: "button", class: "btn small", "aria-pressed": "false", title: "Aufnehmen: ein Klick startet, noch einer beendet; der Hub schreibt mit, du prüfst den Text vor dem Senden"});
|
||||
const submit = h("button", {type: "submit", class: "btn primary"}, icon("bi-send"), "Senden");
|
||||
const attach = h("input", {type: "checkbox", title: "Aufnahme anhängen: die Agenten bekommen den Pfad der Tondatei; sonst geht nur der geprüfte Text"});
|
||||
const record = h("button", {type: "button", class: "btn small", "aria-pressed": "false", title: "Record: one click starts, another stops; the hub transcribes, you check the text before sending"});
|
||||
const submit = h("button", {type: "submit", class: "btn primary"}, icon("bi-send"), "Send");
|
||||
const attach = h("input", {type: "checkbox", title: "Attach recording: the agents get the path of the sound file; otherwise only the checked text goes out"});
|
||||
const parts = {record, status, text, replay, attach};
|
||||
recordLabel(parts, false);
|
||||
const gap = micGap();
|
||||
if (gap) {
|
||||
record.disabled = true;
|
||||
record.title = `Aufnehmen: ${gap}`;
|
||||
record.title = `Record: ${gap}`;
|
||||
}
|
||||
record.addEventListener("click", () => (composer.recorder ? stopRecording() : startRecording(parts)));
|
||||
const sync = () => {
|
||||
const direct = TO_ONE.some(([id]) => id === channel.value);
|
||||
toField.hidden = !direct;
|
||||
hint.textContent = `${CHANNEL_HINTS[channel.value]} Dein Passkey signiert jede Nachricht.`;
|
||||
hint.textContent = `${CHANNEL_HINTS[channel.value]} Your passkey signs every message.`;
|
||||
};
|
||||
channel.addEventListener("change", sync);
|
||||
sync();
|
||||
const form = h("form", {class: "composer"}, h("div", {class: "composer-grid"}, h("label", {}, "Kanal", channel), toField), text, replay, hint,
|
||||
const form = h("form", {class: "composer"}, h("div", {class: "composer-grid"}, h("label", {}, "Channel", channel), toField), text, replay, hint,
|
||||
h("div", {class: "actions"}, record, status, h("span", {class: "spacer"}), submit));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const direct = TO_ONE.some(([id]) => id === channel.value);
|
||||
if (!text.value.trim()) {
|
||||
toast("Nichts zu senden", ["Schreib einen Text oder nimm einen auf; die Mitschrift landet zum Prüfen im Feld."], false);
|
||||
toast("Nothing to send", ["Write a text or record one; the transcript lands in the field for checking."], false);
|
||||
return;
|
||||
}
|
||||
if ((direct && !recipient.value) || (channel.value === "mayday" && !confirmMayday())) {
|
||||
|
||||
@@ -25,19 +25,19 @@
|
||||
|
||||
function consoleWords(job) {
|
||||
if (job.status === "queued") {
|
||||
return `wartet auf den Runner seit ${lasting(nowSeconds() - job.created_ts)}`;
|
||||
return `waiting for the runner for ${lasting(nowSeconds() - job.created_ts)}`;
|
||||
}
|
||||
if (job.status === "running") {
|
||||
return `läuft seit ${lasting(nowSeconds() - job.started)}`;
|
||||
return `running for ${lasting(nowSeconds() - job.started)}`;
|
||||
}
|
||||
if (job.status === "refused") {
|
||||
return "der Runner hat die Signatur nicht angenommen";
|
||||
return "the runner did not accept the signature";
|
||||
}
|
||||
return `Exit-Code ${job.code} nach ${lasting(job.finished - job.started)}`;
|
||||
return `Exit code ${job.code} after ${lasting(job.finished - job.started)}`;
|
||||
}
|
||||
|
||||
function paintConsole(view, job) {
|
||||
const text = job.output || (jobOpen(job) ? "Noch keine Ausgabe." : "Keine Ausgabe.");
|
||||
const text = job.output || (jobOpen(job) ? "No output yet." : "No output.");
|
||||
if (view.out.textContent !== text) {
|
||||
view.out.textContent = text;
|
||||
}
|
||||
@@ -48,7 +48,7 @@
|
||||
function consoleOf(job) {
|
||||
let view = consoles.get(job.id);
|
||||
if (!view) {
|
||||
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Ausgabe des Befehls", "data-job": job.id});
|
||||
const out = h("pre", {class: "console-out", tabindex: "0", "aria-label": "Command output", "data-job": job.id});
|
||||
const line = h("div", {class: "console-status"});
|
||||
view = {box: h("div", {class: "console", "data-job": job.id}, line, out), out, line, follow: true, top: 0};
|
||||
out.addEventListener("scroll", () => {
|
||||
@@ -87,7 +87,7 @@
|
||||
async function dismissJob(job) {
|
||||
const result = await sendJson("/api/inbox/dismiss", {id: job.id});
|
||||
if (!result.ok) {
|
||||
toast("Schließen nicht möglich", fail(result), false);
|
||||
toast("Cannot close", fail(result), false);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
@@ -3,27 +3,27 @@
|
||||
const ACTIVE_BEAT = 900;
|
||||
const DAY = 86400;
|
||||
const HUMAN = "Operator";
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "all"], [-1, "custom"]];
|
||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
|
||||
const PARTS = [["user", "User"], ["assistant", "Assistant"], ["tool_call", "Tool call"], ["tool_result", "Tool result"], ["system", "System"]];
|
||||
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
||||
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
||||
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator", rule: "Regelwunsch"};
|
||||
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
|
||||
const ACTIONS = [["clear-mayday", "Mayday aufheben", "Die Funkstille des aktiven Maydays aufheben?"], ["evening", "Abend", "Alle Agenten in den Abend schicken (kvb-agent-relax)?"], ["sunset", "Sonnenuntergang", "Alle Agenten schlafen legen (kvb-agent-sleep)?"], ["sunrise", "Sonnenaufgang", "Alle Agenten wecken (kvb-agent-wakeup)?"]];
|
||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Decision", operator: "Operator", rule: "Rule request"};
|
||||
const NIGHT = {day: ["Day", "ok"], evening: ["Evening", "warn"], sunset: ["Night", "violet"]};
|
||||
const ACTIONS = [["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?"], ["evening", "Evening", "Send all agents into the evening (kvb-agent-relax)?"], ["sunset", "Sunset", "Put all agents to sleep (kvb-agent-sleep)?"], ["sunrise", "Sunrise", "Wake all agents (kvb-agent-wakeup)?"]];
|
||||
const UPLOAD_LIMIT = 50 * 1024 * 1024;
|
||||
const STREAM_KEEP = 5000;
|
||||
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Security", receipt: "Gelesen", grant: "Freigabe"};
|
||||
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sécurité", receipt: "Read", grant: "Grant"};
|
||||
const PILLS = {post: "info", call: "info", newsletter: "teal", broadcast: "teal", mayday: "bad", "pan-pan": "warn", security: "violet", receipt: "neutral", grant: "ok"};
|
||||
const LEGEND = [["direct", "Post / Call"], ["broadcast", "Newsletter / Broadcast"], ["alarm", "Mayday / Pan-Pan"], ["security", "Security"], ["grant", "Queue-Freigabe"]];
|
||||
const TO_ALL = [["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["pan-pan", "Pan-Pan"], ["security", "Security"], ["mayday", "Mayday"]];
|
||||
const LEGEND = [["direct", "Post / Call"], ["broadcast", "Newsletter / Broadcast"], ["alarm", "Mayday / Pan-Pan"], ["security", "Sécurité"], ["grant", "Queue grant"]];
|
||||
const TO_ALL = [["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["pan-pan", "Pan-Pan"], ["security", "Sécurité"], ["mayday", "Mayday"]];
|
||||
const TO_ONE = [["post", "Post"], ["call", "Call"]];
|
||||
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"],
|
||||
running: ["running", "ok"], queued: ["waiting", "info"], blocked: ["blocked", "bad"], stale: ["stale", "warn"], pending: ["open", "warn"], done: ["done", "neutral"], stopped: ["stopped", "neutral"],
|
||||
approved: ["approved", "ok"], rejected: ["denied", "bad"], allowed: ["allowed", "ok"], denied: ["denied", "bad"], expired: ["expired", "neutral"], decided: ["decided", "ok"], failed: ["failed", "bad"], refused: ["refused", "bad"],
|
||||
};
|
||||
const SECTIONS = {staged: "Gestaged", unstaged: "Nicht gestaged", untracked: "Unversioniert"};
|
||||
const SECTIONS = {staged: "Staged", unstaged: "Not staged", untracked: "Untracked"};
|
||||
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"};
|
||||
@@ -34,16 +34,16 @@
|
||||
const RELAYOUT = 60000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const STORE = {views: "kvb-orchestrator.views"};
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, Asks, Entscheidungen"], ["direct", "Post und Calls an dich"], ["radio", "Aller Funk"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "SECURITE"]];
|
||||
const SOUND_KINDS = [["mayday", "MAYDAY / PAN-PAN"], ["review", "Reviews, asks, decisions"], ["direct", "Post and calls to you"], ["radio", "All radio"]];
|
||||
const SPEECH_CLASSES = [["post", "Post"], ["call", "Call"], ["newsletter", "Newsletter"], ["broadcast", "Broadcast"], ["mayday", "MAYDAY"], ["pan-pan", "PAN-PAN"], ["security", "Sécurité"]];
|
||||
const SPEECH_QUEUE = 20;
|
||||
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 THEME_CHOICES = [["sky", "Sky (follows the light outside)"], ["light", "Always light"], ["dark", "Always dark"], ["custom", "Custom colors"]];
|
||||
const COLOR_FIELDS = [["top", "Sky at the top"], ["bottom", "Sky at the horizon"], ["accent", "Accent"]];
|
||||
const STAGE_LABELS = {night: "Night", astronomical: "Astronomical twilight", nautical: "Nautical twilight", civil: "Civil twilight", golden: "Golden hour", day: "Day"};
|
||||
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
const COMPASS = [["N", "North"], ["NE", "Northeast"], ["E", "East"], ["SE", "Southeast"], ["S", "South"], ["SW", "Southwest"], ["W", "West"], ["NW", "Northwest"]];
|
||||
const SUBS_TTL = 60000;
|
||||
const TURN = 10;
|
||||
const ZOOM = 1.2;
|
||||
@@ -60,8 +60,8 @@
|
||||
direct: [[740, 0, 0.2]],
|
||||
radio: [[520, 0, 0.07]],
|
||||
};
|
||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["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", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
|
||||
const KEYS = [["Ctrl+K", "Commands, views, agents and AI command"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: next / previous entry"], ["x", "Inbox: select entry"], ["a", "Inbox: grant entry (3 s to undo)"], ["Enter", "Inbox: open entry"], ["/", "Search the stream"], ["h", "Look at the sky: drag turns the view, wheel or two fingers zoom, Esc ends"], ["Arrows · + / − · 0", "Sky (focused): turn · zoom · reset"], ["Drag · wheel", "3D: turn, right drag pans, wheel zooms"],["🎙 hold", "Voice input into the agent field or the command line, never sent automatically"], ["?", "This help"], ["Esc", "Close"]];
|
||||
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"});
|
||||
const relative = new Intl.RelativeTimeFormat("en", {numeric: "auto"});
|
||||
const absolute = new Intl.DateTimeFormat("en-GB", {dateStyle: "medium", timeStyle: "short"});
|
||||
const clock = new Intl.DateTimeFormat("en-GB", {hour: "2-digit", minute: "2-digit"});
|
||||
@@ -1,40 +1,40 @@
|
||||
const DENY_CHANNELS = [["post", true, "sobald sein Zug endet"], ["call", true, "sofort, weckt ihn"], ["newsletter", false, "sobald ihr Zug endet"], ["broadcast", false, "sofort"], ["pan-pan", false, "sofort, dringend"], ["security", false, "sofort, als Sicherheitsmeldung"]];
|
||||
const DENY_CHANNELS = [["post", true, "when its turn ends"], ["call", true, "at once, wakes it"], ["newsletter", false, "when their turn ends"], ["broadcast", false, "at once"], ["pan-pan", false, "at once, urgent"], ["security", false, "at once, as a safety notice"]];
|
||||
const DENY_REASON = 2000;
|
||||
const requesterOf = (item) => item.to || "niemanden (Empfänger unklar)";
|
||||
const requesterOf = (item) => item.to || "nobody (recipient unclear)";
|
||||
|
||||
function denyNow(item, channel, reason) {
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason, to: item.to}], `Ablehnung (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
return submit([{...entryOf(item, "reject", ""), channel, reason, to: item.to}], `Denial (${CLASSES[channel]}): ${short(item.title)}`);
|
||||
}
|
||||
|
||||
function denySheet(item) {
|
||||
const name = `deny-${keyOf(item)}`;
|
||||
const choices = DENY_CHANNELS.map(([id, direct, hint]) => h("label", {class: "deny-channel"},
|
||||
h("input", {type: "radio", name, value: id, checked: id === "post"}),
|
||||
h("span", {}, h("strong", {}, CLASSES[id]), h("small", {}, `an ${direct ? requesterOf(item) : "alle"}, ${hint}`))));
|
||||
const reason = h("textarea", {class: "field", maxlength: DENY_REASON, rows: 3, placeholder: "z. B. erst das Gate grün machen"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger"}, icon("bi-x-lg"), "Ablehnen");
|
||||
h("span", {}, h("strong", {}, CLASSES[id]), h("small", {}, `to ${direct ? requesterOf(item) : "all"}, ${hint}`))));
|
||||
const reason = h("textarea", {class: "field", maxlength: DENY_REASON, rows: 3, placeholder: "e.g. make the gate green first"});
|
||||
const confirm = h("button", {type: "button", class: "btn danger"}, icon("bi-x-lg"), "Deny");
|
||||
confirm.addEventListener("click", () => {
|
||||
const picked = choices.map((label) => label.querySelector("input")).find((input) => input.checked);
|
||||
closeSheet();
|
||||
denyNow(item, picked.value, reason.value);
|
||||
});
|
||||
openSheet("Ablehnen", [
|
||||
openSheet("Deny", [
|
||||
h("div", {class: "who"}, pill(ITEM_LABELS[item.kind], KIND_TONES[item.kind]), item.agent ? person(item.agent) : null),
|
||||
h("p", {class: "text"}, item.title),
|
||||
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Kanal"), choices),
|
||||
h("label", {class: "deny-reason"}, "Grund (optional, der Agent liest ihn mit)", reason),
|
||||
h("p", {class: "muted"}, "Der Passkey signiert die Ablehnung samt Kanal, Empfänger und Grund; danach bleiben 3 s zum Rückgängigmachen.",
|
||||
" Mayday bleibt dem Operator vorbehalten."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm)], false);
|
||||
h("fieldset", {class: "deny-channels"}, h("legend", {}, "Channel"), choices),
|
||||
h("label", {class: "deny-reason"}, "Reason (optional, the agent reads it too)", reason),
|
||||
h("p", {class: "muted"}, "The passkey signs the denial with channel, recipient and reason; then 3 s remain to undo it.",
|
||||
" Mayday stays reserved for the operator."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm)], false);
|
||||
reason.focus();
|
||||
}
|
||||
|
||||
function denySplit(item) {
|
||||
const main = iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => denyNow(item, "post", ""), `Ablehnen: per Post an ${requesterOf(item)}, 3 s rückgängig`);
|
||||
const more = h("button", {type: "button", class: "btn small danger icon-only", "aria-haspopup": "dialog", ...named("Ablehnen über …", "Ablehnen über einen anderen Kanal oder mit Grund")}, caret());
|
||||
const main = iconBtn("bi-x-lg", "Deny", "btn small danger", () => denyNow(item, "post", ""), `Deny: by post to ${requesterOf(item)}, 3 s to undo`);
|
||||
const more = h("button", {type: "button", class: "btn small danger icon-only", "aria-haspopup": "dialog", ...named("Deny via …", "Deny via another channel or with a reason")}, caret());
|
||||
more.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
denySheet(item);
|
||||
});
|
||||
return h("div", {class: "deny-split", role: "group", "aria-label": "Ablehnen"}, main, more);
|
||||
return h("div", {class: "deny-split", role: "group", "aria-label": "Deny"}, main, more);
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
const named = (label, hint) => ({title: hint || label, "aria-label": label});
|
||||
const iconBtn = (name, label, cls, run, hint) => h("button", {type: "button", class: `${cls} icon-only`, ...named(label, hint), on: {click: run}}, icon(name));
|
||||
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
|
||||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
|
||||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "all");
|
||||
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);
|
||||
@@ -32,7 +32,7 @@
|
||||
const diff = ts - nowSeconds();
|
||||
const size = Math.abs(diff);
|
||||
if (size < 45) {
|
||||
return "gerade eben";
|
||||
return "just now";
|
||||
}
|
||||
if (size < 3600) {
|
||||
return relative.format(Math.round(diff / 60), "minute");
|
||||
@@ -65,7 +65,7 @@
|
||||
}
|
||||
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))}`;
|
||||
updated.textContent = `${ui.offline ? "No connection · " : ""}${live() ? "Live" : "As of"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||||
}
|
||||
|
||||
function avatar(name) {
|
||||
@@ -76,7 +76,7 @@
|
||||
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 person = (name) => (name && name !== "-" && name !== "all" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `Open ${name}`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||||
const people = () => state.people || {};
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||||
@@ -85,7 +85,7 @@
|
||||
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);
|
||||
const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Close"}, 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);
|
||||
@@ -103,15 +103,15 @@
|
||||
}
|
||||
|
||||
function copyButton(text) {
|
||||
const button = iconBtn("bi-clipboard", "Kopieren", "btn small ghost", null, "Kopieren: in die Zwischenablage");
|
||||
const button = iconBtn("bi-clipboard", "Copy", "btn small ghost", null, "Copy: to the clipboard");
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
relabel(button, "bi-clipboard-check", "Kopiert");
|
||||
relabel(button, "bi-clipboard-check", "Copied");
|
||||
} catch {
|
||||
relabel(button, "bi-x-circle", "Nicht möglich");
|
||||
relabel(button, "bi-x-circle", "Not possible");
|
||||
}
|
||||
window.setTimeout(() => relabel(button, "bi-clipboard", "Kopieren"), 1600);
|
||||
window.setTimeout(() => relabel(button, "bi-clipboard", "Copy"), 1600);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
@@ -127,7 +127,7 @@
|
||||
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"}), iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeSheet)),
|
||||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Close", "btn small ghost", closeSheet)),
|
||||
h("div", {class: "sheet-body"}, body)));
|
||||
sheet.hidden = false;
|
||||
}
|
||||
|
||||
@@ -77,14 +77,14 @@
|
||||
}
|
||||
|
||||
function saveView() {
|
||||
const name = (window.prompt("Name der Ansicht", filter.saved || "") || "").trim();
|
||||
const name = (window.prompt("View name", 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);
|
||||
toast("View saved only until reload", ["The browser does not allow localStorage."], false);
|
||||
}
|
||||
useSaved(entry);
|
||||
}
|
||||
@@ -113,11 +113,11 @@
|
||||
useSaved(view);
|
||||
}
|
||||
},
|
||||
}}, view.name, h("button", {type: "button", class: "x", ...named(`${view.name} löschen`), on: {click: (event) => {
|
||||
}}, view.name, h("button", {type: "button", class: "x", ...named(`Delete ${view.name}`), on: {click: (event) => {
|
||||
event.stopPropagation();
|
||||
dropView(view.name);
|
||||
}}}, icon("bi-x-lg"))));
|
||||
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Ansicht speichern", "btn small ghost", saveView));
|
||||
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Save view", "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) {
|
||||
@@ -131,7 +131,7 @@
|
||||
customize();
|
||||
filter.waiting = !filter.waiting;
|
||||
refilter();
|
||||
}}}, "nur Wartendes");
|
||||
}}}, "only waiting");
|
||||
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) {
|
||||
@@ -140,10 +140,10 @@
|
||||
filter.parts.add(id);
|
||||
}
|
||||
refilter();
|
||||
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
||||
}}}, label)) : [h("span", {class: "muted"}, "Only with the kind Transcripts.")];
|
||||
stream.dom.parts.replaceChildren(...parts);
|
||||
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
|
||||
h("button", {type: "button", class: "x", ...named("Filter entfernen"), on: {click: () => {
|
||||
h("button", {type: "button", class: "x", ...named("Remove filter"), on: {click: () => {
|
||||
filter.sub = "";
|
||||
filter.session = "";
|
||||
filter.agent = "";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const WORK_CHIPS = {dirty: ["bi-pencil-square", "nicht committet", "warn"], unpushed: ["bi-cloud-upload", "ungepusht", "info"], unmerged: ["bi-signpost-split", "nicht gemergt", "neutral"], task: ["bi-card-checklist", "Aufgabe offen", "violet"]};
|
||||
const FLOW_SIDES = {pull: "zieht vor dem Gate", push: "mergt fertig in"};
|
||||
const WORK_CHIPS = {dirty: ["bi-pencil-square", "not committed", "warn"], unpushed: ["bi-cloud-upload", "unpushed", "info"], unmerged: ["bi-signpost-split", "not merged", "neutral"], task: ["bi-card-checklist", "task open", "violet"]};
|
||||
const FLOW_SIDES = {pull: "pulls before the gate", push: "merges into when done"};
|
||||
|
||||
function workChips(reasons) {
|
||||
if (!reasons || !reasons.length) {
|
||||
return null;
|
||||
}
|
||||
return h("span", {class: "states work", role: "group", "aria-label": "Offene Arbeit"}, reasons.map((reason) => {
|
||||
return h("span", {class: "states work", role: "group", "aria-label": "Open work"}, reasons.map((reason) => {
|
||||
const [name, label, tone] = Object.hasOwn(WORK_CHIPS, reason) ? WORK_CHIPS[reason] : ["bi-question-circle", reason, "neutral"];
|
||||
return h("span", {class: "state", "data-tone": tone, title: `offene Arbeit: ${label}`}, icon(name), label);
|
||||
return h("span", {class: "state", "data-tone": tone, title: `open work: ${label}`}, icon(name), label);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -16,25 +16,25 @@
|
||||
return null;
|
||||
}
|
||||
if (flow.source === "invalid") {
|
||||
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge-Flow ungültig: pkgmgr kann die Flow-Datei nicht lesen");
|
||||
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge flow invalid: pkgmgr cannot read the flow file");
|
||||
}
|
||||
const refs = (side) => (flow[side].length ? flow[side].map((ref) => h("code", {class: "flow-ref", title: `${FLOW_SIDES[side]}: ${ref}`}, ref)) : h("span", {class: "muted"}, "–"));
|
||||
const arrow = () => h("span", {class: "flow-arrow", ...named("fließt nach")}, icon("bi-arrow-right"));
|
||||
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge-Flow: ${flow.pull.join(", ") || "keine Quelle"} nach ${branch || "–"} nach ${flow.push.join(", ") || "kein Ziel"}`},
|
||||
const arrow = () => h("span", {class: "flow-arrow", ...named("flows to")}, icon("bi-arrow-right"));
|
||||
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge flow: ${flow.pull.join(", ") || "no source"} to ${branch || "–"} to ${flow.push.join(", ") || "no target"}`},
|
||||
h("span", {class: "flow-label"}, icon("bi-sign-merge-right"), "Flow"),
|
||||
h("span", {class: "flow-side"}, refs("pull")), arrow(),
|
||||
h("code", {class: "flow-ref here", title: "dieser Branch"}, branch || "–"), arrow(),
|
||||
h("code", {class: "flow-ref here", title: "this branch"}, branch || "–"), arrow(),
|
||||
h("span", {class: "flow-side"}, refs("push")));
|
||||
}
|
||||
|
||||
function flowDetails(flow, branch) {
|
||||
if (!flow) {
|
||||
return h("p", {class: "muted"}, "Kein Merge-Flow gesetzt.");
|
||||
return h("p", {class: "muted"}, "No merge flow set.");
|
||||
}
|
||||
if (flow.source === "invalid") {
|
||||
return flowLine(flow, branch);
|
||||
}
|
||||
return [h("h3", {}, "Merge-Flow"), flowLine(flow, branch), meta([["Zieht vor dem Gate", flow.pull.join(", ")], ["Mergt fertig in", flow.push.join(", ")], ["Quelle", flow.source]])];
|
||||
return [h("h3", {}, "Merge flow"), flowLine(flow, branch), meta([["Pulls before the gate", flow.pull.join(", ")], ["Merges into when done", flow.push.join(", ")], ["Source", flow.source]])];
|
||||
}
|
||||
|
||||
function flowTarget(names, ref) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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");
|
||||
const svg = chartFrame(400, "Orbit: operator in the middle; whoever works or radios together moves closer, the silent drift outward");
|
||||
svg.append(...RINGS.map((r) => s("circle", {class: "ring", r})));
|
||||
const edgeLayer = s("g", {});
|
||||
const halos = new Map();
|
||||
@@ -16,7 +16,7 @@
|
||||
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}`)));
|
||||
s("title", {}, `${link.source} to all: ${words}`)));
|
||||
continue;
|
||||
}
|
||||
const to = positions.get(link.target);
|
||||
@@ -45,7 +45,7 @@
|
||||
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 svg = chartFrame(450, "Chord: messages between agents, ordered by closeness");
|
||||
const arcs = s("g", {});
|
||||
const point = (angle, r) => `${fixed(r * Math.cos(angle))},${fixed(r * Math.sin(angle))}`;
|
||||
for (const node of nodes) {
|
||||
@@ -91,7 +91,7 @@
|
||||
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}×`)));
|
||||
s("title", {}, `${fan.source} to all: ${fan.count}×`)));
|
||||
}
|
||||
svg.append(arcs, s("g", {}, nodes.map((node) => nodeMark(node, spots.get(node.id), 74))));
|
||||
return svg;
|
||||
@@ -110,7 +110,7 @@
|
||||
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());
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: messages over time"}, 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));
|
||||
@@ -131,7 +131,7 @@
|
||||
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}`);
|
||||
const hint = s("title", {}, `${clock.format(stampOf(ts))} · ${dash(labelOf(from))} → ${to.length ? to.map(labelOf).join(", ") : "all"}\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})));
|
||||
@@ -150,7 +150,7 @@
|
||||
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})`);
|
||||
arrows("grant", grant.ts, chairKey(), [grant.target], `Queue grant ${grant.kind} (${grant.id})`);
|
||||
}
|
||||
svg.append(marks);
|
||||
return svg;
|
||||
@@ -158,29 +158,29 @@
|
||||
|
||||
function legend() {
|
||||
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
|
||||
h("span", {class: "muted"}, "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit"));
|
||||
h("span", {class: "muted"}, "Line width = count · closeness = recent collaboration"));
|
||||
}
|
||||
|
||||
function side() {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
||||
if (!node) {
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf einen Knoten: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.")];
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Click a node: its panel shows tasks, claims, subagents and history.")];
|
||||
}
|
||||
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)}` : ""}`)),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => select(node.id))),
|
||||
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
|
||||
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}`)),
|
||||
iconBtn("bi-x-lg", "Close", "btn small ghost", () => select(node.id))),
|
||||
h("h3", {}, "Latest messages", 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."),
|
||||
h("span", {}, row.text)))) : h("p", {class: "muted"}, "No messages in the chosen time range."),
|
||||
];
|
||||
}
|
||||
|
||||
function viewSwitch(screen) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "View"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
|
||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
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" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} Aufgaben` : "";
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} tasks` : "";
|
||||
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: {
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}${tasks}`),
|
||||
s("title", {}, `${node.label} · ${NODE_KINDS[node.kind]}${node.parent ? ` of ${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));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const LAYOUTS = [
|
||||
["none", "Kraft", "bi-magnet", "Kräfte ordnen frei: Verbundenes zieht sich an, alles stößt sich ab"],
|
||||
["time", "Zeit", "bi-clock", "Tiefe = Zeit: Früheres liegt hinten, Jüngeres vorn"],
|
||||
["spawn", "Spawn-Tiefe", "bi-layers", "Tiefe = Spawn-Tiefe: jede Ebene eine Generation"],
|
||||
["none", "Force", "bi-magnet", "Forces arrange freely: linked nodes attract, everything repels"],
|
||||
["time", "Time", "bi-clock", "Depth = time: earlier lies behind, later in front"],
|
||||
["spawn", "Spawn depth", "bi-layers", "Depth = spawn depth: each level one generation"],
|
||||
];
|
||||
const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"};
|
||||
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"};
|
||||
@@ -19,7 +19,7 @@
|
||||
tag.nonce = NONCE;
|
||||
tag.async = true;
|
||||
tag.addEventListener("load", resolve);
|
||||
tag.addEventListener("error", () => reject(new Error(`${src} nicht geladen`)));
|
||||
tag.addEventListener("error", () => reject(new Error(`${src} not loaded`)));
|
||||
document.head.append(tag);
|
||||
});
|
||||
}
|
||||
@@ -39,12 +39,12 @@
|
||||
}
|
||||
|
||||
function layoutTools(tab) {
|
||||
const group = h("div", {class: "segmented", role: "group", "aria-label": "Anordnung"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
|
||||
const group = h("div", {class: "segmented", role: "group", "aria-label": "Layout"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
|
||||
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${hint}`), "aria-pressed": String(layoutOf() === id), on: {click: () => chooseLayout(tab, id, group)},
|
||||
}, icon(name))));
|
||||
return h("div", {class: "space-tools"}, group,
|
||||
iconBtn("bi-arrow-counterclockwise", "Ansicht zurücksetzen", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
|
||||
h("span", {class: "muted"}, "Ziehen dreht · rechts ziehen verschiebt · Rad zoomt · Klick öffnet das Panel"));
|
||||
iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
|
||||
h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the panel"));
|
||||
}
|
||||
|
||||
function chooseLayout(tab, id, group) {
|
||||
@@ -152,8 +152,8 @@
|
||||
|
||||
function list3d(tab, data) {
|
||||
const pickable = data.nodes.filter((node) => node.kind !== "operator");
|
||||
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} Knoten als Liste`),
|
||||
h("div", {class: "chips", role: "group", "aria-label": "Knoten"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label))));
|
||||
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} nodes as a list`),
|
||||
h("div", {class: "chips", role: "group", "aria-label": "Nodes"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label))));
|
||||
}
|
||||
|
||||
function signature3d(tab, data) {
|
||||
@@ -176,7 +176,7 @@
|
||||
dispose3d();
|
||||
g3.host = host;
|
||||
g3.tab = tab;
|
||||
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D-Graph ${tab === "agents" ? "der Agenten" : "der Artefakte"}; die Knoten stehen auch darunter als Liste`}), h("div", {class: "space-note"}, "Lade 3D …"));
|
||||
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the nodes are also listed below`}), h("div", {class: "space-note"}, "Loading 3D …"));
|
||||
}
|
||||
g3.sig = sig;
|
||||
const box = host.querySelector(".graph3d");
|
||||
@@ -190,14 +190,14 @@
|
||||
return;
|
||||
}
|
||||
if (!Graph) {
|
||||
host.replaceChildren(emptyState("3D nicht geladen", g3.failed || "3d-force-graph fehlt."));
|
||||
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
|
||||
return;
|
||||
}
|
||||
if (!g3.graph) {
|
||||
try {
|
||||
g3.graph = Graph({controlType: "orbit"})(box);
|
||||
} catch (error) {
|
||||
host.replaceChildren(emptyState("3D nicht verfügbar", `Dieser Browser gibt kein WebGL: ${error.message || error}`));
|
||||
host.replaceChildren(emptyState("3D unavailable", `This browser offers no WebGL: ${error.message || error}`));
|
||||
g3.host = null;
|
||||
return;
|
||||
}
|
||||
@@ -218,7 +218,7 @@
|
||||
place(data.nodes, layout);
|
||||
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
|
||||
drawPlanes(layout, data.nodes);
|
||||
const words = `${data.nodes.length} Knoten · ${data.links.length} Kanten · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · ohne Bewegung (prefers-reduced-motion)" : ""}`;
|
||||
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
|
||||
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data));
|
||||
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const hier = {chart: null, sig: "", closed: new Set(), ids: 0};
|
||||
const OFFICE_ICONS = {chair: "bi-award", line: "bi-person-badge", cluster: "bi-collection", work: "bi-briefcase", other: "bi-signpost-split"};
|
||||
const OFFICE_GROUPS = [["cluster", "Organisatoren", "Organisator"], ["work", "Präsidenten", "Präsident"], ["other", "Weitere Ämter", "Amt"]];
|
||||
const OFFICE_GROUPS = [["cluster", "Organizers", "Organizer"], ["work", "Presidents", "President"], ["other", "Other offices", "Office"]];
|
||||
|
||||
function hierarchyScreen() {
|
||||
hier.chart = h("section", {class: "card org-chart", "aria-labelledby": "org-title"});
|
||||
@@ -55,19 +55,19 @@
|
||||
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
|
||||
return h("div", {class: "org-row", "data-office": claim.office},
|
||||
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `${who} öffnen`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
active ? chip("bi-play-circle", "aktiv", "ok") : chip("bi-pause-circle", "ohne Herzschlag", "neutral"),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
|
||||
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||
h("span", {class: "org-actions"},
|
||||
officeBtn("bi-arrow-left-right", "Übergeben", `Übergeben: ${claim.office} an eine andere Session, mit Passkey-Signatur`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
|
||||
officeBtn("bi-person-x", "Abberufen", `Abberufen: ${claim.office} frei machen, mit Passkey-Signatur`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
|
||||
officeBtn("bi-arrow-left-right", "Hand over", `Hand over: ${claim.office} to another session, with a passkey signature`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
|
||||
officeBtn("bi-person-x", "Recall", `Recall: free ${claim.office}, with a passkey signature`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
|
||||
}
|
||||
|
||||
function vacantRow(office, label) {
|
||||
return h("div", {class: "org-row vacant", "data-office": office},
|
||||
h("span", {class: "org-office"}, icon(OFFICE_ICONS.chair), h("strong", {}, label)),
|
||||
pill("unbesetzt", "warn"),
|
||||
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Besetzen", `Besetzen: ${office} einer Session geben, mit Passkey-Signatur`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
|
||||
pill("vacant", "warn"),
|
||||
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Appoint", `Appoint: give ${office} to a session, with a passkey signature`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
|
||||
}
|
||||
|
||||
function groupRow(glyph, label, count, note) {
|
||||
@@ -81,12 +81,12 @@
|
||||
const open = !hier.closed.has(id);
|
||||
hier.ids += 1;
|
||||
const list = h("ul", {class: "org-tree", id: `org-${hier.ids}`, hidden: !open}, children);
|
||||
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Zuklappen" : "Aufklappen"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
|
||||
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Collapse" : "Expand"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
|
||||
toggle.addEventListener("click", () => {
|
||||
const now = list.hidden;
|
||||
list.hidden = !now;
|
||||
toggle.setAttribute("aria-expanded", String(now));
|
||||
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Zuklappen" : "Aufklappen"}: ${label}`);
|
||||
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Collapse" : "Expand"}: ${label}`);
|
||||
if (now) {
|
||||
hier.closed.delete(id);
|
||||
} else {
|
||||
@@ -103,14 +103,14 @@
|
||||
dimensions.set(claim.dimension, [...(dimensions.get(claim.dimension) || []), claim]);
|
||||
}
|
||||
const lines = [...dimensions].sort(([a], [b]) => a.localeCompare(b)).map(([dimension, held]) => orgNode(`dimension:${dimension}`, dimension,
|
||||
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "Linie" : "Linien"),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Repräsentant"), []))));
|
||||
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "line" : "lines"),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Representative"), []))));
|
||||
const groups = OFFICE_GROUPS.map(([kind, label, title]) => [kind, label, title, claims.filter((entry) => entry.kind === kind)]).filter(([, , , held]) => held.length)
|
||||
.map(([kind, label, title, held]) => orgNode(`group:${kind}`, label, groupRow(OFFICE_ICONS[kind], label, held.length, kind === "other" ? "" : `${kind}/…`),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, kind === "other" ? claim.office : claim.office.slice(kind.length + 1), title), []))));
|
||||
const chair = claims.find((entry) => entry.kind === "chair");
|
||||
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Amt") : vacantRow("chair", "Chair"), [...lines, ...groups]);
|
||||
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "vergibt jedes Amt mit Passkey-Signatur"));
|
||||
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Office") : vacantRow("chair", "Chair"), [...lines, ...groups]);
|
||||
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "assigns every office with a passkey signature"));
|
||||
return h("ul", {class: "org-tree root"}, orgNode("operator", HUMAN, root, [chairNode]));
|
||||
}
|
||||
|
||||
@@ -119,10 +119,10 @@
|
||||
hier.ids = 0;
|
||||
const count = state.claims.length;
|
||||
hier.chart.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchie", counter(count)),
|
||||
h("span", {class: "muted"}, "Formale Ämter aus dem Council: Operator → Chair → Dimensionen → Linien, dazu Organisatoren und Präsidenten"),
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchy", counter(count)),
|
||||
h("span", {class: "muted"}, "Formal offices from the council: Operator → Chair → dimensions → lines, plus organizers and presidents"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("Neues Amt", "Neues Amt: ein freies Amt einer Session geben, mit Passkey-Signatur"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "Neues Amt")),
|
||||
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("New office", "New office: give a free office to a session, with a passkey signature"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "New office")),
|
||||
chartTree());
|
||||
refocus(hier.chart, key);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
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);
|
||||
toast(`${label}: not possible`, fail(result), false);
|
||||
await load();
|
||||
return false;
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
function undoToast(batch, label) {
|
||||
const left = h("span", {class: "muted"});
|
||||
const button = h("button", {type: "button", class: "btn small", ...named("Rückgängig")}, icon("bi-arrow-counterclockwise"), "Rückgängig");
|
||||
const button = h("button", {type: "button", class: "btn small", ...named("Undo")}, icon("bi-arrow-counterclockwise"), "Undo");
|
||||
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 = () => {
|
||||
@@ -33,7 +33,7 @@
|
||||
load();
|
||||
return;
|
||||
}
|
||||
left.textContent = `wird in ${rest} s wirksam`;
|
||||
left.textContent = `takes effect in ${rest} s`;
|
||||
};
|
||||
timer = window.setInterval(count, 250);
|
||||
button.addEventListener("click", async () => {
|
||||
@@ -41,7 +41,7 @@
|
||||
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);
|
||||
toast(result.ok ? `Undone: ${label}` : "Too late: already in effect", result.ok ? [] : [`${result.status} ${result.text}`.trim()], result.ok);
|
||||
await load();
|
||||
});
|
||||
document.getElementById("toasts").append(box);
|
||||
@@ -57,18 +57,18 @@
|
||||
openItem(item);
|
||||
return null;
|
||||
}
|
||||
return submit([entryOf(item, "approve", item.recommended)], `Entscheidung: ${item.recommended}`);
|
||||
return submit([entryOf(item, "approve", item.recommended)], `Decision: ${item.recommended}`);
|
||||
}
|
||||
return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Ausführen" : "Freigabe"}: ${short(item.title)}`);
|
||||
return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Run" : "Grant"}: ${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}`);
|
||||
const rejectOne = (item) => submit([entryOf(item, "reject", "")], `Denial: ${short(item.title)}`);
|
||||
const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Decision: ${label}`);
|
||||
|
||||
function bulk(items, label) {
|
||||
const usable = items.filter(selectable);
|
||||
if (!usable.length) {
|
||||
toast(`${label}: nichts freizugeben`, [], false);
|
||||
toast(`${label}: nothing to grant`, [], false);
|
||||
return;
|
||||
}
|
||||
const rows = usable.map((item) => h("li", {},
|
||||
@@ -77,19 +77,19 @@
|
||||
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`);
|
||||
const confirm = h("button", {type: "button", class: "btn primary"}, `Grant ${usable.length}`);
|
||||
confirm.addEventListener("click", () => {
|
||||
closeSheet();
|
||||
submit(usable.map((item) => entryOf(item, "approve", item.kind === "decision" ? item.recommended : "")), `${label}: ${usable.length} Einträge`);
|
||||
submit(usable.map((item) => entryOf(item, "approve", item.kind === "decision" ? item.recommended : "")), `${label}: ${usable.length} entries`);
|
||||
});
|
||||
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);
|
||||
openSheet(label, [h("p", {class: "muted"}, "Every entry and every full command that gets granted. After the click the passkey signs the whole selection once; then 3 s remain to undo."), h("ul", {class: "mini"}, rows), h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "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);
|
||||
toast("Marking failed", [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -116,25 +116,25 @@
|
||||
|
||||
function actionsFor(item) {
|
||||
if (item.held) {
|
||||
return [h("span", {class: "muted"}, "wird wirksam …")];
|
||||
return [h("span", {class: "muted"}, "takes effect …")];
|
||||
}
|
||||
const buttons = [];
|
||||
if (item.kind === "review") {
|
||||
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
|
||||
buttons.push(iconBtn("bi-check-lg", "Grant", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Deny", "btn small danger", () => rejectOne(item)));
|
||||
} else if (item.kind === "ask") {
|
||||
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)));
|
||||
buttons.push(iconBtn("bi-check-lg", "Grant", "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "decision" && item.recommended) {
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Recommendation: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
|
||||
} else if (item.kind === "operator" && item.runnable) {
|
||||
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
|
||||
buttons.push(iconBtn("bi-play-fill", "Run", "btn small primary", () => approveOne(item)));
|
||||
} else if (item.kind === "rule") {
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named("Annehmen", "Annehmen: der Passkey signiert genau diese Regel, dann gilt sie"), on: {click: () => approveOne(item)}}, icon("bi-shield-check"), "Annehmen"));
|
||||
buttons.push(h("button", {type: "button", class: "btn small primary", ...named("Accept", "Accept: the passkey signs exactly this rule, then it applies"), on: {click: () => approveOne(item)}}, icon("bi-shield-check"), "Accept"));
|
||||
}
|
||||
if (item.deniable) {
|
||||
buttons.push(denySplit(item));
|
||||
}
|
||||
if (item.job && !jobOpen(item.job)) {
|
||||
buttons.push(iconBtn("bi-x-circle", "Schließen", "btn small ghost", () => dismissJob(item.job), "Schließen: Konsole ausblenden, eine erledigte Zeile verschwindet mit ihr"));
|
||||
buttons.push(iconBtn("bi-x-circle", "Close", "btn small ghost", () => dismissJob(item.job), "Close: hide the console; a done row disappears with it"));
|
||||
}
|
||||
return buttons;
|
||||
}
|
||||
@@ -142,19 +142,19 @@
|
||||
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)}});
|
||||
const box = h("input", {type: "checkbox", "aria-label": `Select ${item.title}`, 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.kind === "review" ? pill(item.gate === "pass" ? "Gate green" : "Gate red", 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,
|
||||
item.pinned ? pill("pinned", "violet") : null,
|
||||
item.until ? pill(`until ${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,
|
||||
h("button", {type: "button", class: "linkish", title: "Open", on: {click: () => openItem(item)}}, item.title),
|
||||
item.agent ? h("div", {class: "muted"}, "from ", 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 !== "-" ? ` · ${item.detail}` : "") : null,
|
||||
@@ -168,10 +168,10 @@
|
||||
actionsFor(item),
|
||||
item.kind === "operator" ? copyButton(item.command) : null,
|
||||
h("span", {class: "spacer"}),
|
||||
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: eine Stunde zurückstellen"),
|
||||
iconBtn("bi-sunrise", "morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "morgen: bis morgen 8 Uhr zurückstellen"),
|
||||
item.until ? iconBtn("bi-alarm", "wecken", "btn small ghost", () => mark(item, 0, item.pinned), "wecken: wieder zeigen") : null,
|
||||
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: snooze for one hour"),
|
||||
iconBtn("bi-sunrise", "tomorrow", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "tomorrow: snooze until 8 am tomorrow"),
|
||||
item.until ? iconBtn("bi-alarm", "wake", "btn small ghost", () => mark(item, 0, item.pinned), "wake: show again") : null,
|
||||
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "unpin" : "pin", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
}
|
||||
|
||||
function inboxScreen() {
|
||||
@@ -204,14 +204,14 @@
|
||||
ui.snoozed = event.target.checked;
|
||||
drawInbox();
|
||||
}}});
|
||||
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Auswahl freigeben (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, icon("bi-check2-square"), String(chosen.length));
|
||||
const all = h("button", {type: "button", class: "btn small primary", ...named(`Alle grünen freigeben (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, icon("bi-check2-all"), String(green.length));
|
||||
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Grant selection (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Grant selection")}}, icon("bi-check2-square"), String(chosen.length));
|
||||
const all = h("button", {type: "button", class: "btn small primary", ...named(`Grant all green (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Grant all green")}}, icon("bi-check2-all"), String(green.length));
|
||||
inboxDom.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),
|
||||
hidden ? h("label", {class: "check"}, snoozed, `${hidden} snoozed`) : null),
|
||||
h("div", {class: "inbox-actions"}, choose, all),
|
||||
onLocalhost() ? "" : h("p", {class: "muted"}, "Freigaben signiert der Passkey nur unter ", localLink()),
|
||||
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("Alles erledigt", "Gerade wartet nichts auf dich."));
|
||||
onLocalhost() ? "" : h("p", {class: "muted"}, "The passkey signs grants only under ", localLink()),
|
||||
items.length ? h("ol", {class: "inbox-list"}, items.map(inboxItem)) : emptyState("All done", "Nothing is waiting for you right now."));
|
||||
settleConsoles(items);
|
||||
const current = inboxDom.querySelector(`.item[data-index="${ui.cursor}"]`);
|
||||
if (current && document.activeElement === document.body) {
|
||||
@@ -232,7 +232,7 @@
|
||||
h("p", {class: "text"}, item.title),
|
||||
item.command ? h("div", {class: "command"}, h("code", {}, item.command), copyButton(item.command)) : null,
|
||||
item.kind === "rule" ? ruleRequestBody(item) : null,
|
||||
meta([["Verzeichnis", item.directory], ["Warnungen", item.warnings.join(", ")], ["Id", item.id]]),
|
||||
meta([["Directory", item.directory], ["Warnings", 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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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ßer Schrift für den zweiten Bildschirm, unter ⚙ Ambiente. j, k, x, a und Enter wirken in der Inbox.")], false);
|
||||
openSheet("Keyboard shortcuts", [h("dl", {class: "keys"}, KEYS.map(([key, text]) => [h("dt", {}, key), h("dd", {}, text)])),
|
||||
h("p", {class: "muted"}, "Control room: full screen with large type for a second screen, under ⚙ Ambience. j, k, x, a and Enter work in the inbox.")], false);
|
||||
}
|
||||
|
||||
function overlayOpen() {
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
return;
|
||||
}
|
||||
button.setAttribute("aria-pressed", String(settings.music.on));
|
||||
relabel(button, "bi-music-note-beamed", settings.music.on ? "Musik an" : "Musik", settings.music.on ? "Musik an: generative Musik ausschalten" : "Musik: generative Musik einschalten");
|
||||
relabel(button, "bi-music-note-beamed", settings.music.on ? "Music on" : "Music", settings.music.on ? "Music on: turn generative music off" : "Music: turn generative music on");
|
||||
}
|
||||
|
||||
function toggleMusic() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const OFFICE_RESULTS = {done: ["erledigt", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||
const OFFICE_RESULTS = {done: ["done", "ok"], refused: ["refused", "bad"], failed: ["failed", "bad"]};
|
||||
const OFFICE_VERBS = {
|
||||
appoint: ["Besetzen", "bi-person-plus", "council.py claim --operator auf dem Host: die gewählte Session erhält das freie Amt."],
|
||||
takeover: ["Übergeben", "bi-arrow-left-right", "council.py takeover --operator auf dem Host: das Amt geht an die gewählte Session, auch wenn der bisherige Inhaber noch lebt."],
|
||||
recall: ["Abberufen", "bi-person-x", "council.py release --operator auf dem Host: das Amt wird frei, auch wenn der Inhaber noch lebt."],
|
||||
appoint: ["Appoint", "bi-person-plus", "council.py claim --operator on the host: the chosen session gets the free office."],
|
||||
takeover: ["Hand over", "bi-arrow-left-right", "council.py takeover --operator on the host: the office goes to the chosen session, even if the current holder is still alive."],
|
||||
recall: ["Recall", "bi-person-x", "council.py release --operator on the host: the office becomes free, even if the holder is still alive."],
|
||||
};
|
||||
const OFFICE_HINT = "Der Host prüft deine Passkey-Signatur selbst und verweigert, wenn sich der Inhaber seit der Signatur geändert hat.";
|
||||
const OFFICE_HINT = "The host checks your passkey signature itself and refuses if the holder changed since the signature.";
|
||||
|
||||
function successors(office, holder) {
|
||||
const [dimension, line] = office.split("/");
|
||||
@@ -19,13 +19,13 @@
|
||||
const office = claim ? claim.office : "";
|
||||
const holder = claim && action !== "appoint" ? claim.session : "";
|
||||
const [verb, glyph, text] = OFFICE_VERBS[action];
|
||||
const name = h("input", {class: "field", "aria-label": "Amt", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/linie, cluster/slug oder work/slug"});
|
||||
const name = h("input", {class: "field", "aria-label": "Office", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/line, cluster/slug or work/slug"});
|
||||
const picks = successors(office, holder);
|
||||
const session = h("select", {class: "field", "aria-label": "Session"}, picks.map(([id, label]) => h("option", {value: id}, label)));
|
||||
const work = h("input", {class: "field", "aria-label": "Arbeit", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "woran die Session in diesem Amt arbeitet"});
|
||||
const work = h("input", {class: "field", "aria-label": "Work", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "what the session works on in this office"});
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const recall = action === "recall";
|
||||
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: mit deiner Passkey-Signatur an den Host übergeben`)}, icon(glyph), verb);
|
||||
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: hand over to the host with your passkey signature`)}, icon(glyph), verb);
|
||||
confirm.addEventListener("click", async () => {
|
||||
const item = {kind: "office", action, office: name.value.trim(), holder, session: recall ? "" : session.value, work: recall ? "" : work.value.trim()};
|
||||
confirm.disabled = true;
|
||||
@@ -33,15 +33,15 @@
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
openSheet(office ? `${office}: ${verb}` : "Neues Amt besetzen", [
|
||||
openSheet(office ? `${office}: ${verb}` : "Appoint a new office", [
|
||||
h("p", {class: "muted"}, text, " ", OFFICE_HINT),
|
||||
holder ? meta([["Inhaber", holderOf(claim) || holder, holder]]) : null,
|
||||
holder ? meta([["Holder", holderOf(claim) || holder, holder]]) : null,
|
||||
h("div", {class: "office-form"},
|
||||
h("label", {}, "Amt", name),
|
||||
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "Keine passende Session bekannt.")),
|
||||
recall ? null : h("label", {}, "Arbeit", work)),
|
||||
h("label", {}, "Office", name),
|
||||
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "No matching session known.")),
|
||||
recall ? null : h("label", {}, "Work", work)),
|
||||
outcome,
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
|
||||
], false);
|
||||
(office ? (recall ? confirm : session) : name).focus();
|
||||
}
|
||||
@@ -49,19 +49,19 @@
|
||||
async function signOffice(items, outcome, verb) {
|
||||
const signed = await proofFor(items, verb);
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Not signed: nothing was handed over."));
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/offices", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${verb}: nicht möglich`, fail(result), false);
|
||||
toast(`${verb}: not possible`, fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted"},`Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit ruft jetzt council.py auf.`));
|
||||
outcome.replaceChildren(h("p", {class: "muted"},`Signed and handed to the host (job ${result.data.id}); the path unit now calls council.py.`));
|
||||
followJob(`/api/offices/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||
const {good, lines} = showJob(data, outcome, "office", OFFICE_RESULTS);
|
||||
toast(`${verb}: ${good ? "erledigt" : "nicht ausgeführt"}`, lines, good);
|
||||
toast(`${verb}: ${good ? "done" : "not carried out"}`, lines, good);
|
||||
load();
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
function paletteItems() {
|
||||
return [
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `View: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `View: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
||||
{label: "Priorität …", hint: "Reihenfolge der Agenten in der Hierarchie", run: openAgentOrder},
|
||||
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
|
||||
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
||||
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
||||
{label: space.look ? "Himmel verlassen" : "Himmel ansehen", hint: "h", run: () => skyLook(!space.look)},
|
||||
{label: "Leitstand umschalten", hint: "Ambiente", run: toggleLeitstand},
|
||||
{label: "Benachrichtigungen aktivieren", hint: "Ambiente", run: askNotify},
|
||||
{label: "Tastenkürzel", hint: "?", run: helpSheet},
|
||||
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
|
||||
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
||||
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
|
||||
{label: "Settings …", hint: "Ambience, location, colors, music, tones, read radio aloud, internet radio", run: settingsSheet},
|
||||
{label: settings.music.on ? "Music off" : "Music on", hint: "Ambience", run: toggleMusic},
|
||||
{label: space.look ? "Leave the sky" : "Look at the sky", hint: "h", run: () => skyLook(!space.look)},
|
||||
{label: "Toggle control room", hint: "Ambience", run: toggleLeitstand},
|
||||
{label: "Enable notifications", hint: "Ambience", run: askNotify},
|
||||
{label: "Keyboard shortcuts", hint: "?", run: helpSheet},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
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_ORCHESTRATOR_LLM_URL und KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env setzen", keep: true, run: () => toast("KI-Befehl ist aus", ["Setze KVB_ORCHESTRATOR_LLM_URL, KVB_ORCHESTRATOR_LLM_MODEL und KVB_ORCHESTRATOR_LLM_KEY_FILE in orchestrator/.env und installiere neu."], false)});
|
||||
? {label: `AI command: “${raw}”`, hint: "Tab", keep: true, run: () => askModel(raw)}
|
||||
: {label: "AI command is off", hint: "set KVB_ORCHESTRATOR_LLM_URL and KVB_ORCHESTRATOR_LLM_MODEL in orchestrator/.env", keep: true, run: () => toast("AI command is off", ["Set KVB_ORCHESTRATOR_LLM_URL, KVB_ORCHESTRATOR_LLM_MODEL and KVB_ORCHESTRATOR_LLM_KEY_FILE in orchestrator/.env and reinstall."], false)});
|
||||
}
|
||||
palette.items = items;
|
||||
palette.index = Math.min(palette.index, Math.max(items.length - 1, 0));
|
||||
@@ -60,23 +60,23 @@
|
||||
|
||||
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)")));
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {class: "muted"}, "Asking the model … (only the command, ids and titles leave the hub)")));
|
||||
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())));
|
||||
palette.list.replaceChildren(h("li", {}, h("span", {}, `AI command failed: ${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}`));
|
||||
const rejected = result.data.rejected.map((entry) => h("p", {class: "muted"}, `discarded: ${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."),
|
||||
actions.length ? actions : h("p", {class: "muted"}, "The model proposed no valid action."),
|
||||
rejected,
|
||||
h("p", {class: "muted"}, actions.length ? "Enter führt genau diese Aktionen aus · Esc bricht ab" : "Esc schließt")));
|
||||
h("p", {class: "muted"}, actions.length ? "Enter runs exactly these actions · Esc cancels" : "Esc closes")));
|
||||
}
|
||||
|
||||
async function executePlan(plan) {
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
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.input = h("input", {type: "text", placeholder: "View, agent, action, or a command in words …", "aria-label": "Command", role: "combobox", "aria-expanded": "true"});
|
||||
palette.list = h("ul", {class: "palette-list", role: "listbox"});
|
||||
palette.index = 0;
|
||||
palette.plan = null;
|
||||
@@ -142,7 +142,7 @@
|
||||
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.replaceChildren(h("div", {class: "palette-box", role: "dialog", "aria-label": "Commands"}, h("div", {class: "palette-row"}, palette.input, micButton(palette.input)), palette.list));
|
||||
box.hidden = false;
|
||||
drawPalette();
|
||||
palette.input.focus();
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
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());
|
||||
const KEEPASSXC_LOCALHOST = "KeePassXC lehnt Passkeys auf http://localhost ab, bis in KeePassXC unter Werkzeuge → Einstellungen → Browser-Integration → Fortgeschritten „Nutzung von localhost mit Passkeys zulassen“ (Allow using localhost with passkeys) angehakt ist; die Datenbank muss entsperrt sein. Sonst meldet KeePassXC-Browser „Passkeys request canceled.“ und Chrome öffnet seinen eigenen Passkey-Dialog.";
|
||||
const KEEPASSXC_LOCALHOST = "KeePassXC refuses passkeys on http://localhost until “Allow using localhost with passkeys” is checked in KeePassXC under Tools → Settings → Browser Integration → Advanced; the database must be unlocked. Otherwise KeePassXC-Browser reports “Passkeys request canceled.” and Chrome opens its own passkey dialog.";
|
||||
|
||||
async function assertion(prepared, label) {
|
||||
if (!onLocalhost()) {
|
||||
toast(`${label}: nicht signiert`, [`Der Passkey signiert nur unter ${localUrl()}`], false);
|
||||
toast(`${label}: not signed`, [`The passkey signs only under ${localUrl()}`], false);
|
||||
return null;
|
||||
}
|
||||
if (!prepared.credentials.length) {
|
||||
toast(`${label}: nicht signiert`, ["Kein Passkey gepinnt: Einstellungen → Passkey registrieren"], false);
|
||||
toast(`${label}: not signed`, ["No passkey pinned: Settings → Register passkey"], false);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
@@ -27,7 +27,7 @@
|
||||
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);
|
||||
toast(`${label}: not signed`, [`${(error && error.name) || error}: KeePassXC locked or canceled; nothing was sent.`], false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
async function signedProof(path, fields, label) {
|
||||
const result = await sendJson(path, fields);
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
toast(`${label}: not possible`, fail(result), false);
|
||||
return null;
|
||||
}
|
||||
if (result.data.payload === null) {
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
async function registerPasskey(output) {
|
||||
if (!onLocalhost()) {
|
||||
output.replaceChildren(h("p", {class: "muted"}, "nicht registriert: WebAuthn braucht die Domain ", localLink()));
|
||||
output.replaceChildren(h("p", {class: "muted"}, "not registered: WebAuthn needs the domain ", localLink()));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -68,11 +68,11 @@
|
||||
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-orchestrator/client/approval.py pin --port ${window.location.port} --approvers ~/.config/kvb-orchestrator/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("p", {class: "muted"}, "Run this command in the host's terminal, outside any sandbox; only then does the passkey apply. The hub never pins by itself."),
|
||||
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}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
|
||||
output.replaceChildren(h("p", {class: "muted"}, `not registered: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
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.");
|
||||
: h("p", {class: "muted"}, "No passkey pinned: runner, hook and commit skills refuse every grant.");
|
||||
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."),
|
||||
h("p", {class: "muted"}, "A passkey in KeePassXC signs grants of asks, reviews and operator commands; runner, hook and commit skills check the signature themselves against the pinned keys."),
|
||||
keys,
|
||||
h("p", {class: "muted"}, KEEPASSXC_LOCALHOST),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()),
|
||||
h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn needs the domain: ", localLink()),
|
||||
h("div", {}, btn("Register passkey", "btn small soft", () => registerPasskey(output))),
|
||||
output);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
const PLAY_REFUSED = {
|
||||
NotAllowedError: ["blocked", "Klick nötig", "Der Browser blockiert automatisches Abspielen: ein Klick auf ▶ oder irgendwo auf die Seite startet den Sender."],
|
||||
NotSupportedError: ["error", "nicht abspielbar", "Nicht abspielbar: der Browser versteht Format oder Adresse nicht, oder der Sender leitet auf eine Adresse um, von der die Seite keinen Ton laden darf."],
|
||||
NotAllowedError: ["blocked", "Click needed", "The browser blocks autoplay: a click on ▶ or anywhere on the page starts the station."],
|
||||
NotSupportedError: ["error", "not playable", "Not playable: the browser does not understand the format or the address, or the station redirects to an address the page may not load sound from."],
|
||||
};
|
||||
const MEDIA_ERRORS = {1: "Laden abgebrochen", 2: "Netzwerkfehler", 3: "Ton nicht dekodierbar", 4: "Format oder Adresse nicht abspielbar"};
|
||||
const RESOLVE_STEP = "Radio → Sender bearbeiten: ändere die Adresse des Senders, oder entferne ihn, speichere, lege ihn neu an und speichere wieder; dann löst der Server die Weiterleitung neu auf und gibt ihr Ziel frei.";
|
||||
const MEDIA_ERRORS = {1: "Loading aborted", 2: "Network error", 3: "Sound not decodable", 4: "Format or address not playable"};
|
||||
const RESOLVE_STEP = "Radio → Edit stations: change the station's address, or remove it, save, add it again and save once more; then the server resolves the redirect again and releases its target.";
|
||||
|
||||
function radioVolume() {
|
||||
if (radio.element) {
|
||||
@@ -47,22 +47,22 @@
|
||||
told.box.remove();
|
||||
}
|
||||
const stream = settings.streams[radio.index];
|
||||
const box = toast(`Radio ${kind === "blocked" ? "blockiert" : "stumm"}: ${stream ? stream.name : radio.url}`, [note], false);
|
||||
const box = toast(`Radio ${kind === "blocked" ? "blocked" : "silent"}: ${stream ? stream.name : radio.url}`, [note], false);
|
||||
radio.told = {...fresh, status: [kind, label, note], box};
|
||||
}
|
||||
|
||||
function redirectNote(stream) {
|
||||
if (stream && stream.via === null) {
|
||||
return "Der Server hat die Weiterleitung dieses Senders noch nicht aufgelöst; ein Speichern nach zehn Minuten versucht es erneut.";
|
||||
return "The server has not resolved this station's redirect yet; saving after ten minutes tries again.";
|
||||
}
|
||||
const released = stream ? stream.via : [];
|
||||
const unloaded = released.filter((origin) => !pageMedia.has(origin));
|
||||
if (unloaded.length) {
|
||||
return `Der Sender leitet auf ${unloaded.join(", ")} um; die geladene Seite darf von dort noch keinen Ton laden: Seite neu laden.`;
|
||||
return `The station redirects to ${unloaded.join(", ")}; the loaded page may not load sound from there yet: reload the page.`;
|
||||
}
|
||||
return released.length
|
||||
? `Der Sender leitet auf eine Adresse außerhalb der freigegebenen ${released.join(", ")} um. ${RESOLVE_STEP}`
|
||||
: `Der Sender leitet auf eine Adresse um, die noch nicht freigegeben ist; der Browser nennt das Ziel einer Weiterleitung nicht. ${RESOLVE_STEP}`;
|
||||
? `The station redirects to an address outside the released ${released.join(", ")}. ${RESOLVE_STEP}`
|
||||
: `The station redirects to an address that is not released yet; the browser does not name the target of a redirect. ${RESOLVE_STEP}`;
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
@@ -100,7 +100,7 @@
|
||||
const missing = stream ? Ambience.stationOrigins(stream).filter((origin) => !pageMedia.has(origin)) : [];
|
||||
radio.index = index;
|
||||
if (missing.length) {
|
||||
radioStatus("reload", "lädt neu", `Neue Adresse ${missing.join(", ")}: die Seite lädt neu, damit der Browser von dort Ton laden darf.`);
|
||||
radioStatus("reload", "reloading", `New address ${missing.join(", ")}: the page reloads so the browser may load sound from there.`);
|
||||
if (missing.every((origin) => state.media.includes(origin))) {
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
radio.url = url;
|
||||
radio.waiting = false;
|
||||
radioStatus(url ? "loading" : "", url ? "verbindet" : "", url ? "Verbindet mit dem Sender …" : "");
|
||||
radioStatus(url ? "loading" : "", url ? "connecting" : "", url ? "Connecting to the station …" : "");
|
||||
fadeRadio(0, () => {
|
||||
if (radio.url !== url) {
|
||||
return;
|
||||
@@ -121,7 +121,7 @@
|
||||
radio.element.play().then(() => {
|
||||
radio.playing = true;
|
||||
radio.told = null;
|
||||
radioStatus("playing", "spielt", `spielt seit ${clock.format(new Date())}`);
|
||||
radioStatus("playing", "playing", `playing since ${clock.format(new Date())}`);
|
||||
tuneMusic(FADE / 1000);
|
||||
fadeRadio(1, () => null);
|
||||
}).catch((error) => {
|
||||
@@ -129,7 +129,7 @@
|
||||
return;
|
||||
}
|
||||
radio.waiting = true;
|
||||
const [kind, label, note] = PLAY_REFUSED[error.name] || ["error", "Fehler", `${error.name}: ${error.message}`];
|
||||
const [kind, label, note] = PLAY_REFUSED[error.name] || ["error", "Error", `${error.name}: ${error.message}`];
|
||||
radioTrouble(kind, label, note, "refused");
|
||||
});
|
||||
});
|
||||
@@ -159,13 +159,13 @@
|
||||
radio.playing = false;
|
||||
radio.waiting = true;
|
||||
tuneMusic(FADE / 1000);
|
||||
const reason = failure ? MEDIA_ERRORS[failure.code] || `Fehler ${failure.code}` : "unbekannter Fehler";
|
||||
radioTrouble("error", "Fehler", `Der Sender spielt nicht: ${reason}${failure && failure.message ? ` (${failure.message})` : ""}.`, "media");
|
||||
const reason = failure ? MEDIA_ERRORS[failure.code] || `Error ${failure.code}` : "unknown error";
|
||||
radioTrouble("error", "Error", `The station does not play: ${reason}${failure && failure.message ? ` (${failure.message})` : ""}.`, "media");
|
||||
});
|
||||
document.addEventListener("securitypolicyviolation", (event) => {
|
||||
// Trip-wire: for a redirected load, blockedURI is the station's own URL, never the redirect target.
|
||||
if (event.effectiveDirective === "media-src" && radio.url && !event.blockedURI.startsWith(window.location.origin)) {
|
||||
radioTrouble("error", "gesperrt", redirectNote(settings.streams[radio.index]), "csp");
|
||||
radioTrouble("error", "blocked", redirectNote(settings.streams[radio.index]), "csp");
|
||||
}
|
||||
});
|
||||
return element;
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
let radioDom = null;
|
||||
|
||||
function voiceReplay(note) {
|
||||
return typeof note === "string" && NOTE_ID.test(note) ? h("audio", {class: "voice-replay", controls: true, preload: "none", src: `/api/voice/${note}.wav`, "aria-label": "Sprachnachricht abspielen"}) : null;
|
||||
return typeof note === "string" && NOTE_ID.test(note) ? h("audio", {class: "voice-replay", controls: true, preload: "none", src: `/api/voice/${note}.wav`, "aria-label": "Play voice message"}) : null;
|
||||
}
|
||||
|
||||
function daysText(days) {
|
||||
return {"0,1,2,3,4,5,6": "täglich", "0,1,2,3,4": "werktags", "5,6": "am Wochenende"}[days.join(",")] || days.map((day) => WEEKDAYS[day]).join(" ");
|
||||
return {"0,1,2,3,4,5,6": "daily", "0,1,2,3,4": "weekdays", "5,6": "on weekends"}[days.join(",")] || days.map((day) => WEEKDAYS[day]).join(" ");
|
||||
}
|
||||
|
||||
function scheduleOf(stream) {
|
||||
return `${daysText(stream.days)}, ${stream.from === "00:00" && stream.to === "24:00" ? "rund um die Uhr" : `${stream.from} bis ${stream.to}`}`;
|
||||
return `${daysText(stream.days)}, ${stream.from === "00:00" && stream.to === "24:00" ? "around the clock" : `${stream.from} to ${stream.to}`}`;
|
||||
}
|
||||
|
||||
function stationState(stream, index) {
|
||||
@@ -19,20 +19,20 @@
|
||||
return [radio.state, radio.short, radio.note];
|
||||
}
|
||||
if (!stream.on) {
|
||||
return ["off", "aus", "In den Einstellungen ausgeschaltet"];
|
||||
return ["off", "off", "Switched off in the settings"];
|
||||
}
|
||||
if (!Ambience.activeStream([stream], Ambience.slotOf(new Date()))) {
|
||||
return ["idle", "nicht dran", `Spielt ${scheduleOf(stream)}; ▶ spielt ihn jetzt`];
|
||||
return ["idle", "not due", `Plays ${scheduleOf(stream)}; ▶ plays it now`];
|
||||
}
|
||||
return radio.pick === -1 ? ["stopped", "gestoppt", "Von Hand gestoppt; ▶ spielt ihn wieder"] : ["due", "dran", "Dran, sobald kein früherer Sender spielt"];
|
||||
return radio.pick === -1 ? ["stopped", "stopped", "Stopped by hand; ▶ plays it again"] : ["due", "due", "Due as soon as no earlier station plays"];
|
||||
}
|
||||
|
||||
function stationRow(stream, index) {
|
||||
const [kind, label, note] = stationState(stream, index);
|
||||
const live = index === radio.index && (kind === "playing" || kind === "loading");
|
||||
const action = live
|
||||
? iconBtn("bi-stop-fill", "Stopp", "btn small ghost", stopStation, `Stopp: ${stream.name} anhalten`)
|
||||
: iconBtn("bi-play-fill", "Abspielen", "btn small ghost", () => playStation(index), `Abspielen: ${stream.name} jetzt hören`);
|
||||
? iconBtn("bi-stop-fill", "Stop", "btn small ghost", stopStation, `Stop: pause ${stream.name}`)
|
||||
: iconBtn("bi-play-fill", "Play", "btn small ghost", () => playStation(index), `Play: listen to ${stream.name} now`);
|
||||
action.dataset.action = live ? "stop" : "play";
|
||||
return h("li", {class: "station", "data-station": index, "data-state": kind},
|
||||
h("span", {class: "station-name", title: stream.name}, stream.name),
|
||||
@@ -52,7 +52,7 @@
|
||||
radioDom.stationsSig = sig;
|
||||
radioDom.stations.replaceChildren(...(settings.streams.length
|
||||
? settings.streams.map(stationRow)
|
||||
: [h("li", {class: "muted"}, "Noch kein Sender: unter ⚙ Einstellungen → Radio fügst du einen hinzu.")]));
|
||||
: [h("li", {class: "muted"}, "No station yet: add one under ⚙ Settings → Internet radio.")]));
|
||||
}
|
||||
|
||||
function speakerChips() {
|
||||
@@ -69,9 +69,9 @@
|
||||
radioDom.caption.hidden = !now;
|
||||
radioDom.caption.replaceChildren(...(now ? [
|
||||
h("span", {class: "speaker-face"}, face(nameOf(now.who), false)),
|
||||
h("strong", {}, `${nameOf(now.who)} spricht`),
|
||||
h("strong", {}, `${nameOf(now.who)} speaking`),
|
||||
h("span", {class: "caption-line", title: now.line}, now.line),
|
||||
iconBtn("bi-stop-fill", "Vorlesen abbrechen", "btn small ghost", cancelSpeech),
|
||||
iconBtn("bi-stop-fill", "Stop speaking", "btn small ghost", cancelSpeech),
|
||||
] : []));
|
||||
}
|
||||
if (ui.screen === "agents" && (ui.views.agents === "orbit" || ui.views.agents === "chord")) {
|
||||
@@ -83,9 +83,9 @@
|
||||
const rows = state.messages.slice(-5).reverse();
|
||||
return rows.length ? h("ul", {class: "mini"}, rows.map((message) => h("li", {},
|
||||
h("div", {class: "row"}, klass(message.class || "post"), when(message.created_ts)),
|
||||
h("span", {class: "muted"}, `an ${message.to === "*" ? "alle" : message.to}`),
|
||||
h("span", {class: "muted"}, `to ${message.to === "*" ? "all" : message.to}`),
|
||||
h("span", {}, short(message.text)),
|
||||
voiceReplay(message.voice)))) : h("p", {class: "muted"}, "Im gewählten Zeitraum noch nichts gesendet.");
|
||||
voiceReplay(message.voice)))) : h("p", {class: "muted"}, "Nothing sent in the chosen time range yet.");
|
||||
}
|
||||
|
||||
function drawRadio() {
|
||||
@@ -113,19 +113,19 @@
|
||||
}
|
||||
|
||||
function radioScreen() {
|
||||
const speakers = h("ul", {class: "speakers", "aria-label": "Stationen"});
|
||||
const speakers = h("ul", {class: "speakers", "aria-label": "Speakers"});
|
||||
const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true});
|
||||
const stations = h("ul", {class: "stations", "aria-label": "Sender"});
|
||||
const stations = h("ul", {class: "stations", "aria-label": "Stations"});
|
||||
const outbox = h("div", {});
|
||||
const {form, recipient} = composerForm();
|
||||
radioDom = {speakers, caption, stations, outbox, recipient, crew: "", names: "", sent: "", stationsSig: ""};
|
||||
return h("div", {class: "stack radio-view"},
|
||||
h("h2", {class: "title"}, "Radio"),
|
||||
h("div", {class: "radio-grid"},
|
||||
h("section", {class: "card radio-talk", "aria-label": "Funk"}, h("h3", {class: "group-title"}, "Funk"), caption, speakers, form),
|
||||
h("section", {class: "card radio-talk", "aria-label": "Radio"}, h("h3", {class: "group-title"}, "Radio"), caption, speakers, form),
|
||||
h("div", {class: "stack"},
|
||||
h("section", {class: "card", "aria-label": "Internetradio"},
|
||||
h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internetradio"), btn("Sender bearbeiten", "btn small ghost", settingsSheet)),
|
||||
h("section", {class: "card", "aria-label": "Internet radio"},
|
||||
h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internet radio"), btn("Edit stations", "btn small ghost", settingsSheet)),
|
||||
stations),
|
||||
h("section", {class: "card", "aria-label": "Zuletzt gesendet"}, h("h3", {class: "group-title"}, "Zuletzt gesendet"), outbox))));
|
||||
h("section", {class: "card", "aria-label": "Recently sent"}, h("h3", {class: "group-title"}, "Recently sent"), outbox))));
|
||||
}
|
||||
@@ -33,20 +33,20 @@
|
||||
const review = reviewOf(ui.review);
|
||||
if (!review) {
|
||||
closeSheet();
|
||||
toast("Review nicht mehr im Zeitraum", [], false);
|
||||
toast("Review no longer in the time range", [], 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 treeNav = h("nav", {class: "card tree", "aria-label": "Changed files"}, 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)),
|
||||
h("div", {class: "card-head"}, h("div", {}, h("p", {class: "muted"}, "submitted ", when(review.created_ts))), h("div", {class: "who"}, status(review.status || "pending"), review.gate ? pill(review.gate === "pass" ? "Gate green" : "Gate red", 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 icon-only", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer", ...named("Vollständige Seite öffnen", "Vollständige Seite öffnen (neuer Tab)")}, icon("bi-box-arrow-up-right")),
|
||||
h("a", {class: "btn ghost icon-only", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer", ...named("Open full page", "Open full page (new tab)")}, icon("bi-box-arrow-up-right")),
|
||||
h("span", {class: "spacer"}),
|
||||
item ? actionsFor(item).map((node) => {
|
||||
node.addEventListener("click", closeSheet);
|
||||
@@ -113,14 +113,14 @@
|
||||
|
||||
function treePane(list) {
|
||||
if (list === "loading") {
|
||||
return h("p", {class: "muted pad"}, "Lade Dateiliste …");
|
||||
return h("p", {class: "muted pad"}, "Loading file list …");
|
||||
}
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return h("p", {class: "muted pad"}, "Keine Dateiliste. Die vollständige Seite zeigt alle Änderungen.");
|
||||
return h("p", {class: "muted pad"}, "No file list. The full page shows all changes.");
|
||||
}
|
||||
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,
|
||||
sections.length > 1 ? h("h3", {class: "tree-title"}, SECTIONS[section] || section || "Files") : null,
|
||||
branch(nest(list.map((file, index) => [file, index]).filter(([file]) => file.section === section)), list))));
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
function diffPane(list) {
|
||||
if (!Array.isArray(list) || !list.length) {
|
||||
return h("p", {class: "muted pad"}, list === "loading" ? "Lade Änderungen …" : "Kein Diff verfügbar.");
|
||||
return h("p", {class: "muted pad"}, list === "loading" ? "Loading changes …" : "No diff available.");
|
||||
}
|
||||
const file = list[Math.min(ui.file, list.length - 1)];
|
||||
const [letter, tone] = FILE_STATUS[file.status] || ["?", "neutral"];
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
const COUNTED = {ask: "Asks", review: "Reviews", decision: "Entscheidungen"};
|
||||
const COUNTED = {ask: "asks", review: "reviews", decision: "decisions"};
|
||||
|
||||
function ruleRequestBody(item) {
|
||||
const rule = item.rule;
|
||||
const dry = item.dry_run;
|
||||
const pairs = [["Art", RULE_KINDS[rule.kind]], rule.kind === "ask" ? ["Werkzeug (Glob)", rule.tool] : null, ["Muster (Glob)", rule.pattern], rule.kind === "decision" ? null : ["Verzeichnis (Glob)", rule.scope], ["Läuft ab am", absolute.format(stampOf(rule.expires))]];
|
||||
const pairs = [["Kind", RULE_KINDS[rule.kind]], rule.kind === "ask" ? ["Tool (glob)", rule.tool] : null, ["Pattern (glob)", rule.pattern], rule.kind === "decision" ? null : ["Directory (glob)", rule.scope], ["Expires on", absolute.format(stampOf(rule.expires))]];
|
||||
return h("div", {class: "rule-request"},
|
||||
item.detail ? h("p", {class: "text"}, item.detail) : null,
|
||||
meta(pairs.filter(Boolean)),
|
||||
h("p", {class: "muted dry-run-count"}, "Trockenlauf: Die Regel hätte ", h("strong", {}, `${dry.matched} von ${dry.total}`), ` ${COUNTED[rule.kind]} der letzten ${dry.days} Tage freigegeben.`),
|
||||
h("p", {class: "muted dry-run-count"}, "Dry run: the rule would have granted ", h("strong", {}, `${dry.matched} of ${dry.total}`), ` ${COUNTED[rule.kind]} from the last ${dry.days} days.`),
|
||||
h("p", {class: "muted"}, RULE_HINTS[rule.kind]));
|
||||
}
|
||||
|
||||
async function acceptRule(item) {
|
||||
const label = `Regel: ${short(item.title)}`;
|
||||
const label = `Rule: ${short(item.title)}`;
|
||||
ui.busy = true;
|
||||
const prepared = await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/sign`, {});
|
||||
const signed = prepared.ok && prepared.data ? await assertion(prepared.data, label) : null;
|
||||
if (!prepared.ok || !prepared.data) {
|
||||
toast(`${label}: nicht möglich`, fail(prepared), false);
|
||||
toast(`${label}: not possible`, fail(prepared), false);
|
||||
}
|
||||
const result = signed ? await sendJson(`/api/rule-requests/${encodeURIComponent(item.id)}/accept`, {rule: prepared.data.payload, assertion: signed}) : null;
|
||||
ui.busy = false;
|
||||
if (result) {
|
||||
toast(result.ok ? `Angelegt: ${short(item.title)}` : `${label}: nicht angelegt`, result.ok ? [] : fail(result), result.ok);
|
||||
toast(result.ok ? `Created: ${short(item.title)}` : `${label}: not created`, result.ok ? [] : fail(result), result.ok);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
const RULE_KINDS = {ask: "Ask: Werkzeug + Befehlsmuster", review: "Review: Branch-Muster + Gate grün", decision: "Entscheidung: Empfehlung nehmen"};
|
||||
const RULE_KINDS = {ask: "Ask: tool + command pattern", review: "Review: branch pattern + gate green", decision: "Decision: take the recommendation"};
|
||||
const RULE_HINTS = {
|
||||
ask: "Gibt passende Asks in passenden Verzeichnissen frei, nie einen Befehl mit Warnung (rm -r, push, --force, sudo, Pipe in eine Shell …). Der Passkey signiert die Regel einmal; der Hook prüft Signatur und Muster selbst.",
|
||||
review: "Gibt Reviews passender Branches und Worktrees frei, nur wenn ihr Gate grün ist. Der Passkey signiert die Regel einmal; der Commit-Skill prüft Signatur und Muster selbst.",
|
||||
decision: "Nimmt bei Entscheidungen passender Agenten die empfohlene Option. Der Passkey signiert die Regel einmal; die Entscheidungen selbst bleiben unsigniert.",
|
||||
ask: "Grants matching asks in matching directories, never a command with a warning (rm -r, push, --force, sudo, pipes into a shell …). The passkey signs the rule once; the hook checks signature and pattern itself.",
|
||||
review: "Grants reviews of matching branches and worktrees, only when their gate is green. The passkey signs the rule once; the commit skill checks signature and pattern itself.",
|
||||
decision: "Takes the recommended option in decisions of matching agents. The passkey signs the rule once; the decisions themselves stay unsigned.",
|
||||
};
|
||||
|
||||
function rulesScreen() {
|
||||
const kind = h("select", {class: "field", "aria-label": "Art"}, Object.entries(RULE_KINDS).map(([value, label]) => h("option", {value}, label)));
|
||||
const kind = h("select", {class: "field", "aria-label": "Kind"}, Object.entries(RULE_KINDS).map(([value, label]) => h("option", {value}, label)));
|
||||
const tool = h("input", {class: "field", type: "text", placeholder: "Bash", value: "Bash"});
|
||||
const pattern = h("input", {class: "field", type: "text", placeholder: "make test*"});
|
||||
const scope = h("input", {class: "field", type: "text", value: "*", placeholder: "/home/…/Repositories/*"});
|
||||
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 toolField = h("label", {}, "Tool (glob)", tool);
|
||||
const scopeField = h("label", {}, "Directory (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];
|
||||
pattern.placeholder = {ask: "make test*", review: "feature/*", decision: "* (agent pattern)"}[kind.value];
|
||||
hint.textContent = RULE_HINTS[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"));
|
||||
const form = h("form", {class: "rule-form"}, h("label", {}, "Kind", kind), toolField, h("label", {}, "Pattern (glob)", pattern), scopeField, h("label", {}, "Expires on", expires), h("label", {class: "check"}, enabled, "active"), h("button", {type: "submit", class: "btn primary"}, "Create rule"));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const end = Date.parse(`${expires.value}T23:59:59`) / 1000;
|
||||
@@ -37,21 +37,21 @@
|
||||
};
|
||||
const prepared = await sendJson("/api/rules/sign", fields);
|
||||
if (!prepared.ok || !prepared.data) {
|
||||
toast("Regel nicht angelegt", fail(prepared), false);
|
||||
toast("Rule not created", fail(prepared), false);
|
||||
return;
|
||||
}
|
||||
const signed = prepared.data.sign ? await assertion(prepared.data, "Regel") : null;
|
||||
const signed = prepared.data.sign ? await assertion(prepared.data, "Rule") : 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);
|
||||
toast(result.ok ? "Rule created" : "Rule not created", result.ok ? [] : fail(result), result.ok);
|
||||
await load();
|
||||
});
|
||||
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);
|
||||
toast("Rule not changed", [`${result.status} ${result.text}`.trim()], false);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
@@ -60,16 +60,16 @@
|
||||
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 || "–"),
|
||||
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"),
|
||||
rule.kind === "decision" ? null : h("code", {title: "Directory"}, rule.scope || "–"),
|
||||
pill(rule.signed ? "signed" : "unsigned: has no effect", rule.signed ? "ok" : "bad"),
|
||||
rule.expires <= now ? pill("expired", "bad") : h("span", {class: "muted"}, `until ${absolute.format(stampOf(rule.expires))}`),
|
||||
rule.enabled ? pill("active", "ok") : pill("off", "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?")) {
|
||||
btn(rule.enabled ? "Turn off" : "Turn on", "btn small", () => change(rule, rule.enabled ? "disable" : "enable")),
|
||||
btn("Delete", "btn small danger", () => {
|
||||
if (window.confirm("Delete rule?")) {
|
||||
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.");
|
||||
return h("div", {class: "stack"}, h("h2", {class: "title"}, "Regeln für automatische Freigaben"), list, h("div", {class: "card"}, hint, form));
|
||||
})))) : h("p", {class: "muted"}, "No rule yet. Every automatic grant shows up in the stream and can be undone for 3 s.");
|
||||
return h("div", {class: "stack"}, h("h2", {class: "title"}, "Firewall rules for automatic grants"), list, h("div", {class: "card"}, hint, form));
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
document.getElementById("tabs").append(...Nav.TABS.map(([id, label], index) => h("div", {class: "tab-group"},
|
||||
tabButton(id, label, `Taste ${index + 1}`),
|
||||
tabButton(id, label, `key ${index + 1}`),
|
||||
Nav.SUBS[id] ? h("div", {class: "subtabs"}, Nav.SUBS[id].map(([sub, name]) => tabButton(sub, name, ""))) : null)));
|
||||
|
||||
function measure() {
|
||||
@@ -99,7 +99,7 @@
|
||||
const banner = document.getElementById("mayday");
|
||||
const silence = state.silence;
|
||||
banner.hidden = !silence;
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "aufheben", "btn small", () => confirmAct("clear-mayday"), "aufheben: Mayday beenden (radio.py clear)")] : []));
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday"), "cancel: end the mayday (radio.py clear)")] : []));
|
||||
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
||||
window.setTimeout(measure, 0);
|
||||
}
|
||||
|
||||
@@ -16,25 +16,25 @@
|
||||
}
|
||||
|
||||
async function saveSettings(next) {
|
||||
const signed = await signedProof("/api/settings/sign", next, "Einstellungen");
|
||||
const signed = await signedProof("/api/settings/sign", next, "Settings");
|
||||
if (!signed) {
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/settings", {...next, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast("Einstellungen nicht gespeichert", [`${result.status} ${result.text}`.trim()], false);
|
||||
toast("Settings not saved", [`${result.status} ${result.text}`.trim()], false);
|
||||
return false;
|
||||
}
|
||||
const {settings: saved, problems} = result.data;
|
||||
const unresolved = problems.join("\n");
|
||||
if (unresolved && unresolved !== redirectsTold) {
|
||||
toast("Weiterleitung nicht aufgelöst", [...problems, "Diese Sender laden vorerst nur von ihrer eigenen Adresse; ein Speichern nach zehn Minuten versucht es erneut."], false);
|
||||
toast("Redirect not resolved", [...problems, "These stations load only from their own address for now; saving after ten minutes tries again."], false);
|
||||
}
|
||||
redirectsTold = unresolved;
|
||||
const fresh = [...new Set(saved.streams.flatMap((stream) => Ambience.stationOrigins(stream)))].filter((origin) => !pageMedia.has(origin));
|
||||
adoptSettings(saved);
|
||||
if (fresh.length) {
|
||||
toast("Neuer Sender", ["Die Seite lädt neu, damit der Browser ihn abspielen darf. Blockiert der Browser danach das Abspielen, startet ein Klick den Sender.", `Neu für diese Seite: ${fresh.join(", ")}`], true);
|
||||
toast("New station", ["The page reloads so the browser may play it. If the browser then blocks playback, a click starts the station.", `New for this page: ${fresh.join(", ")}`], true);
|
||||
window.setTimeout(() => window.location.reload(), 1500);
|
||||
}
|
||||
return true;
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
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 name = field({type: "text", value: stream.name, placeholder: "Station name", 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});
|
||||
@@ -58,9 +58,9 @@
|
||||
via: stream.via,
|
||||
})};
|
||||
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", () => {
|
||||
h("div", {class: "grid"}, h("label", {}, "Name", name), h("label", {}, "Stream URL", url), h("label", {}, "from", from), h("label", {}, "to", to)),
|
||||
h("div", {class: "days"}, days.map((box, index) => h("label", {class: "check"}, box, WEEKDAYS[index])), h("label", {class: "check"}, on, "active"), h("span", {class: "spacer"}),
|
||||
btn("Remove", "btn small danger", () => {
|
||||
rows.splice(rows.indexOf(entry), 1);
|
||||
entry.row.remove();
|
||||
})));
|
||||
@@ -70,14 +70,14 @@
|
||||
|
||||
function ambience() {
|
||||
const tool = (id, name, label, title, run) => h("button", {type: "button", id, class: "btn small icon-only", ...named(label, title), on: {click: run}}, icon(name));
|
||||
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
|
||||
tool("sky-look", "bi-stars", "Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
|
||||
const section = h("section", {}, h("h3", {}, "Ambience"), h("div", {class: "toolbar"},
|
||||
tool("sky-look", "bi-stars", "Sky", "Look at the sky: dragging turns the view (key h)", () => {
|
||||
closeSheet();
|
||||
skyLook(true);
|
||||
}),
|
||||
tool("music-toggle", "bi-music-note-beamed", "Musik", "Musik: generative Musik an oder aus", toggleMusic),
|
||||
tool("leitstand", "bi-display", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
||||
tool("notify", "bi-bell", "Benachrichtigungen", "Benachrichtigungen: Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
||||
tool("music-toggle", "bi-music-note-beamed", "Music", "Music: generative music on or off", toggleMusic),
|
||||
tool("leitstand", "bi-display", "Control room", "Control room: full screen with large type", toggleLeitstand),
|
||||
tool("notify", "bi-bell", "Notifications", "Notifications: allow the browser's desktop notifications", askNotify)));
|
||||
window.setTimeout(() => {
|
||||
musicButton();
|
||||
notifyLabel();
|
||||
@@ -87,8 +87,8 @@
|
||||
|
||||
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 latitude = number(settings.latitude, -90, 90, "Latitude");
|
||||
const longitude = number(settings.longitude, -180, 180, "Longitude");
|
||||
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])));
|
||||
@@ -98,25 +98,25 @@
|
||||
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" : ""}`;
|
||||
verdict.textContent = `Accent on surfaces: contrast ${ratio.toFixed(1)} : 1${ratio < 3 ? "; too weak, pick a stronger accent" : ""}`;
|
||||
};
|
||||
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 volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Overall volume"});
|
||||
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 on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} on`});
|
||||
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} volume`});
|
||||
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))))};
|
||||
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "on"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
|
||||
});
|
||||
const squelch = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.squelch, "aria-label": "Squelch"});
|
||||
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", title: id === "mayday" ? "MAYDAY wird immer vorgelesen" : null})}));
|
||||
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", title: id === "mayday" ? "MAYDAY is always read aloud" : null})}));
|
||||
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`})}));
|
||||
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `Read ${entry.now} aloud`})}));
|
||||
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: "newsletter", who: HUMAN, line: "radio check"}));
|
||||
sample.disabled = !state.speech.tts;
|
||||
@@ -125,24 +125,24 @@
|
||||
settings.streams.forEach((stream) => streamRow(stream, rows, list));
|
||||
const form = h("form", {class: "settings"},
|
||||
ambience(),
|
||||
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", {}, "Squelch: scales every tone, 0 plays none", squelch), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")),
|
||||
h("section", {}, h("h3", {}, "Funk vorlesen"),
|
||||
h("section", {}, h("h3", {}, "Location"), h("p", {class: "muted"}, "Sun, moon and stars stand where you see them from here, looking south; under 🔭 Sky (key h) you turn the view. Decimal degrees, north and east positive."),
|
||||
h("div", {class: "grid"}, h("label", {}, "Latitude", latitude), h("label", {}, "Longitude", longitude))),
|
||||
h("section", {}, h("h3", {}, "Appearance"), h("label", {}, "Colors", theme), custom, verdict),
|
||||
h("section", {}, h("h3", {}, "Music"), h("p", {class: "muted"}, "Generative ambient music, made offline: calm with an empty inbox, a little tenser when something waits, quieter under messages."),
|
||||
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Music on"), h("label", {}, "Overall volume (music and radio)", volume))),
|
||||
h("section", {}, h("h3", {}, "Tones and notifications"), h("p", {class: "muted"}, "MAYDAY and PAN-PAN are always loudest. With several new events only the most urgent one plays."),
|
||||
tones.map((tone) => tone.row), h("label", {}, "Squelch: scales every tone, 0 plays none", squelch), h("label", {class: "check"}, notify, "Show desktop notifications when the browser allows them")),
|
||||
h("section", {}, h("h3", {}, "Read radio aloud"),
|
||||
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."
|
||||
: `Der Funk bleibt stumm: ${state.speech.tts_off}.`),
|
||||
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. Ein neuer Sender läuft rund um die Uhr, bis du seine Zeiten änderst; unter Stream → Radio startest und stoppst du ihn von Hand. Die Seite darf nur von diesen Adressen Ton laden; eine neue Adresse signiert dein Passkey. Leitet ein signierter Sender um, löst der Server die Weiterleitung beim Speichern eines neuen Senders oder einer geänderten Adresse auf und gibt ihr Ziel frei; der Hinweis nach dem Speichern nennt es."),
|
||||
list, h("div", {}, btn("+ Sender", "btn small soft", () => streamRow({name: "", url: "", from: "00:00", to: "24:00", days: [0, 1, 2, 3, 4, 5, 6], on: true, via: []}, rows, list)))),
|
||||
? "New radio is read aloud in maritime radio style (SMCP), one message after another, each speaker with its own voice; music and internet radio go quieter meanwhile. The volume follows the tones above; MAYDAY is always spoken, and loudest."
|
||||
: `The radio stays silent: ${state.speech.tts_off}.`),
|
||||
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Read radio aloud"), sample),
|
||||
h("div", {class: "days", role: "group", "aria-label": "Classes"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
|
||||
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Speakers"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
|
||||
h("section", {}, h("h3", {}, "Internet radio"), h("p", {class: "muted"}, "Internet radio at fixed times with a 3 s fade in and out; the music pauses meanwhile. A new station plays around the clock until you change its times; under Stream → Radio you start and stop it by hand. The page may load sound only from these addresses; your passkey signs a new address. If a signed station redirects, the server resolves the redirect when you save a new station or a changed address and releases its target; the note after saving names it."),
|
||||
list, h("div", {}, btn("+ Station", "btn small soft", () => streamRow({name: "", url: "", from: "00:00", to: "24:00", days: [0, 1, 2, 3, 4, 5, 6], on: true, via: []}, 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")));
|
||||
h("div", {class: "sheet-foot"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), h("button", {type: "submit", class: "btn primary"}, "Save")));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
context();
|
||||
@@ -165,8 +165,8 @@
|
||||
};
|
||||
if (await saveSettings(next)) {
|
||||
closeSheet();
|
||||
toast("Einstellungen gespeichert", [], true);
|
||||
toast("Settings saved", [], true);
|
||||
}
|
||||
});
|
||||
openSheet("Einstellungen und Ambiente", [form], false);
|
||||
openSheet("Settings and ambience", [form], false);
|
||||
}
|
||||
@@ -56,12 +56,12 @@
|
||||
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]}` : ""}`;
|
||||
const moonText = moon.alt > 0 ? ` · Moon ${Math.round(moon.lit * 100)} % ${moon.waxing ? "waxing" : "waning"}` : "";
|
||||
document.getElementById("sky-note").textContent = `${sun.alt > 0 ? "☀" : "☾"} ${STAGE_LABELS[Ambience.stage(sun.alt)]}${moonText}${turned ? ` · facing ${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)}°`;
|
||||
const words = `Facing ${facing[1]} · ${((Math.round(view.azimuth) % 360) + 360) % 360}° · altitude ${Math.round(view.altitude)}° · field of view ${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.`);
|
||||
sky.canvas.setAttribute("aria-label", `Sky, ${words}. Arrow keys turn the view, plus and minus zoom, 0 resets, Esc ends.`);
|
||||
}
|
||||
const canvas = sky.canvas;
|
||||
const paint = canvas.getContext("2d");
|
||||
@@ -177,7 +177,7 @@
|
||||
canvas.removeAttribute("aria-hidden");
|
||||
canvas.setAttribute("tabindex", "0");
|
||||
canvas.setAttribute("role", "application");
|
||||
canvas.setAttribute("aria-roledescription", "Himmel");
|
||||
canvas.setAttribute("aria-roledescription", "Sky");
|
||||
drawSky();
|
||||
canvas.focus();
|
||||
return;
|
||||
@@ -194,13 +194,13 @@
|
||||
const step = (name, label, run) => iconBtn(name, label, "btn small", run);
|
||||
space.compass = h("span", {class: "compass", "aria-live": "polite"});
|
||||
panel.append(space.compass,
|
||||
step("bi-caret-left-fill", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("bi-caret-right-fill", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
||||
step("bi-caret-up-fill", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
||||
step("bi-caret-down-fill", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
||||
step("bi-zoom-out", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("bi-zoom-in", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("bi-arrow-counterclockwise", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||
step("bi-check-lg", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||
step("bi-caret-left-fill", "Turn the view left (left arrow)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("bi-caret-right-fill", "Turn the view right (right arrow)", () => skyHooks.turnStep(1, 0)),
|
||||
step("bi-caret-up-fill", "Raise the view (up arrow)", () => skyHooks.turnStep(0, -1)),
|
||||
step("bi-caret-down-fill", "Lower the view (down arrow)", () => skyHooks.turnStep(0, 1)),
|
||||
step("bi-zoom-out", "Widen the field of view (minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("bi-zoom-in", "Zoom in (plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("bi-arrow-counterclockwise", "Reset the view: south, horizon, field of view 180° (key 0)", () => resetView("sky")),
|
||||
step("bi-check-lg", "Leave the sky (Esc)", () => skyLook(false)));
|
||||
steer(sky.canvas, "sky", skyHooks);
|
||||
}
|
||||
@@ -88,10 +88,10 @@
|
||||
}
|
||||
|
||||
function spawnItem(entry) {
|
||||
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "läuft", "ok") : chip("bi-check-circle", "fertig", "neutral");
|
||||
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "running", "ok") : chip("bi-check-circle", "finished", "neutral");
|
||||
const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id);
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Transkript im Stream zeigen" : `${entry.label} öffnen`, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Tiefe ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
|
||||
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
|
||||
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
|
||||
entry.children.length ? h("ul", {class: "spawn-tree"}, entry.children.map(spawnItem)) : null);
|
||||
@@ -116,6 +116,6 @@
|
||||
const all = spawnFlat(root).map(({entry}) => entry).filter((entry) => entry.running !== null);
|
||||
const running = all.filter((entry) => entry.running).length;
|
||||
spawn.dom.list.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn-Baum", counter(all.length)), h("span", {class: "muted"}, `${running} laufen · ${all.length - running} fertig · Operator → Chair → Sessions → Subagenten`)),
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn tree", counter(all.length)), h("span", {class: "muted"}, `${running} running · ${all.length - running} finished · Operator → Chair → Sessions → Subagents`)),
|
||||
h("ul", {class: "spawn-tree root"}, spawnItem(root)));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
function speechFailed(lines) {
|
||||
if (Date.now() - speech.warned > SPEECH_QUIET) {
|
||||
speech.warned = Date.now();
|
||||
toast("Funk vorlesen fehlgeschlagen", lines, false);
|
||||
toast("Reading radio aloud failed", lines, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,16 @@
|
||||
|
||||
function micGap() {
|
||||
return !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: ${state.speech.stt_off}` : "";
|
||||
? "The microphone needs a secure context: open the hub via 127.0.0.1 or localhost"
|
||||
: !window.MediaRecorder ? "This browser cannot record" : !state.speech.stt ? `Voice input is off: ${state.speech.stt_off}` : "";
|
||||
}
|
||||
|
||||
function micButton(field) {
|
||||
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Spracheingabe", "Spracheingabe: gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet")}, icon("bi-mic"));
|
||||
const button = h("button", {type: "button", class: "btn small ghost icon-only mic", "aria-pressed": "false", ...named("Voice input", "Voice input: hold and speak; the text lands in the field and is not sent")}, icon("bi-mic"));
|
||||
const reason = micGap();
|
||||
if (reason) {
|
||||
button.disabled = true;
|
||||
button.title = `Spracheingabe: ${reason}`;
|
||||
button.title = `Voice input: ${reason}`;
|
||||
return button;
|
||||
}
|
||||
const press = (event) => {
|
||||
@@ -119,7 +119,7 @@
|
||||
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);
|
||||
toast("Microphone not available", [String(error.message || error)], false);
|
||||
return;
|
||||
}
|
||||
if (!mic.held) {
|
||||
@@ -158,7 +158,7 @@
|
||||
const rate = state.speech.rate;
|
||||
const length = Voice.frames(decoded.duration, rate, state.speech.seconds);
|
||||
if (length < rate / 4) {
|
||||
throw new Error("zu kurz: sprich mindestens eine Viertelsekunde");
|
||||
throw new Error("too short: speak for at least a quarter second");
|
||||
}
|
||||
const offline = new OfflineAudioContext(1, length, rate);
|
||||
const source = offline.createBufferSource();
|
||||
@@ -187,7 +187,7 @@
|
||||
}
|
||||
field.focus();
|
||||
} catch (error) {
|
||||
toast("Spracheingabe fehlgeschlagen", [String(error.message || error)], false);
|
||||
toast("Voice input failed", [String(error.message || error)], false);
|
||||
} finally {
|
||||
mic.busy = false;
|
||||
button.disabled = false;
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
holder.textContent = data.text;
|
||||
button.remove();
|
||||
} else {
|
||||
button.textContent = "Nicht mehr verfügbar";
|
||||
button.textContent = "No longer available";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
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];
|
||||
return [status(ref.status), ref.gate ? pill(ref.gate === "pass" ? "Gate green" : "Gate red", ref.gate === "pass" ? "ok" : "bad") : null];
|
||||
}
|
||||
if (entry.kind === "ask") {
|
||||
return [status(ref.status), ...ref.warnings.map((warning) => pill(warning, "bad"))];
|
||||
@@ -93,7 +93,7 @@
|
||||
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")];
|
||||
return [pill(ref.active ? "active" : "inactive", ref.active ? "ok" : "neutral")];
|
||||
}
|
||||
if (entry.kind === "run" || entry.kind === "operator" || entry.kind === "register") {
|
||||
return [ref.status ? status(ref.status) : null];
|
||||
@@ -104,7 +104,7 @@
|
||||
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;
|
||||
const more = entry.ref.truncated ? h("button", {type: "button", class: "btn small ghost"}, "Show full text") : null;
|
||||
if (more) {
|
||||
more.addEventListener("click", () => expand(entry, text, more));
|
||||
}
|
||||
@@ -115,12 +115,12 @@
|
||||
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"}, iconBtn("bi-eye", "Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
|
||||
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, iconBtn("bi-eye", "View diff", "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)];
|
||||
const ended = ref.deleted ? [" · ended ", when(ref.deleted)] : [];
|
||||
return [entry.text ? h("pre", {}, entry.text) : null, h("span", {class: "muted"}, "created ", when(ref.created), ended)];
|
||||
}
|
||||
if (entry.kind === "run") {
|
||||
return [entry.text ? h("pre", {}, entry.text) : null];
|
||||
@@ -131,7 +131,7 @@
|
||||
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 renamed = entry.now && entry.now !== entry.agent ? h("span", {title: `now: ${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" : ""}`},
|
||||
@@ -149,33 +149,33 @@
|
||||
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" : ""}`;
|
||||
stream.dom.count.textContent = `${rows.length} entries${live() ? " · following live" : ""}`;
|
||||
if (stream.error) {
|
||||
stream.dom.list.replaceChildren(emptyState("Stream nicht lesbar", stream.error));
|
||||
stream.dom.list.replaceChildren(emptyState("Stream not readable", 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."));
|
||||
rows.length > shown.length ? h("p", {class: "footnote"}, `${rows.length - shown.length} older entries hidden; narrow down with a filter or the time range.`) : null)
|
||||
: emptyState(stream.busy ? "Loading …" : "No entries", activeWaiting() ? "Nothing is waiting for you right now." : "Nothing in the chosen time range and filter."));
|
||||
}
|
||||
|
||||
function streamScreen() {
|
||||
const search = h("input", {type: "search", placeholder: "Suchen … agent:Name art:funk seit:2h (/)", value: filter.q, "aria-label": "Stream durchsuchen: Wörter, agent:, art: und seit:", on: {input: (event) => {
|
||||
const search = h("input", {type: "search", placeholder: "Search … agent:Name kind:radio since:2h (/)", value: filter.q, "aria-label": "Search the stream: words, agent:, kind: and since:", on: {input: (event) => {
|
||||
filter.q = event.target.value;
|
||||
filter.saved = "";
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = window.setTimeout(fetchEvents, 300);
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const categories = h("div", {class: "chips", role: "group", "aria-label": "Kategorien"});
|
||||
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Arten"});
|
||||
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transkript-Teile"});
|
||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
||||
const categories = h("div", {class: "chips", role: "group", "aria-label": "Categories"});
|
||||
const kinds = h("div", {class: "chips", role: "group", "aria-label": "Kinds"});
|
||||
const parts = h("div", {class: "chips", role: "group", "aria-label": "Transcript parts"});
|
||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Saved views"});
|
||||
const focus = h("div", {class: "chips"});
|
||||
const list = h("div", {});
|
||||
const more = h("details", {class: "menu"}, h("summary", named("Filter", "Filter: Arten, Transkript-Teile, gespeicherte Ansichten, eigener Zeitraum"), icon("bi-funnel"), caret()), h("div", {class: "menu-box end"},
|
||||
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
|
||||
const more = h("details", {class: "menu"}, h("summary", named("Filter", "Filter: kinds, transcript parts, saved views, custom time range"), icon("bi-funnel"), caret()), h("div", {class: "menu-box end"},
|
||||
h("h3", {}, "Kinds"), kinds, h("h3", {}, "Transcript parts"), parts, h("h3", {}, "Saved views"), saved, h("h3", {}, "Custom time range"), customRange()));
|
||||
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
|
||||
window.setTimeout(() => {
|
||||
drawFilters();
|
||||
|
||||
@@ -10,76 +10,76 @@
|
||||
|
||||
async function changeTask(row, argv, label, outcome) {
|
||||
const items = [{kind: "task", id: row.task, argv}];
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Signiere mit dem Passkey …"));
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Signing with the passkey …"));
|
||||
const signed = await proofFor(items, label);
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Not signed: nothing was handed over."));
|
||||
return;
|
||||
}
|
||||
const result = await sendJson("/api/tasks", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${label}: nicht möglich`, fail(result), false);
|
||||
toast(`${label}: not possible`, fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signiert und an den Runner übergeben (Job ${result.data.jobs.join(", ")}); er ruft rasci.py auf dem Host auf.`));
|
||||
toast(`${label}: an den Runner übergeben`, [shownArgv(argv)], true);
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signed and handed to the runner (job ${result.data.jobs.join(", ")}); it calls rasci.py on the host.`));
|
||||
toast(`${label}: handed to the runner`, [shownArgv(argv)], true);
|
||||
await load();
|
||||
}
|
||||
|
||||
function priorityForm(row) {
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const own = row.estimates[ESTIMATOR] || null;
|
||||
const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priorität", placeholder: "1"});
|
||||
const value = fibonacciPicker("Geschäftswert", own ? own.value : 5);
|
||||
const complexity = fibonacciPicker("Komplexität", own ? own.complexity : 3);
|
||||
const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priority", placeholder: "1"});
|
||||
const value = fibonacciPicker("Business value", own ? own.value : 5);
|
||||
const complexity = fibonacciPicker("Complexity", own ? own.complexity : 3);
|
||||
const pinCode = h("code", {});
|
||||
const estimateCode = h("code", {});
|
||||
const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)];
|
||||
const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value];
|
||||
const pin = h("button", {type: "button", class: "btn small primary", ...named("Anheften", "Anheften: Priorität mit dem Passkey signieren")}, icon("bi-pin-angle-fill"), "Anheften");
|
||||
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Schätzung speichern", "Schätzung signiert an den Runner geben")}, icon("bi-calculator"), "Schätzung speichern");
|
||||
const pin = h("button", {type: "button", class: "btn small primary", ...named("Pin", "Pin: sign the priority with the passkey")}, icon("bi-pin-angle-fill"), "Pin");
|
||||
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Save estimate", "Hand the estimate to the runner, signed")}, icon("bi-calculator"), "Save estimate");
|
||||
const sync = () => {
|
||||
pin.disabled = !prioText(prio.value);
|
||||
pinCode.textContent = pin.disabled ? `Priorität 1 bis ${PRIO_MAX} eingeben` : shownArgv(prioArgv());
|
||||
pinCode.textContent = pin.disabled ? `Enter a priority from 1 to ${PRIO_MAX}` : shownArgv(prioArgv());
|
||||
estimateCode.textContent = shownArgv(estimateArgv());
|
||||
};
|
||||
for (const field of [prio, value, complexity]) {
|
||||
field.addEventListener("input", sync);
|
||||
}
|
||||
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priorität ${row.task}`, outcome));
|
||||
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priority ${row.task}`, outcome));
|
||||
estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome));
|
||||
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Schätzung verwerfen", "Schätzung verwerfen: deine Schätzung mit dem Passkey signiert löschen"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Schätzung verwerfen") : null;
|
||||
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Discard estimate", "Discard estimate: delete your estimate, signed with the passkey"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Discard estimate") : null;
|
||||
sync();
|
||||
return h("section", {class: "task-edit", "aria-label": "Priorität und WSJF"},
|
||||
h("h3", {}, "Priorität anheften"),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Priorität (1 zuerst)", prio), pin),
|
||||
return h("section", {class: "task-edit", "aria-label": "Priority and WSJF"},
|
||||
h("h3", {}, "Pin priority"),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Priority (1 first)", prio), pin),
|
||||
h("div", {class: "command"}, pinCode),
|
||||
h("h3", {}, `WSJF schätzen als ${ESTIMATOR}`),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Geschäftswert", value), h("label", {}, "Komplexität", complexity), estimate, drop),
|
||||
h("h3", {}, `Estimate WSJF as ${ESTIMATOR}`),
|
||||
h("div", {class: "task-form"}, h("label", {}, "Business value", value), h("label", {}, "Complexity", complexity), estimate, drop),
|
||||
h("div", {class: "command"}, estimateCode),
|
||||
h("p", {class: "muted"}, "Dein Passkey signiert genau diesen Aufruf; der Runner auf dem Host prüft die Signatur und die Form der Argumente und startet rasci.py ohne Shell. Die Priorität der Agenten bleibt unberührt."),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Signieren geht nur unter ", localLink()),
|
||||
h("p", {class: "muted"}, "Your passkey signs exactly this call; the runner on the host checks the signature and the shape of the arguments and starts rasci.py without a shell. The agent priority stays untouched."),
|
||||
onLocalhost() ? null : h("p", {class: "muted"}, "Signing works only under ", localLink()),
|
||||
outcome);
|
||||
}
|
||||
|
||||
function taskRoles(row) {
|
||||
const entries = Object.entries(row.roles);
|
||||
return [h("h3", {}, "RASCI", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "Keine Rollen eingetragen.")];
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "No roles entered.")];
|
||||
}
|
||||
|
||||
function taskEstimates(row) {
|
||||
const entries = Object.entries(row.estimates);
|
||||
const score = row.wsjf;
|
||||
return [h("h3", {}, "WSJF-Schätzungen", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Geschäftswert ${entry.value} · Komplexität ${entry.complexity}`))))) : h("p", {class: "muted"}, "Noch keine Schätzung: die Aufgabe rangiert nach den geschätzten."),
|
||||
return [h("h3", {}, "WSJF estimates", counter(entries.length)),
|
||||
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Business value ${entry.value} · complexity ${entry.complexity}`))))) : h("p", {class: "muted"}, "No estimate yet: the task ranks after the estimated ones."),
|
||||
score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null];
|
||||
}
|
||||
|
||||
function taskJobs(row) {
|
||||
const jobs = state.jobs.filter((job) => job.kind === "task" && job.task === row.task).slice(-5).reverse();
|
||||
return jobs.length ? [h("h3", {}, "Signierte Änderungen", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
|
||||
return jobs.length ? [h("h3", {}, "Signed changes", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
|
||||
h("div", {class: "row"}, h("code", {}, job.command), status(job.status)),
|
||||
job.status === "refused" || job.status === "failed" ? h("pre", {class: "notes"}, job.output) : null)))] : null;
|
||||
}
|
||||
@@ -89,16 +89,16 @@
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const facts = [["Ziel", row.objective], ["Key Results", row.total ? `${row.met} von ${row.total} erfüllt` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
|
||||
const facts = [["Objective", row.objective], ["Key Results", row.total ? `${row.met} of ${row.total} met` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
|
||||
openSheet(row.task, [
|
||||
h("div", {class: "who"}, taskBadges(row, true), row.worker !== "-" ? [face(row.worker, false), person(row.worker)] : null),
|
||||
h("p", {class: "text"}, titleOf(row)),
|
||||
meta(row.queue ? [["Queue", `${row.queue.kind} für ${row.queue.parent}`], ...facts.slice(2)] : facts),
|
||||
meta(row.queue ? [["Queue", `${row.queue.kind} for ${row.queue.parent}`], ...facts.slice(2)] : facts),
|
||||
workChips(row.open_work),
|
||||
flowDetails(row.flow, branchOf(row)),
|
||||
taskRoles(row),
|
||||
row.kind === "task" ? taskEstimates(row) : null,
|
||||
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Beendet: Priorität und WSJF bleiben, wie sie sind." : "Priorität und WSJF gibt es nur für Aufgaben des Registers, nicht für Befehle, Hotfixes und Queue-Jobs."),
|
||||
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Ended: priority and WSJF stay as they are." : "Priority and WSJF exist only for tasks of the register, not for commands, hotfixes and queue jobs."),
|
||||
taskJobs(row),
|
||||
], false);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]};
|
||||
const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]];
|
||||
const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]};
|
||||
const TASK_STATUS = {queued: ["waiting", "info"], running: ["running", "ok"], blocked: ["blocked", "bad"], done: ["done", "neutral"], stopped: ["stopped", "neutral"], awaiting: ["waiting for a grant", "warn"]};
|
||||
const TASK_COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["blocked", "Blocked"], ["done", "Done"], ["stopped", "Stopped"]];
|
||||
const TASK_KINDS = {command: ["Command", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue job", "teal"]};
|
||||
const TASK_FILTERS = [
|
||||
["alle", "Alle", "bi-list-check", () => true],
|
||||
["offen", "Offen", "bi-play-circle", (row) => !row.ended],
|
||||
["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0],
|
||||
["operator", "Wartet auf Operator", "bi-person-raised-hand", null],
|
||||
["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
|
||||
["beendet", "Beendet", "bi-check2-circle", (row) => row.ended],
|
||||
["all", "All", "bi-list-check", () => true],
|
||||
["open", "Open", "bi-play-circle", (row) => !row.ended],
|
||||
["work", "Open work", "bi-pencil-square", (row) => row.open_work.length > 0],
|
||||
["operator", "Waiting for operator", "bi-person-raised-hand", null],
|
||||
["grant", "Waiting for a grant", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
|
||||
["ended", "Ended", "bi-check2-circle", (row) => row.ended],
|
||||
];
|
||||
const ROLE_TONES = {R: "ok", A: "violet", S: "info", C: "warn", I: "neutral"};
|
||||
const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"};
|
||||
const MATRIX_PARTIES = 40;
|
||||
const taskView = {filter: "alle", q: "", dom: null, sig: ""};
|
||||
const taskView = {filter: "all", q: "", dom: null, sig: ""};
|
||||
|
||||
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
|
||||
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
|
||||
@@ -31,9 +31,9 @@
|
||||
kindName ? pill(kindName, kindTone) : null,
|
||||
withStatus ? taskStatus(row) : null,
|
||||
row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null,
|
||||
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "angeheftete Priorität"}, `Prio ${row.prio}`) : null,
|
||||
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null,
|
||||
openJobs(row.task).length ? pill("Änderung beim Runner", "warn") : null,
|
||||
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "pinned priority"}, `Prio ${row.prio}`) : null,
|
||||
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: business value ${score.value} / complexity ${score.complexity}`}, `WSJF ${score.score}`) : null,
|
||||
openJobs(row.task).length ? pill("Change at the runner", "warn") : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,23 +44,23 @@
|
||||
|
||||
function taskPlace(row) {
|
||||
if (row.kind === "queue") {
|
||||
return h("span", {}, `${row.queue.kind} für ${row.queue.parent} · eingereiht `, when(row.queue.enqueued));
|
||||
return h("span", {}, `${row.queue.kind} for ${row.queue.parent} · queued `, when(row.queue.enqueued));
|
||||
}
|
||||
const place = row.worktree || (row.workdir !== "-" ? row.workdir : "");
|
||||
return place ? h("code", {title: row.repo || place}, place) : null;
|
||||
}
|
||||
|
||||
function opener(row) {
|
||||
return h("button", {type: "button", class: "linkish", title: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
|
||||
return h("button", {type: "button", class: "linkish", title: `Open ${row.task}`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
|
||||
}
|
||||
|
||||
function taskItem(row) {
|
||||
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
|
||||
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
|
||||
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
|
||||
row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null),
|
||||
row.editable ? iconBtn("bi-sort-numeric-down", "Priority", "btn small soft", () => taskSheet(row.task), `Priority and WSJF of ${row.task}: pin or estimate, signed with the passkey`) : null),
|
||||
opener(row),
|
||||
row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${row.objective}`) : null,
|
||||
row.objective !== "-" ? h("div", {class: "muted"}, `Objective: ${row.objective}`) : null,
|
||||
h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)),
|
||||
flowLine(row.flow, branchOf(row)),
|
||||
workChips(row.open_work));
|
||||
@@ -82,7 +82,7 @@
|
||||
const cards = rows.filter((row) => columnOf(row) === id);
|
||||
return h("section", {class: "card task-column", "data-column": id, "aria-label": label},
|
||||
h("h3", {class: "group-title"}, label, counter(cards.length)),
|
||||
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer"));
|
||||
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "empty"));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -91,21 +91,21 @@
|
||||
}
|
||||
|
||||
function taskMatrix(rows) {
|
||||
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "de"));
|
||||
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "en"));
|
||||
if (!parties.length) {
|
||||
return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder I.");
|
||||
return emptyState("No roles", "None of the shown tasks names R, A, S, C or I.");
|
||||
}
|
||||
const columns = parties.slice(0, MATRIX_PARTIES);
|
||||
const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)),
|
||||
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} weitere Parteien nicht gezeigt`) : null);
|
||||
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
|
||||
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} more parties not shown`) : null);
|
||||
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Task"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
|
||||
const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null},
|
||||
h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))),
|
||||
columns.map((party) => {
|
||||
const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : "";
|
||||
return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters));
|
||||
}));
|
||||
const table = h("table", {class: "matrix", "aria-label": "RASCI-Matrix: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line)));
|
||||
const table = h("table", {class: "matrix", "aria-label": "RASCI matrix: tasks by parties"}, head, h("tbody", {}, rows.map(line)));
|
||||
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
}
|
||||
|
||||
function taskViewSwitch(inbox) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "View"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
|
||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
@@ -139,9 +139,9 @@
|
||||
function tasksScreen() {
|
||||
const inbox = ui.screen === "inbox";
|
||||
const view = ui.views.tasks;
|
||||
const chips = h("div", {class: "chips", role: "group", "aria-label": "Aufgabenfilter"});
|
||||
const chips = h("div", {class: "chips", role: "group", "aria-label": "Task filters"});
|
||||
const count = h("span", {class: "muted"});
|
||||
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => {
|
||||
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Filter by task, title, worker, branch …", value: taskView.q, "aria-label": "Filter tasks", on: {input: (event) => {
|
||||
taskView.q = event.target.value;
|
||||
drawTasks();
|
||||
}}});
|
||||
@@ -165,13 +165,13 @@
|
||||
const rows = state.tasks;
|
||||
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
|
||||
if (dom.inbox) {
|
||||
dom.count.textContent = `${rows.length} Aufgaben im Register`;
|
||||
dom.count.textContent = `${rows.length} tasks in the register`;
|
||||
return;
|
||||
}
|
||||
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
|
||||
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
|
||||
const shown = rows.filter(test).filter(hit);
|
||||
dom.count.textContent = `${shown.length} von ${rows.length} Aufgaben`;
|
||||
dom.count.textContent = `${shown.length} of ${rows.length} tasks`;
|
||||
const views = {list: (found) => h("ol", {class: "task-list"}, found.map(taskItem)), board: taskBoard, rasci: taskMatrix};
|
||||
dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter."));
|
||||
dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("No task", "No entry matches this filter."));
|
||||
}
|
||||
@@ -13,14 +13,14 @@
|
||||
};
|
||||
|
||||
function customRange() {
|
||||
const from = h("input", {type: "datetime-local", "aria-label": "Von", value: localInput(ui.from === null ? state.since : ui.from)});
|
||||
const to = h("input", {type: "datetime-local", "aria-label": "Bis", value: localInput(ui.to === null ? state.until : ui.to)});
|
||||
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "bis"), to,
|
||||
const from = h("input", {type: "datetime-local", "aria-label": "From", value: localInput(ui.from === null ? state.since : ui.from)});
|
||||
const to = h("input", {type: "datetime-local", "aria-label": "To", value: localInput(ui.to === null ? state.until : ui.to)});
|
||||
return h("span", {class: "custom"}, from, h("span", {class: "muted"}, "to"), 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);
|
||||
toast("Time range invalid", ["“From” must come before “To”."], false);
|
||||
return;
|
||||
}
|
||||
stopReplay();
|
||||
@@ -28,15 +28,15 @@
|
||||
ui.from = begin;
|
||||
ui.to = end;
|
||||
reload();
|
||||
}}}, "Anwenden"));
|
||||
}}}, "Apply"));
|
||||
}
|
||||
|
||||
function timeBar() {
|
||||
const bar = document.getElementById("time");
|
||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
||||
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
|
||||
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
|
||||
const play = iconBtn("bi-play-fill", "Wiedergabe", "btn small soft", null);
|
||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Time range"});
|
||||
const summary = h("summary", {title: "Time range and replay"});
|
||||
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Replay position"});
|
||||
const play = iconBtn("bi-play-fill", "Replay", "btn small soft", null);
|
||||
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
||||
const label = h("span", {class: "window-label"});
|
||||
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
reload();
|
||||
});
|
||||
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Zeitraum"), presets, h("h3", {}, "Wiedergabe"), h("div", {class: "replay"}, play, slider, liveButton), label,
|
||||
h("p", {class: "muted"}, "Einen eigenen Zeitraum setzt Filter ▾ im Stream.")));
|
||||
bar.append(summary, h("div", {class: "menu-box end"}, h("h3", {}, "Time range"), presets, h("h3", {}, "Replay"), h("div", {class: "replay"}, play, slider, liveButton), label,
|
||||
h("p", {class: "muted"}, "Filter ▾ in the stream sets a custom time range.")));
|
||||
timeDom = {summary, presets, slider, play, live: liveButton, label};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
for (const button of timeDom.presets.children) {
|
||||
button.setAttribute("aria-pressed", String(Number(button.dataset.preset) === ui.preset));
|
||||
}
|
||||
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "eigener"])[1]}${ui.asOf === null ? "" : " · Wiedergabe"} ▾`;
|
||||
timeDom.summary.textContent = `${(PRESETS.find(([seconds]) => seconds === ui.preset) || [0, "custom"])[1]}${ui.asOf === null ? "" : " · replay"} ▾`;
|
||||
timeDom.slider.disabled = ui.preset === -1;
|
||||
timeDom.play.disabled = ui.preset === -1;
|
||||
timeDom.live.disabled = live();
|
||||
@@ -127,6 +127,6 @@
|
||||
ui.playing = false;
|
||||
window.clearTimeout(replay);
|
||||
if (timeDom) {
|
||||
relabel(timeDom.play, "bi-play-fill", "Wiedergabe");
|
||||
relabel(timeDom.play, "bi-play-fill", "Replay");
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
function branchStates(entry) {
|
||||
return [
|
||||
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `gemergt${entry.merged_by ? ` durch ${entry.merged_by}` : ""}${entry.merged_into ? ` in ${entry.merged_into}` : ""}`, "ok") : null,
|
||||
entry.state === "unmerged" ? chip("bi-signpost-split", "nicht gemergt", "neutral") : null,
|
||||
entry.state === "protected" ? chip("bi-shield-lock", "geschützt", "violet") : null,
|
||||
entry.state === "stale" ? chip("bi-hourglass-bottom", "veraltet", "warn") : null,
|
||||
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} Commits ungepusht`, "info", entry.ahead) : null,
|
||||
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} Commits zurück`, "warn", entry.behind) : null,
|
||||
entry.gone ? chip("bi-cloud-slash", "Upstream weg", "bad") : null,
|
||||
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "ohne Upstream", "neutral") : null,
|
||||
entry.skip ? chip("bi-slash-circle", `gc überspringt: ${entry.skip}`, "bad") : null,
|
||||
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `merged${entry.merged_by ? ` by ${entry.merged_by}` : ""}${entry.merged_into ? ` into ${entry.merged_into}` : ""}`, "ok") : null,
|
||||
entry.state === "unmerged" ? chip("bi-signpost-split", "not merged", "neutral") : null,
|
||||
entry.state === "protected" ? chip("bi-shield-lock", "protected", "violet") : null,
|
||||
entry.state === "stale" ? chip("bi-hourglass-bottom", "stale", "warn") : null,
|
||||
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} commits unpushed`, "info", entry.ahead) : null,
|
||||
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} commits behind`, "warn", entry.behind) : null,
|
||||
entry.gone ? chip("bi-cloud-slash", "upstream gone", "bad") : null,
|
||||
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "no upstream", "neutral") : null,
|
||||
entry.skip ? chip("bi-slash-circle", `gc skips: ${entry.skip}`, "bad") : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
const counts = [wt.staged, wt.unstaged, wt.untracked];
|
||||
const changed = counts.every((value) => typeof value === "number") ? counts.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
return [
|
||||
wt.state === "clean" ? chip("bi-check-circle", "sauber", "ok") : null,
|
||||
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "nicht committete Änderungen" : `nicht committet: ${wt.staged} gestaged, ${wt.unstaged} geändert, ${wt.untracked} neu`, "warn", changed) : null,
|
||||
wt.state === "missing" ? chip("bi-question-circle", "Worktree fehlt", "neutral") : null,
|
||||
wt.locked ? chip("bi-lock-fill", `gesperrt: ${wt.locked}`, "warn") : null,
|
||||
wt.state === "clean" ? chip("bi-check-circle", "clean", "ok") : null,
|
||||
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "uncommitted changes" : `not committed: ${wt.staged} staged, ${wt.unstaged} changed, ${wt.untracked} new`, "warn", changed) : null,
|
||||
wt.state === "missing" ? chip("bi-question-circle", "worktree missing", "neutral") : null,
|
||||
wt.locked ? chip("bi-lock-fill", `locked: ${wt.locked}`, "warn") : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
return null;
|
||||
}
|
||||
const ready = tree.data.delete_ready === true;
|
||||
const hint = ready ? `Löschen: ${entry.name} lokal entfernen, nach Trockenlauf und Signatur` : `Löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}`;
|
||||
const button = iconBtn("bi-trash3", "Löschen", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
|
||||
const hint = ready ? `Delete: remove ${entry.name} locally, after dry run and signature` : `Delete blocked: ${tree.data.delete_reason || "delete_ready is false"}`;
|
||||
const button = iconBtn("bi-trash3", "Delete", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
|
||||
button.disabled = !ready;
|
||||
return h("span", {class: "hint", title: hint}, button);
|
||||
}
|
||||
@@ -39,19 +39,19 @@
|
||||
function deleteAll() {
|
||||
const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
|
||||
const ready = tree.data.delete_ready === true && items.length > 0;
|
||||
const hint = tree.data.delete_ready !== true ? `Alle gemergten löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}` : items.length ? `Alle gemergten löschen: ${items.length} Branches nach Trockenlauf und Signatur` : "Alle gemergten löschen: kein gemergter Branch ohne Skip-Grund";
|
||||
const button = iconBtn("bi-trash3-fill", "Alle gemergten löschen", "btn small danger", () => deleteDialog(items), hint);
|
||||
const hint = tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason";
|
||||
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items), hint);
|
||||
button.disabled = !ready;
|
||||
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null);
|
||||
}
|
||||
|
||||
function leafRow(found) {
|
||||
return h("li", {class: "leaf"}, h("span", {class: "state", "data-tone": found.ended ? "warn" : "neutral", role: "img", ...named(found.kind)}, icon(ARTEFACT_ICONS[found.kind] || "bi-paperclip")),
|
||||
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
|
||||
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("task ended", "warn") : null);
|
||||
}
|
||||
|
||||
function openWork(wt) {
|
||||
return wt.open_work.length ? chip("bi-lightning-charge", `offene Arbeit: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
|
||||
return wt.open_work.length ? chip("bi-lightning-charge", `open work: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
|
||||
}
|
||||
|
||||
function worktreeRow(wt) {
|
||||
@@ -61,11 +61,11 @@
|
||||
h("span", {class: "states"}, worktreeStates(wt), openWork(wt)),
|
||||
h("code", {}, wt.path),
|
||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||
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,
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "nobody"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate green" : "Gate red", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
copyButton(wt.path)),
|
||||
flowLine(wt.flow, wt.branch),
|
||||
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
|
||||
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artifacts"}, wt.artefacts.map(leafRow)) : null);
|
||||
}
|
||||
|
||||
function resultOf(repo, entry) {
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
|
||||
function resultChip(result) {
|
||||
const [name, label, tone] = result.status === "done" ? ["bi-check2-all", "gelöscht", "ok"] : result.status === "refused" ? ["bi-x-octagon", "verweigert", "bad"] : ["bi-exclamation-triangle", "fehlgeschlagen", "bad"];
|
||||
const [name, label, tone] = result.status === "done" ? ["bi-check2-all", "deleted", "ok"] : result.status === "refused" ? ["bi-x-octagon", "refused", "bad"] : ["bi-exclamation-triangle", "failed", "bad"];
|
||||
return chip(name, `${label}${result.reason ? `: ${result.reason}` : ""}`, tone);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
if (!tree.data || tree.data.error) {
|
||||
tree.dom.count.textContent = "";
|
||||
tree.dom.removeAll.replaceChildren();
|
||||
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
|
||||
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
|
||||
return;
|
||||
}
|
||||
tree.dom.removeAll.replaceChildren(deleteAll());
|
||||
@@ -110,9 +110,9 @@
|
||||
const tasks = (tree.data.tasks || []).filter((task) => hit(task.task, task.title, ...task.artefacts.map((found) => found.ref)));
|
||||
const listed = new Set(tree.data.repos.flatMap((repo) => repo.branches.map((entry) => `${repo.path}\u0000${entry.name}`)));
|
||||
const gone = (tree.data.cleanups || []).flatMap((result) => result.items).filter((item) => !listed.has(`${item.repository}\u0000${item.branch}`) && hit(item.repository, item.branch));
|
||||
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Löschen gesperrt: ${tree.data.delete_reason}`}`;
|
||||
tree.dom.count.textContent = `${repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Delete blocked: ${tree.data.delete_reason}`}`;
|
||||
if (!repos.length && !tasks.length && !gone.length) {
|
||||
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
|
||||
tree.dom.list.replaceChildren(emptyState("Nothing found", "No repository matches the filter."));
|
||||
return;
|
||||
}
|
||||
const cards = repos.map(({repo, branches, detached}) => {
|
||||
@@ -121,11 +121,11 @@
|
||||
const merged = repo.branches.filter((entry) => entry.cleanup).length;
|
||||
const busy = trees.filter((wt) => wt.open_work.length).length;
|
||||
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
|
||||
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null,
|
||||
busy ? chip("bi-lightning-charge", `${busy} Worktrees mit offener Arbeit`, "warn", busy) : null),
|
||||
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} branches`), counter(`${trees.length} worktrees`),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} worktrees with changes`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} merged, deletable`, "ok", merged) : null,
|
||||
busy ? chip("bi-lightning-charge", `${busy} worktrees with open work`, "warn", busy) : null),
|
||||
branches.map((entry) => branchRow(repo, entry)),
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "detached HEAD")), detached.map(worktreeRow)) : null);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
tree.open.add(repo.path);
|
||||
@@ -135,10 +135,10 @@
|
||||
});
|
||||
return details;
|
||||
});
|
||||
const loose = tasks.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-card-checklist"), h("span", {}, "Aufgaben ohne Worktree"), counter(tasks.length)),
|
||||
const loose = tasks.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-card-checklist"), h("span", {}, "Tasks without a worktree"), counter(tasks.length)),
|
||||
tasks.map((task) => h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, task.task), h("span", {class: "muted"}, task.title), status(task.status)),
|
||||
h("ul", {class: "leaves", "aria-label": "Artefakte"}, task.artefacts.map(leafRow))))) : null;
|
||||
const past = gone.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-trash3"), h("span", {}, "Zuletzt gelöscht"), counter(gone.length)),
|
||||
h("ul", {class: "leaves", "aria-label": "Artifacts"}, task.artefacts.map(leafRow))))) : null;
|
||||
const past = gone.length ? h("details", {class: "card repo", open: true}, h("summary", {}, icon("bi-trash3"), h("span", {}, "Recently deleted"), counter(gone.length)),
|
||||
gone.map((item) => h("div", {class: "branch-row", "data-result": item.status}, h("div", {class: "branch-name"}, h("strong", {}, item.branch), h("code", {}, item.repository), resultChip(item))))) : null;
|
||||
tree.dom.list.replaceChildren(...cards, loose || "", past || "");
|
||||
}
|
||||
@@ -239,7 +239,7 @@ skills_current() {
|
||||
|
||||
offices_unit() {
|
||||
if ! has_systemd; then
|
||||
warn "skills: no systemd user session; the office changes signed in the Hierarchie view wait."
|
||||
warn "skills: no systemd user session; the office changes signed in the Hierarchy view wait."
|
||||
return 0
|
||||
fi
|
||||
if ! skills_current; then
|
||||
@@ -250,7 +250,7 @@ offices_unit() {
|
||||
cp -f "${HUB}/systemd/${OFFICES_UNIT}.service" "${HUB}/systemd/${OFFICES_UNIT}.path" "${UNITS}/"
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "${OFFICES_UNIT}.path"
|
||||
log "skills: ${OFFICES_UNIT}.path runs council.py for the office changes the operator signs in the Hierarchie view"
|
||||
log "skills: ${OFFICES_UNIT}.path runs council.py for the office changes the operator signs in the Hierarchy view"
|
||||
}
|
||||
|
||||
install_all() {
|
||||
@@ -338,7 +338,7 @@ check() {
|
||||
if [[ "$(systemctl --user is-active "${OFFICES_UNIT}.path" 2>/dev/null || true)" == "active" ]]; then
|
||||
ok "${OFFICES_UNIT}.path active"
|
||||
else
|
||||
todo "${OFFICES_UNIT}.path off: office changes from the Hierarchie view wait for make orchestrator-install"
|
||||
todo "${OFFICES_UNIT}.path off: office changes from the Hierarchy view wait for make orchestrator-install"
|
||||
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
|
||||
|
||||
@@ -65,8 +65,8 @@ proceed and never carries over. Findings and notes never stop a commit.
|
||||
|
||||
## Output
|
||||
|
||||
One line with the target, the project licence, and "Keine Rechtsberatung" (in
|
||||
the operator's language). Then one table, blockers first:
|
||||
One line with the target, the project licence, and "not legal advice" in the
|
||||
operator's language. Then one table, blockers first:
|
||||
|
||||
| Severity | Check | Location | What is wrong | Licence or law | Fix | Evidence |
|
||||
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
radio; everyone else leaves it alone.
|
||||
- After a restart or reboot, run `council.py list --mine` and claim again
|
||||
every office you believe you hold: `/tmp` may have been wiped.
|
||||
- Only the operator, through the Orchestrator's passkey-signed Hierarchie
|
||||
- Only the operator, through the Orchestrator's passkey-signed Hierarchy
|
||||
view, removes a live holder; agents have no override.
|
||||
+124
-124
@@ -36,7 +36,7 @@ WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||
TASK_VIEWS = ("list", "board", "rasci")
|
||||
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)]
|
||||
SUBS = ["Inbox", "Regeln", "Radio"]
|
||||
SUBS = ["Inbox", "Firewall", "Radio"]
|
||||
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
||||
HELPERS = """
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -56,7 +56,7 @@ HELPERS = """
|
||||
const name = (node) => (node ? node.getAttribute("aria-label") || text(node) : null);
|
||||
"""
|
||||
PROBE = """(async () => {@HELPERS@
|
||||
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Lade"), 8000);
|
||||
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Loading"), 8000);
|
||||
const badge = (button) => {
|
||||
const found = button.querySelector(".badge");
|
||||
return found && !found.hidden ? text(found) : null;
|
||||
@@ -73,9 +73,9 @@ PROBE = """(async () => {@HELPERS@
|
||||
badges: Object.fromEntries(all("#tabs button").map((button) => [button.id, badge(button)])),
|
||||
banner: banner.hidden ? null : text(banner),
|
||||
rail: Boolean(document.getElementById("rail")),
|
||||
views: all('#main [aria-label="Ansicht"] button').map(name),
|
||||
layouts: all('#main [aria-label="Anordnung"] button').map(name),
|
||||
chips: all('#main [aria-label="Kategorien"] button').map(name),
|
||||
views: all('#main [aria-label="View"] button').map(name),
|
||||
layouts: all('#main [aria-label="Layout"] button').map(name),
|
||||
chips: all('#main [aria-label="Categories"] button').map(name),
|
||||
menus: all("#main details.menu > summary").map(name),
|
||||
time: text(document.querySelector("#time > summary")),
|
||||
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
||||
@@ -87,7 +87,7 @@ PROBE = """(async () => {@HELPERS@
|
||||
unlabelled: icons.map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160)),
|
||||
broken: all("svg.bi use").filter((use) => !document.getElementById(use.getAttribute("href").slice(1))).map((use) => use.getAttribute("href")),
|
||||
states: all("#main span.state").map(name),
|
||||
deletes: all('#main button[aria-label="Löschen"], #main button[aria-label="Alle gemergten löschen"]').map((button) => [name(button), button.disabled, button.title]),
|
||||
deletes: all('#main button[aria-label="Delete"], #main button[aria-label="Delete all merged"]').map((button) => [name(button), button.disabled, button.title]),
|
||||
spawn: all("#main .spawn-tree li").map((item) => [item.dataset.kind, item.dataset.running || null]),
|
||||
marks: all("#main svg.chart .node").length,
|
||||
note: text(document.querySelector("#main .space-note")),
|
||||
@@ -95,7 +95,7 @@ PROBE = """(async () => {@HELPERS@
|
||||
org: all("#main .org-chart .org-row[data-office]").map((row) => [row.dataset.office, text(row.querySelector(".org-holder"))]),
|
||||
toggles: all("#main .org-toggle").map((button) => [name(button), button.getAttribute("aria-expanded")]),
|
||||
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
||||
taskFilters: all('#main [aria-label="Aufgabenfilter"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
|
||||
taskFilters: all('#main [aria-label="Task filters"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
|
||||
taskRows: all("#main .task-row").map((row) => [row.dataset.task, row.classList.contains("open-work")]),
|
||||
columns: all("#main .task-column").map((column) => [column.dataset.column, all(".task-card", column).map((card) => card.dataset.task)]),
|
||||
matrix: [all("#main table.matrix thead th").length, all("#main table.matrix tbody tr").length, all("#main table.matrix td .letter").map(text).join("")],
|
||||
@@ -111,7 +111,7 @@ ORG_OPEN = """(async () => {@HELPERS@
|
||||
if (!row) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
const toggle = all("#main .org-toggle").find((button) => name(button) === "Zuklappen: identity");
|
||||
const toggle = all("#main .org-toggle").find((button) => name(button) === "Collapse: identity");
|
||||
toggle.focus();
|
||||
return {focused: document.activeElement.dataset.focus, offices: all("#main .org-row[data-office]").map((entry) => entry.dataset.office)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
@@ -127,19 +127,19 @@ ORG_FOLDED = """(async () => {@HELPERS@
|
||||
ORDER_MOVED = """(async () => {@HELPERS@
|
||||
const after = all("#main .agent-order li").map((item) => item.dataset.name);
|
||||
const focused = document.activeElement.dataset.focus || null;
|
||||
const save = all("#main .agent-order button").find((button) => text(button) === "Reihenfolge speichern");
|
||||
const save = all("#main .agent-order button").find((button) => text(button) === "Save order");
|
||||
const disabled = save.disabled;
|
||||
save.click();
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => text(box).startsWith("Priorität")), 8000);
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => text(box).startsWith("Priority")), 8000);
|
||||
return {after, focused, disabled, toast: text(toast)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
OPEN_TAKEOVER = """(async () => {@HELPERS@
|
||||
document.querySelector('#main [data-focus="takeover:identity/oidc"]').click();
|
||||
await sleep(400);
|
||||
return {title: text(document.querySelector(".sheet-head .panel-title")), options: all(".sheet-box select option").map((option) => option.value), work: document.querySelector('.sheet-box input[aria-label="Arbeit"]').value, focused: document.activeElement.tagName};
|
||||
return {title: text(document.querySelector(".sheet-head .panel-title")), options: all(".sheet-box select option").map((option) => option.value), work: document.querySelector('.sheet-box input[aria-label="Work"]').value, focused: document.activeElement.tagName};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CONFIRM_TAKEOVER = """(async () => {@HELPERS@
|
||||
all(".sheet-box button").find((button) => name(button) === "Übergeben").click();
|
||||
all(".sheet-box button").find((button) => name(button) === "Hand over").click();
|
||||
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||||
return {result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
@@ -152,11 +152,11 @@ AFTER_TAKEOVER = """(async () => {@HELPERS@
|
||||
return {holder: text(row)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
INBOX_CHIP = """(async () => {@HELPERS@
|
||||
const chip = (id) => document.querySelector(`#main [aria-label="Aufgabenfilter"] button[data-filter="${id}"]`);
|
||||
const chip = (id) => document.querySelector(`#main [aria-label="Task filters"] button[data-filter="${id}"]`);
|
||||
chip("operator").click();
|
||||
await until(() => location.hash === "#stream/inbox" && document.querySelector("#main .inbox-list"), 4000);
|
||||
const inbox = {hash: location.hash, items: all("#main .inbox-list > li").length, counted: Number(text(chip("operator").querySelector(".count"))), pressed: chip("operator").getAttribute("aria-pressed"), current: all("#tabs button[aria-current=page]").map(name)};
|
||||
chip("freigabe").click();
|
||||
chip("grant").click();
|
||||
await until(() => location.hash.startsWith("#tasks/"), 4000);
|
||||
await sleep(300);
|
||||
return {inbox, back: {hash: location.hash, rows: all("#main .task-row").map((row) => [row.dataset.task, text(row.querySelector(".pill"))])}};
|
||||
@@ -166,13 +166,13 @@ PIN = """(async () => {@HELPERS@
|
||||
if (!row) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
row.querySelector('button[aria-label="Priorität"]').click();
|
||||
row.querySelector('button[aria-label="Priority"]').click();
|
||||
await sleep(400);
|
||||
const field = document.querySelector('.sheet-box input[aria-label="Priorität"]');
|
||||
const field = document.querySelector('.sheet-box input[aria-label="Priority"]');
|
||||
field.value = "2";
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
const preview = all(".sheet-box .task-edit .command code").map(text);
|
||||
all(".sheet-box button").find((button) => name(button) === "Anheften").click();
|
||||
all(".sheet-box button").find((button) => name(button) === "Pin").click();
|
||||
const done = await until(() => document.querySelector(".sheet-box [data-jobs]"), 20000);
|
||||
return {title: text(document.querySelector(".sheet-head .panel-title")), preview, jobs: done ? done.dataset.jobs : null, toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
@@ -186,7 +186,7 @@ ACTIONS = """(async () => {@HELPERS@
|
||||
document.getElementById("settings-open").click();
|
||||
await sleep(300);
|
||||
found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id);
|
||||
found.speech = all('.sheet-box [aria-label="Klassen"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||||
found.speech = all('.sheet-box [aria-label="Classes"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||||
found.settingsUnlabelled = all(".sheet-box svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
|
||||
document.querySelector(".sheet-head button").click();
|
||||
document.getElementById("palette-open").click();
|
||||
@@ -194,9 +194,9 @@ ACTIONS = """(async () => {@HELPERS@
|
||||
found.palette = all(".palette-list li > span:first-child").map(text);
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||
await sleep(200);
|
||||
document.querySelector('#main [aria-label="Kategorien"] button:nth-child(2)').click();
|
||||
document.querySelector('#main [aria-label="Categories"] button:nth-child(2)').click();
|
||||
await sleep(900);
|
||||
document.querySelector('#main button.linkish[title$="öffnen"]').click();
|
||||
document.querySelector('#main button.linkish[title^="Open "]').click();
|
||||
await sleep(500);
|
||||
found.drawer = all("#drawer input[type=radio]").map((input) => [input.value, input.checked]);
|
||||
found.drawerUnlabelled = all("#drawer svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
|
||||
@@ -217,8 +217,8 @@ PICK_3D = """(async () => {@HELPERS@
|
||||
return {panel: text(document.querySelector("#main aside .panel-title")), label: chip.textContent};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CALL = """(async () => {@HELPERS@
|
||||
document.querySelector('#main [aria-label="Kategorien"] button[aria-label="Funk"]').click();
|
||||
const someone = await until(() => document.querySelector('#main button.linkish[title$="öffnen"]'), 8000);
|
||||
document.querySelector('#main [aria-label="Categories"] button[aria-label="Radio"]').click();
|
||||
const someone = await until(() => document.querySelector('#main button.linkish[title^="Open "]'), 8000);
|
||||
someone.click();
|
||||
await sleep(500);
|
||||
document.querySelector('#drawer input[type=radio][value="call"]').click();
|
||||
@@ -226,7 +226,7 @@ CALL = """(async () => {@HELPERS@
|
||||
field.value = "E2E call ping";
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
document.querySelector('#drawer button[type="submit"]').click();
|
||||
const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Gelesen") && row.textContent.includes("E2E call ping")), 20000);
|
||||
const entry = await until(() => all("#main .entry").find((row) => row.textContent.includes("Read") && row.textContent.includes("E2E call ping")), 20000);
|
||||
return {to: text(someone), receipt: entry ? text(entry) : null, sent: all("#main .entry").filter((row) => row.textContent.includes("E2E call ping")).length};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
OPEN_DELETE = """(async () => {@HELPERS@
|
||||
@@ -236,22 +236,22 @@ OPEN_DELETE = """(async () => {@HELPERS@
|
||||
}
|
||||
card.open = true;
|
||||
await sleep(300);
|
||||
const every = document.querySelector('#main button[aria-label="Alle gemergten löschen"]');
|
||||
const buttons = all('button[aria-label="Löschen"]', card);
|
||||
const every = document.querySelector('#main button[aria-label="Delete all merged"]');
|
||||
const buttons = all('button[aria-label="Delete"]', card);
|
||||
const states = all("span.state", card).map(name);
|
||||
buttons.find((button) => !button.disabled).click();
|
||||
await sleep(500);
|
||||
return {states, every: [every.disabled, every.title], buttons: buttons.map((button) => [button.disabled, button.title]), title: text(document.querySelector(".sheet-head .panel-title")), dry: all('.sheet-box [aria-label="Trockenlauf"] pre').map(text)};
|
||||
return {states, every: [every.disabled, every.title], buttons: buttons.map((button) => [button.disabled, button.title]), title: text(document.querySelector(".sheet-head .panel-title")), dry: all('.sheet-box [aria-label="Dry run"] pre').map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CONFIRM = """(async () => {@HELPERS@
|
||||
all(".sheet-box button").find((button) => name(button) === "Löschen").click();
|
||||
all(".sheet-box button").find((button) => name(button) === "Delete").click();
|
||||
const done = await until(() => document.querySelector(".sheet-box [data-result]"), 30000);
|
||||
const job = document.querySelector(".sheet-box [data-cleanup]");
|
||||
return {job: job ? job.dataset.cleanup : null, result: done ? done.dataset.result : null, text: text(done), toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
AFTER = """(async () => {@HELPERS@
|
||||
document.querySelector(".sheet-head button").click();
|
||||
const past = await until(() => all("#main details.repo").find((details) => details.textContent.includes("Zuletzt gelöscht")), 15000);
|
||||
const past = await until(() => all("#main details.repo").find((details) => details.textContent.includes("Recently deleted")), 15000);
|
||||
return {past: past ? [past.querySelector("[data-result]").dataset.result, name(past.querySelector("span.state"))] : null, branches: all("#main details.repo").filter((details) => details !== past).flatMap((details) => all(".branch-name strong", details)).map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
OVERLAP = """
|
||||
@@ -291,12 +291,12 @@ RULES_OVERLAP = """(async () => {@HELPERS@@CLASHES@
|
||||
SETTINGS_OVERLAP = """(async () => {@HELPERS@@CLASHES@
|
||||
document.getElementById("settings-open").click();
|
||||
const settings = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
|
||||
all("button", settings).find((button) => text(button) === "+ Sender").click();
|
||||
all("button", settings).find((button) => text(button) === "+ Station").click();
|
||||
const theme = all("select", settings).find((select) => [...select.options].some((option) => option.value === "custom"));
|
||||
theme.value = "custom";
|
||||
theme.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
await sleep(250);
|
||||
const place = all("section", settings).find((section) => text(section.querySelector("h3")) === "Ort");
|
||||
const place = all("section", settings).find((section) => text(section.querySelector("h3")) === "Location");
|
||||
place.scrollIntoView({block: "start"});
|
||||
return clashes(settings);
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
|
||||
@@ -334,7 +334,7 @@ ADD_STATION = """(async () => {@HELPERS@
|
||||
const clock = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
document.getElementById("settings-open").click();
|
||||
const form = await until(() => document.querySelector(".sheet-box form.settings"), 8000);
|
||||
all("button", form).find((button) => text(button) === "+ Sender").click();
|
||||
all("button", form).find((button) => text(button) === "+ Station").click();
|
||||
await sleep(200);
|
||||
const row = all(".stream-row", form).pop();
|
||||
const [label, address, start, end] = all("input.field", row);
|
||||
@@ -350,10 +350,10 @@ ADD_STATION = """(async () => {@HELPERS@
|
||||
box.checked = true;
|
||||
});
|
||||
all("button", form).find((button) => button.type === "submit").click();
|
||||
await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Neuer Sender")), 8000);
|
||||
await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("New station")), 8000);
|
||||
return {toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
STATION_NAME = "E2E Ton mit einem sehr langen Sendernamen, der in keine Zeile passt"
|
||||
STATION_NAME = "E2E tone with a very long station name that fits on no single line"
|
||||
DLF_PATH = "/dlf/01/high/aac/stream.aac?aggregator=web"
|
||||
DLF_NODE = "/ard/dlf/01/aac/192/stream.aac?aggregator=web"
|
||||
RADIO_STATE = """(async () => {@HELPERS@@MEDIA@
|
||||
@@ -459,22 +459,22 @@ COMPOSE = """(async () => {@HELPERS@
|
||||
COMPOSE_SEND = """(async () => {@HELPERS@
|
||||
const form = document.querySelector("#main form.composer");
|
||||
const field = form.querySelector("textarea");
|
||||
field.value = `${field.value} bitte`;
|
||||
field.value = `${field.value} please`;
|
||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||
form.querySelector('.voice-note input[type="checkbox"]').checked = @ATTACH@;
|
||||
const before = all("#toasts .toast").length;
|
||||
form.querySelector('button[type="submit"]').click();
|
||||
const sent = await until(() => all("#toasts .toast").slice(before).map(text).find((toast) => toast.startsWith("Call an")), 20000);
|
||||
const sent = await until(() => all("#toasts .toast").slice(before).map(text).find((toast) => toast.startsWith("Call to")), 20000);
|
||||
return {sent, left: field.value, voice: Boolean(form.querySelector(".voice-note audio"))};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
CLEAR_MAYDAY = """(async () => {@HELPERS@
|
||||
window.confirm = () => true;
|
||||
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Mayday aufheben" && !node.disabled), 8000);
|
||||
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Cancel mayday" && !node.disabled), 8000);
|
||||
if (!button) {
|
||||
return {error: "no enabled Mayday aufheben button"};
|
||||
return {error: "no enabled Cancel mayday button"};
|
||||
}
|
||||
button.click();
|
||||
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Mayday aufheben")), 15000);
|
||||
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Cancel mayday")), 15000);
|
||||
return {done, toasts: all("#toasts .toast").map(text)};
|
||||
})()""".replace("@HELPERS@", HELPERS)
|
||||
|
||||
@@ -514,20 +514,20 @@ LABELS = """
|
||||
const line = () => text(document.querySelector("#main .console .console-status"));
|
||||
"""
|
||||
RUN_START = """(async () => {@HELPERS@@LABELS@
|
||||
const row = await until(() => rowOf("Zeile"), 8000);
|
||||
const row = await until(() => rowOf("Row"), 8000);
|
||||
if (!row) {
|
||||
return {error: text(document.getElementById("main"))};
|
||||
}
|
||||
press(row, "Ausführen");
|
||||
const toast = await until(() => all("#toasts .toast.wait").find((box) => box.textContent.includes("Ausführen")), 8000);
|
||||
press(row, "Run");
|
||||
const toast = await until(() => all("#toasts .toast.wait").find((box) => box.textContent.includes("Run")), 8000);
|
||||
const queued = await until(() => out() && line(), 15000);
|
||||
await until(() => out().textContent.includes("Zeile 60"), 20000);
|
||||
await until(() => out().textContent.includes("Row 60"), 20000);
|
||||
return {toast: text(toast), queued, running: line(), overflows: out().scrollHeight > out().clientHeight, followed: atEnd()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
RUN_HELD = """(async () => {@HELPERS@@LABELS@
|
||||
out().scrollTop = 0;
|
||||
await sleep(300);
|
||||
await until(() => out().textContent.includes("Zeile 80"), 20000);
|
||||
await until(() => out().textContent.includes("Row 80"), 20000);
|
||||
await sleep(300);
|
||||
return {held: out().scrollTop, running: line()};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
@@ -539,8 +539,8 @@ RUN_DONE = """(async () => {@HELPERS@@LABELS@
|
||||
return {done: line(), last: lines[lines.length - 1], lines: lines.length, followed: atEnd(), actions: all(".item-actions button", out().closest("li")).map(name)};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
LAYOUT = """(async () => {@HELPERS@@LABELS@
|
||||
await until(() => document.querySelector("#main .console") && rowOf("Regelwunsch"), 8000);
|
||||
const rule = rowOf("Regelwunsch");
|
||||
await until(() => document.querySelector("#main .console") && rowOf("Rule request"), 8000);
|
||||
const rule = rowOf("Rule request");
|
||||
return {
|
||||
kinds: all("#main .inbox-list > li").map((li) => text(li.querySelector(".pill"))),
|
||||
splits: all("#main .deny-split").map((group) => all("button", group).map(name)),
|
||||
@@ -549,14 +549,14 @@ LAYOUT = """(async () => {@HELPERS@@LABELS@
|
||||
fits: out().getBoundingClientRect().right <= window.innerWidth,
|
||||
dry: text(rule.querySelector(".dry-run-count")),
|
||||
fields: all(".rule-request dt", rule).map(text),
|
||||
accept: all("button", rule).filter((button) => name(button) === "Annehmen").length,
|
||||
accept: all("button", rule).filter((button) => name(button) === "Accept").length,
|
||||
unlabelled: unlabelled(document),
|
||||
wide: wide(),
|
||||
};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
DENY_SHEET = """(async () => {@HELPERS@@LABELS@
|
||||
const row = await until(() => rowOf("git push"), 8000);
|
||||
press(row, "Ablehnen über …");
|
||||
press(row, "Deny via …");
|
||||
const box = await until(() => document.querySelector(".sheet-box"), 4000);
|
||||
return {
|
||||
title: text(box.querySelector(".panel-title")),
|
||||
@@ -569,24 +569,24 @@ DENY_SHEET = """(async () => {@HELPERS@@LABELS@
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
DENY = """(async () => {@HELPERS@@LABELS@
|
||||
const row = await until(() => rowOf("Bash: make deploy"), 8000);
|
||||
press(row, "Ablehnen über …");
|
||||
press(row, "Deny via …");
|
||||
const box = await until(() => document.querySelector(".sheet-box"), 4000);
|
||||
box.querySelector('input[value="call"]').click();
|
||||
box.querySelector("textarea").value = "erst das Gate grün machen";
|
||||
press(box.querySelector(".actions"), "Ablehnen");
|
||||
const undo = await until(() => all("#toasts .toast.wait").find((toast) => toast.textContent.includes("Ablehnung")), 8000);
|
||||
box.querySelector("textarea").value = "make the gate green first";
|
||||
press(box.querySelector(".actions"), "Deny");
|
||||
const undo = await until(() => all("#toasts .toast.wait").find((toast) => toast.textContent.includes("Denial")), 8000);
|
||||
return {undo: text(undo), sheet: !document.getElementById("sheet").hidden};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
DENIED = """(async () => {@HELPERS@@LABELS@
|
||||
return {gone: Boolean(await until(() => !rowOf("Bash: make deploy"), 20000))};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
ACCEPT = """(async () => {@HELPERS@@LABELS@
|
||||
press(rowOf("Regelwunsch"), "Annehmen");
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => box.textContent.includes("Angelegt: Regel")), 15000);
|
||||
await until(() => !rowOf("Regelwunsch"), 8000);
|
||||
return {toast: text(toast), left: Boolean(rowOf("Regelwunsch"))};
|
||||
press(rowOf("Rule request"), "Accept");
|
||||
const toast = await until(() => all("#toasts .toast").find((box) => box.textContent.includes("Created: Rule")), 15000);
|
||||
await until(() => !rowOf("Rule request"), 8000);
|
||||
return {toast: text(toast), left: Boolean(rowOf("Rule request"))};
|
||||
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
|
||||
SLOW = "seq -f 'Zeile %g' 1 60; sleep 4; seq -f 'Zeile %g' 61 80; sleep 4; echo fertig"
|
||||
SLOW = "seq -f 'Row %g' 1 60; sleep 4; seq -f 'Row %g' 61 80; sleep 4; echo finished"
|
||||
|
||||
|
||||
def labelled(probe: dict) -> dict:
|
||||
@@ -609,12 +609,12 @@ class TestOrchestratorE2E(HubCase):
|
||||
"rasci.json": {
|
||||
"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}},
|
||||
"work/feature": {
|
||||
"title": "Merge-Flow im Hub zeigen", "status": "running", "worker": "Skills session", "objective": "Der Operator sieht, wohin ein Branch fließt", "workdir": feature, "prio": 1,
|
||||
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow im Tree", "met": True}, {"text": "Flow in Tasks", "met": False}],
|
||||
"title": "Show the merge flow in the hub", "status": "running", "worker": "Skills session", "objective": "The operator sees where a branch flows", "workdir": feature, "prio": 1,
|
||||
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow in the tree", "met": True}, {"text": "Flow in Tasks", "met": False}],
|
||||
"estimates": {"Skills": {"value": 8, "complexity": 3, "at": iso(now - 600)}},
|
||||
},
|
||||
"work/docs": {"title": "README nachziehen", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||||
"work/old": {"title": "Alte Linie", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}},
|
||||
"work/docs": {"title": "Update the README", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
|
||||
"work/old": {"title": "Old line", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}},
|
||||
"operator/push": {"title": "git push", "workdir": "/repo", "status": "done", "roles": {"Operator": "R"}},
|
||||
},
|
||||
"queue.json": {"seq": 3, "entries": [{"id": "gate-3", "kind": "gate", "task": "work/feature", "workdir": feature, "session": "s1", "name": "Skills", "status": "waiting", "enqueued": now - 120, "cmd": "make test"}]},
|
||||
@@ -679,7 +679,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
def synthesize(body: bytes) -> bytes:
|
||||
word = json.loads(body)["text"]
|
||||
if word in cues:
|
||||
row = {"ts": time.time(), "from": "Stage", "to": [], "text": f"Platte fast voll ({word})\nzweite Zeile", "class": "security", "v": 2}
|
||||
row = {"ts": time.time(), "from": "Stage", "to": [], "text": f"Disk almost full ({word})\nsecond line", "class": "security", "v": 2}
|
||||
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
|
||||
handle.write(self.jsonl([row]))
|
||||
return spoken
|
||||
@@ -731,7 +731,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
steps.append({"width": width, "height": height, "url": None, "wait": 0, "probe": SETTINGS_OVERLAP, "shot": str(OUT / f"{browser}-{width}-settings-form.png")})
|
||||
data = self.drive(steps, None, driver, binary)
|
||||
(OUT / f"{browser}-overlap.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
hidden = {"decision": ("Werkzeug (Glob)", "Verzeichnis (Glob)"), "review": ("Werkzeug (Glob)",), "ask": ()}
|
||||
hidden = {"decision": ("Tool (glob)", "Directory (glob)"), "review": ("Tool (glob)",), "ask": ()}
|
||||
for index, (width, _) in enumerate(WIDTHS):
|
||||
rules, settings = data["results"][2 * index : 2 * index + 2]
|
||||
with self.subTest(browser=browser, width=width):
|
||||
@@ -740,7 +740,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
for kind, found in rules.items():
|
||||
self.assertEqual((found["found"], found["outside"]), ([], []), kind)
|
||||
self.assertEqual([label for label in hidden[kind] if label in found["labels"]], [], kind)
|
||||
self.assertIn("Verzeichnis (Glob)", rules["review"]["labels"])
|
||||
self.assertIn("Directory (glob)", rules["review"]["labels"])
|
||||
self.assertEqual(settings, {"found": [], "outside": []})
|
||||
|
||||
def check_radio(self, browser: str, driver: str, binary: str, redirected: bool = False) -> dict:
|
||||
@@ -770,7 +770,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
(OUT / f"{browser}-radio.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
saved, view, click = data["results"]
|
||||
if signs:
|
||||
self.assertTrue(any(toast.startswith("Neuer Sender") for toast in saved["toasts"]), saved)
|
||||
self.assertTrue(any(toast.startswith("New station") for toast in saved["toasts"]), saved)
|
||||
self.assertNotIn("error", view)
|
||||
self.assertEqual(view["title"], STATION_NAME)
|
||||
self.assertEqual(view["line"], ["nowrap", "ellipsis", True, True])
|
||||
@@ -778,8 +778,8 @@ class TestOrchestratorE2E(HubCase):
|
||||
if not view["media"]["playing"]:
|
||||
self.assertTrue(blocked, view)
|
||||
self.assertEqual(view["state"], "blocked", view)
|
||||
self.assertIn("Klick", view["note"])
|
||||
self.assertTrue(any("blockiert" in toast for toast in view["toasts"]), view["toasts"])
|
||||
self.assertIn("Click", view["note"])
|
||||
self.assertTrue(any("blocked" in toast for toast in view["toasts"]), view["toasts"])
|
||||
self.assertTrue(click["clicked"], click)
|
||||
self.assertNotIn("error", click)
|
||||
self.assertTrue(click["media"]["playing"], click)
|
||||
@@ -815,7 +815,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "branch:"), "shot": self.shot("1366-artifacts-3d-picked")})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#agents/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "s2"), "shot": self.shot("1366-agents-3d-picked")})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-freigabe")})
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-grant")})
|
||||
data = self.drive(steps, None)
|
||||
(OUT / "chromium-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
@@ -836,43 +836,43 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS))
|
||||
by_hash = {probe["hash"]: probe for probe in probes}
|
||||
stream = by_hash["#stream"]
|
||||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
||||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["For you", "Radio", "Work", "Transcripts"]] * len(WIDTHS))
|
||||
self.assertEqual((stream["menus"], stream["custom"]), (["Filter"], True))
|
||||
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
||||
for tab, own in (("agents", ["Hierarchie"]), ("artifacts", [])):
|
||||
for tab, own in (("agents", ["Hierarchy"]), ("artifacts", [])):
|
||||
with self.subTest(tab=tab):
|
||||
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D", *own])
|
||||
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Kraft", "Zeit", "Spawn-Tiefe"])
|
||||
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Force", "Time", "Spawn depth"])
|
||||
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ Knoten · \d+ Kanten · Kraft$")
|
||||
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
|
||||
agents = by_hash["#agents/orbit"]
|
||||
self.assertEqual(agents["toolbar"], ["Funk an alle", "Mayday aufheben", "Priorität", "Tag/Nacht"])
|
||||
self.assertEqual(agents["toolbar"], ["Radio to all", "Cancel mayday", "Priority", "Day/Night"])
|
||||
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
||||
hierarchy = by_hash["#agents/hierarchy"]
|
||||
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
||||
self.assertEqual(hierarchy["toggles"], [["Zuklappen: Operator", "true"], ["Zuklappen: Chair", "true"], ["Zuklappen: identity", "true"], ["Zuklappen: Präsidenten", "true"]])
|
||||
self.assertEqual(hierarchy["toggles"], [["Collapse: Operator", "true"], ["Collapse: Chair", "true"], ["Collapse: identity", "true"], ["Collapse: Presidents", "true"]])
|
||||
self.assertEqual(hierarchy["toolbar"], agents["toolbar"])
|
||||
self.assertGreaterEqual(len(hierarchy["order"]), 2)
|
||||
self.assertEqual(by_hash["#agents/tree"]["spawn"][0], ["operator", None])
|
||||
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
||||
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
|
||||
artifacts = by_hash["#artifacts/tree"]
|
||||
self.assertEqual(artifacts["deletes"], [["Alle gemergten löschen", True, "Alle gemergten löschen gesperrt: Secret Service locked"], ["Löschen", True, "Löschen gesperrt: Secret Service locked"]])
|
||||
for state in ("gemergt durch #12 in main", "Upstream weg", "geschützt", "1 Commits ungepusht", "sauber", "nicht committet: 0 gestaged, 0 geändert, 1 neu", "gc überspringt: ignored files beyond known caches: notes.txt"):
|
||||
self.assertEqual(artifacts["deletes"], [["Delete all merged", True, "Delete all merged blocked: Secret Service locked"], ["Delete", True, "Delete blocked: Secret Service locked"]])
|
||||
for state in ("merged by #12 into main", "upstream gone", "protected", "1 commits unpushed", "clean", "not committed: 0 staged, 0 changed, 1 new", "gc skips: ignored files beyond known caches: notes.txt"):
|
||||
with self.subTest(state=state):
|
||||
self.assertIn(state, artifacts["states"])
|
||||
for view in ("orbit", "chord"):
|
||||
with self.subTest(view=view):
|
||||
self.assertGreaterEqual(by_hash[f"#artifacts/{view}"]["marks"], 4)
|
||||
self.assertEqual((artifacts["flows"], artifacts["openWork"]), (["file", "invalid"], 5))
|
||||
for state in ("offene Arbeit: nicht committet, Aufgabe offen", "offene Arbeit: ungepusht, Aufgabe offen", "offene Arbeit: nicht gemergt", "3 Worktrees mit offener Arbeit"):
|
||||
for state in ("open work: not committed, task open", "open work: unpushed, task open", "open work: not merged", "3 worktrees with open work"):
|
||||
with self.subTest(state=state):
|
||||
self.assertIn(state, artifacts["states"])
|
||||
self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"])
|
||||
order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"]
|
||||
listed = by_hash["#tasks/list"]
|
||||
self.assertEqual(listed["views"], ["Liste", "Board", "RASCI"])
|
||||
self.assertEqual(listed["taskFilters"], [["alle", "true", "6"], ["offen", "false", "4"], ["arbeit", "false", "4"], ["operator", "false", "2"], ["freigabe", "false", "1"], ["beendet", "false", "2"]])
|
||||
self.assertEqual(listed["views"], ["List", "Board", "RASCI"])
|
||||
self.assertEqual(listed["taskFilters"], [["all", "true", "6"], ["open", "false", "4"], ["work", "false", "4"], ["operator", "false", "2"], ["grant", "false", "1"], ["ended", "false", "2"]])
|
||||
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
|
||||
self.assertEqual(listed["flows"], ["file", "file"])
|
||||
self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["blocked", ["work/docs"]], ["done", ["work/old", "operator/push"]], ["stopped", []]])
|
||||
@@ -885,19 +885,19 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"])
|
||||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
||||
self.assertEqual((actions["settingsUnlabelled"], actions["drawerUnlabelled"]), (0, 0))
|
||||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "SECURITE"])
|
||||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
|
||||
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
|
||||
for tool in ("Funk an alle …", "Mayday aufheben", "Priorität …", "Abend", "Regeln …", "Einstellungen …", "Himmel ansehen", "Leitstand umschalten", "Benachrichtigungen aktivieren"):
|
||||
for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Evening", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"):
|
||||
with self.subTest(tool=tool):
|
||||
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
|
||||
self.assertEqual(actions["drawer"], [["post", True], ["call", False]])
|
||||
artifact_pick, agent_pick, calm, chips = extra[4:]
|
||||
self.assertEqual(artifact_pick["panel"], artifact_pick["label"])
|
||||
self.assertEqual(agent_pick["panel"], agent_pick["label"])
|
||||
self.assertRegex(calm["note"], r"Kraft · ohne Bewegung \(prefers-reduced-motion\)$")
|
||||
self.assertRegex(calm["note"], r"Force · without motion \(prefers-reduced-motion\)$")
|
||||
self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]})
|
||||
self.assertGreaterEqual(chips["inbox"]["items"], 1)
|
||||
self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue-Job"]]})
|
||||
self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue job"]]})
|
||||
|
||||
def test_chromium_sends_a_call_from_the_agent_panel_and_shows_its_receipt(self):
|
||||
handled = []
|
||||
@@ -918,7 +918,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
result = data["results"][0]
|
||||
self.assertNotIn("error", result)
|
||||
self.assertIsNotNone(result["receipt"], result)
|
||||
self.assertIn("Gelesen", result["receipt"])
|
||||
self.assertIn("Read", result["receipt"])
|
||||
|
||||
def test_chromium_shows_who_speaks_while_a_message_is_read(self):
|
||||
self.speaks(("e2e-cue-1", "e2e-cue-2"))
|
||||
@@ -932,11 +932,11 @@ class TestOrchestratorE2E(HubCase):
|
||||
(OUT / "chromium-speaking.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
self.assertEqual(data["errors"], [])
|
||||
speaking, graph, ended, calm = data["results"]
|
||||
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-1)", "s2", "speaking"), speaking)
|
||||
self.assertEqual((speaking["who"], speaking["line"], speaking["chip"], speaking["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-1)", "s2", "speaking"), speaking)
|
||||
self.assertEqual((graph["node"] or "").split(",")[0], "Stage", graph)
|
||||
self.assertEqual(graph["ring"], "speaking-node")
|
||||
self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0})
|
||||
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage spricht", "Platte fast voll (e2e-cue-2)", "none"), calm)
|
||||
self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm)
|
||||
|
||||
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
|
||||
local = f"http://localhost:{self.port}/"
|
||||
@@ -956,18 +956,18 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(composed["recording"], "true")
|
||||
self.assertEqual(composed["text"], "hello from the mic second line", composed)
|
||||
self.assertRegex(composed["replay"] or "", r"^/api/voice/[0-9a-f]{16}\.wav$")
|
||||
self.assertEqual(composed["attach"], ["Aufnahme anhängen", False])
|
||||
self.assertEqual(composed["attach"], ["Attach recording", False])
|
||||
self.assertEqual(composed["clashes"], {"found": [], "outside": []})
|
||||
self.assertEqual(again["attach"], ["Aufnahme anhängen", False])
|
||||
self.assertEqual(again["attach"], ["Attach recording", False])
|
||||
note = again["replay"].removeprefix("/api/voice/").removesuffix(".wav")
|
||||
for result in (plain, sent):
|
||||
self.assertEqual((result["left"], result["voice"]), ("", False), result)
|
||||
self.assertTrue(result["sent"].startswith("Call an Stage: gesendet"), result)
|
||||
self.assertTrue(result["sent"].startswith("Call to Stage: sent"), result)
|
||||
calls = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
|
||||
self.assertEqual(len(calls), 2, self.scripts.calls)
|
||||
self.assertEqual([call[:4] for call in calls], [["send", "call", "--to", "Stage"]] * 2)
|
||||
self.assertTrue(calls[0][-1].endswith("] hello from the mic second line bitte"), calls[0])
|
||||
self.assertTrue(calls[1][-1].endswith(f"] hello from the mic second line bitte voice: {HOST_STATE}/voice/{note}.wav"), calls[1])
|
||||
self.assertTrue(calls[0][-1].endswith("] hello from the mic second line please"), calls[0])
|
||||
self.assertTrue(calls[1][-1].endswith(f"] hello from the mic second line please voice: {HOST_STATE}/voice/{note}.wav"), calls[1])
|
||||
status, _, body = self.get(f"/api/voice/{note}.wav")
|
||||
self.assertEqual((status, body[:4]), (200, b"RIFF"))
|
||||
|
||||
@@ -977,7 +977,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(data["errors"], [])
|
||||
result = data["results"][0]
|
||||
self.assertNotIn("error", result)
|
||||
self.assertTrue((result["done"] or "").startswith("Mayday aufheben: erledigt"), result)
|
||||
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
|
||||
|
||||
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
||||
@@ -1011,15 +1011,15 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(data["errors"], [])
|
||||
opened, confirmed, after = data["results"]
|
||||
self.assertNotIn("error", opened)
|
||||
self.assertEqual(opened["every"], [False, "Alle gemergten löschen: 1 Branches nach Trockenlauf und Signatur"])
|
||||
self.assertEqual(opened["buttons"], [[False, "Löschen: feature/done lokal entfernen, nach Trockenlauf und Signatur"]])
|
||||
self.assertEqual(opened["title"], "feature/done löschen")
|
||||
self.assertEqual(opened["every"], [False, "Delete all merged: 1 branches after dry run and signature"])
|
||||
self.assertEqual(opened["buttons"], [[False, "Delete: remove feature/done locally, after dry run and signature"]])
|
||||
self.assertEqual(opened["title"], "Delete feature/done")
|
||||
self.assertEqual(len(opened["dry"]), 1)
|
||||
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, gemergt durch #12"), opened["dry"])
|
||||
self.assertIn("bleibt: remote-branch (nur lokal)", opened["dry"][0])
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donegelöscht"), confirmed)
|
||||
self.assertEqual(confirmed["toasts"], ["Löschen: erledigtfeature/done: gelöscht"])
|
||||
self.assertEqual(after["past"], ["done", "gelöscht"])
|
||||
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12"), opened["dry"])
|
||||
self.assertIn("stays: remote-branch (local only)", opened["dry"][0])
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donedeleted"), confirmed)
|
||||
self.assertEqual(confirmed["toasts"], ["Delete: donefeature/done: deleted"])
|
||||
self.assertEqual(after["past"], ["done", "deleted"])
|
||||
self.assertNotIn("feature/done", after["branches"])
|
||||
written = [json.loads(path.read_text(encoding="utf-8")) for path in results.glob("*.json")]
|
||||
self.assertEqual([[(item["branch"], item["status"]) for item in result["items"]] for result in written], [[("feature/done", "done")]])
|
||||
@@ -1053,16 +1053,16 @@ class TestOrchestratorE2E(HubCase):
|
||||
opened, folded, moved, sheet, confirmed, after = data["results"]
|
||||
self.assertNotIn("error", opened)
|
||||
self.assertEqual(opened["focused"], "toggle:dimension:identity")
|
||||
self.assertEqual((folded["focused"], folded["expanded"], folded["label"], folded["hidden"], folded["again"]), ("toggle:dimension:identity", "false", "Aufklappen: identity", True, "true"))
|
||||
self.assertEqual((folded["focused"], folded["expanded"], folded["label"], folded["hidden"], folded["again"]), ("toggle:dimension:identity", "false", "Expand: identity", True, "true"))
|
||||
self.assertNotIn("identity/oidc", folded["seen"])
|
||||
before = folded["before"]
|
||||
self.assertEqual(moved["after"], [before[1], before[0], *before[2:]])
|
||||
self.assertEqual((moved["focused"], moved["disabled"]), (f"down:{before[1]}", False))
|
||||
self.assertTrue(moved["toast"].startswith("Priorität"), moved)
|
||||
self.assertTrue(moved["toast"].startswith("Priority"), moved)
|
||||
self.assertIn(("rasci.py", ["agents", "order", ",".join(moved["after"])]), [(Path(script).name, args) for script, args in self.scripts.calls])
|
||||
self.assertEqual((sheet["title"], sheet["options"], sheet["work"], sheet["focused"]), ("identity/oidc: Übergeben", ["s1"], "oidc line", "SELECT"))
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "identity/oidcerledigt"), confirmed)
|
||||
self.assertIn("Übergeben: erledigtidentity/oidc: erledigt", confirmed["toasts"])
|
||||
self.assertEqual((sheet["title"], sheet["options"], sheet["work"], sheet["focused"]), ("identity/oidc: Hand over", ["s1"], "oidc line", "SELECT"))
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "identity/oidcdone"), confirmed)
|
||||
self.assertIn("Hand over: doneidentity/oidc: done", confirmed["toasts"])
|
||||
self.assertNotIn("error", after)
|
||||
claim = json.loads((self.council / "identity" / "oidc" / "claim.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual((claim["session"], claim["work"]), ("s1", "oidc line"))
|
||||
@@ -1071,7 +1071,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
|
||||
def inbox_work(self) -> None:
|
||||
stage = {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}
|
||||
greet = {"title": SLOW, "workdir": str(self.outside), "description": "zählt", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||||
greet = {"title": SLOW, "workdir": str(self.outside), "description": "counts", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||||
push = {"title": "git push", "workdir": "/repo", "description": "ship", "check": "manual", "status": "queued", "roles": {"Operator": "R", "Skills session": "A"}}
|
||||
self.write_council({"rasci.json": {"work/stage": stage, "operator/greet": greet, "operator/push": push}})
|
||||
arguments = {"kind": "ask", "tool": "Bash", "pattern": "make deploy", "scope": "/repo*", "expires": int(time.time()) + 7 * 86400, "agent": "Skills", "why": "deploy runs after every green gate"}
|
||||
@@ -1079,11 +1079,11 @@ class TestOrchestratorE2E(HubCase):
|
||||
|
||||
def layout(self, probe: dict) -> None:
|
||||
self.assertNotIn("error", probe)
|
||||
self.assertEqual(sorted(probe["kinds"]), ["Ask", "Operator", "Operator", "Regelwunsch", "Review"])
|
||||
self.assertIn(["Ablehnen", "Ablehnen über …"], probe["splits"])
|
||||
self.assertEqual(sorted(probe["kinds"]), ["Ask", "Operator", "Operator", "Review", "Rule request"])
|
||||
self.assertIn(["Deny", "Deny via …"], probe["splits"])
|
||||
self.assertEqual(len(probe["splits"]), 4)
|
||||
self.assertEqual(probe["fields"], ["Art", "Werkzeug (Glob)", "Muster (Glob)", "Verzeichnis (Glob)", "Läuft ab am"])
|
||||
self.assertEqual(probe["dry"], "Trockenlauf: Die Regel hätte 1 von 1 Asks der letzten 7 Tage freigegeben.")
|
||||
self.assertEqual(probe["fields"], ["Kind", "Tool (glob)", "Pattern (glob)", "Directory (glob)", "Expires on"])
|
||||
self.assertEqual(probe["dry"], "Dry run: the rule would have granted 1 of 1 asks from the last 7 days.")
|
||||
self.assertEqual(probe["accept"], 1)
|
||||
self.assertTrue(probe["fits"] and probe["followed"], probe)
|
||||
self.assertEqual((probe["unlabelled"], probe["wide"]), ([], False))
|
||||
@@ -1120,32 +1120,32 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(data["errors"], [])
|
||||
started, held, done, *rest = data["results"]
|
||||
self.assertNotIn("error", started)
|
||||
self.assertIn("wird in", started["toast"])
|
||||
self.assertTrue(started["running"].startswith("läuft"), started)
|
||||
self.assertIn("takes effect in", started["toast"])
|
||||
self.assertTrue(started["running"].startswith("running"), started)
|
||||
self.assertTrue(started["overflows"] and started["followed"], started)
|
||||
self.assertLess(held["held"], 50, held)
|
||||
self.assertEqual((done["last"], done["lines"], done["followed"]), ("fertig", 81, True))
|
||||
self.assertRegex(done["done"], r"^erledigtExit-Code 0 nach \d+ s$")
|
||||
self.assertIn("Schließen", done["actions"])
|
||||
self.assertEqual((done["last"], done["lines"], done["followed"]), ("finished", 81, True))
|
||||
self.assertRegex(done["done"], r"^doneExit code 0 after \d+ s$")
|
||||
self.assertIn("Close", done["actions"])
|
||||
layouts, sheets = rest[0:6:2], rest[1:6:2]
|
||||
for probe, sheet, (width, _) in zip(layouts, sheets, WIDTHS, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.layout(probe)
|
||||
self.assertTrue(probe["console"].startswith("erledigtExit-Code 0"), probe)
|
||||
self.assertEqual(sheet["title"], "Ablehnen")
|
||||
self.assertTrue(probe["console"].startswith("doneExit code 0"), probe)
|
||||
self.assertEqual(sheet["title"], "Deny")
|
||||
self.assertEqual(sheet["channels"], [["post", True], ["call", False], ["newsletter", False], ["broadcast", False], ["pan-pan", False], ["security", False]])
|
||||
self.assertEqual(sheet["hints"][:3], ["an Skills, sobald sein Zug endet", "an Skills, sofort, weckt ihn", "an alle, sobald ihr Zug endet"])
|
||||
self.assertEqual(sheet["hints"][:3], ["to Skills, when its turn ends", "to Skills, at once, wakes it", "to all, when their turn ends"])
|
||||
self.assertEqual((sheet["reason"], sheet["unlabelled"], sheet["wide"]), (True, [], False))
|
||||
denied, gone, accepted = rest[6:]
|
||||
self.assertTrue(denied["undo"].startswith("Ablehnung (Call): Bash: make deploy"), denied)
|
||||
self.assertTrue(denied["undo"].startswith("Denial (Call): Bash: make deploy"), denied)
|
||||
self.assertFalse(denied["sheet"])
|
||||
self.assertTrue(gone["gone"])
|
||||
self.assertEqual((accepted["toast"], accepted["left"]), ("Angelegt: Regel: ask Bash make deploy in /repo*", False))
|
||||
self.assertEqual((accepted["toast"], accepted["left"]), ("Created: Rule: ask Bash make deploy in /repo*", False))
|
||||
ask = self.httpd.hub.asks.listed()[0]
|
||||
self.assertEqual((ask["status"], ask["reason"], ask["channel"]), ("denied", "erst das Gate grün machen", "call"))
|
||||
self.assertEqual((ask["status"], ask["reason"], ask["channel"]), ("denied", "make the gate green first", "call"))
|
||||
calls = [args for script, args in self.scripts.calls if script.endswith("radio.py")]
|
||||
self.assertEqual(calls[-1][:4], ["send", "call", "--to", "Skills session"])
|
||||
self.assertTrue(calls[-1][-1].endswith("Reason: erst das Gate grün machen"), calls[-1])
|
||||
self.assertTrue(calls[-1][-1].endswith("Reason: make the gate green first"), calls[-1])
|
||||
self.assertEqual([(rule["kind"], rule["pattern"], rule["proof"] is not None) for rule in self.httpd.hub.rules.listed()], [("ask", "make deploy", True)])
|
||||
self.assertEqual([(job["status"], job["code"]) for job in self.httpd.hub.jobs.listed()], [("done", 0)])
|
||||
|
||||
@@ -1154,7 +1154,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.inbox_work()
|
||||
now = time.time()
|
||||
fields = {"task": "operator/greet", "command": SLOW, "directory": str(self.outside), "sha256": "", "status": "done", "proof": None}
|
||||
output = "".join(f"Zeile {number}\n" for number in range(1, 81)) + "fertig\n"
|
||||
output = "".join(f"Row {number}\n" for number in range(1, 81)) + "finished\n"
|
||||
self.httpd.hub.jobs.add({**fields, "code": 0, "output": output, "created_ts": now - 20, "started": now - 19, "finished": now - 10, "dismissed": False})
|
||||
shots = [(width, height, OUT / f"firefox-{width}-inbox-console.png") for width, height in WIDTHS]
|
||||
steps = [{"width": width, "height": height, "url": f"{self.base}#stream/inbox", "wait": 1200, "probe": LAYOUT, "shot": str(shot)} for width, height, shot in shots]
|
||||
@@ -1164,7 +1164,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
for probe, (width, _, shot) in zip(data["results"], shots, strict=True):
|
||||
with self.subTest(width=width):
|
||||
self.layout(probe)
|
||||
self.assertEqual(probe["console"], "erledigtExit-Code 0 nach 9 s")
|
||||
self.assertEqual(probe["console"], "doneExit code 0 after 9 s")
|
||||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||||
|
||||
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
|
||||
@@ -1178,7 +1178,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
pinned = data["results"][0]
|
||||
self.assertNotIn("error", pinned)
|
||||
self.assertEqual((pinned["title"], pinned["preview"][0]), ("work/docs", "rasci.py set work/docs --prio 2"))
|
||||
self.assertEqual(pinned["toasts"], ["Priorität work/docs: an den Runner übergebenrasci.py set work/docs --prio 2"])
|
||||
self.assertEqual(pinned["toasts"], ["Priority work/docs: handed to the runnerrasci.py set work/docs --prio 2"])
|
||||
jobs = self.runner_jobs()
|
||||
self.assertEqual([(job["id"], job["kind"], job["argv"]) for job in jobs], [(pinned["jobs"], "task", ["set", "work/docs", "--prio", "2"])])
|
||||
self.run_runner()
|
||||
|
||||
@@ -59,9 +59,9 @@ HOOK = {
|
||||
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, "via": []},
|
||||
{"name": "Nacht", "url": "http://10.0.0.5:8000/nacht", "from": "22:00", "to": "02:00", "days": [5], "on": True, "via": []},
|
||||
{"name": "Aus", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False, "via": []},
|
||||
{"name": "Morning", "url": "https://radio.example/morning", "from": "07:00", "to": "09:00", "days": [0, 1, 2, 3, 4], "on": True, "via": []},
|
||||
{"name": "Night", "url": "http://10.0.0.5:8000/night", "from": "22:00", "to": "02:00", "days": [5], "on": True, "via": []},
|
||||
{"name": "Off", "url": "https://other.example:8443/", "from": "00:00", "to": "24:00", "days": [0, 1, 2, 3, 4, 5, 6], "on": False, "via": []},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+10
-10
@@ -34,28 +34,28 @@ class TestCoach(unittest.TestCase):
|
||||
return {row["area"]: row for row in json.loads(result.stdout)}
|
||||
|
||||
def test_a_dislike_replaces_a_like_and_forget_drops_it(self):
|
||||
self._run("like", "body", "Laufen")
|
||||
self._run("like", "body", "Laufen")
|
||||
self._run("like", "body", "Running")
|
||||
self._run("like", "body", "Running")
|
||||
self._run("like", "body", "Yoga")
|
||||
self._run("dislike", "body", "Laufen")
|
||||
self._run("dislike", "body", "Running")
|
||||
body = self._show("2026-11-03T18:00:00+00:00")["body"]
|
||||
self.assertEqual(body["likes"], ["Yoga"])
|
||||
self.assertEqual(body["dislikes"], ["Laufen"])
|
||||
self._run("forget", "body", "Laufen")
|
||||
self.assertEqual(body["dislikes"], ["Running"])
|
||||
self._run("forget", "body", "Running")
|
||||
self.assertEqual(self._show("2026-11-03T18:00:00+00:00")["body"]["dislikes"], [])
|
||||
|
||||
def test_the_latest_done_per_area_gives_the_hours_since(self):
|
||||
self._run("done", "food", "Frühstück", "--at", "2026-11-03T08:00:00+00:00")
|
||||
self._run("done", "food", "Mittagessen", "--at", "2026-11-03T12:30:00+00:00")
|
||||
self._run("done", "sleep", "Nacht", "--at", "2026-11-02T23:00:00+00:00")
|
||||
self._run("done", "food", "Breakfast", "--at", "2026-11-03T08:00:00+00:00")
|
||||
self._run("done", "food", "Lunch", "--at", "2026-11-03T12:30:00+00:00")
|
||||
self._run("done", "sleep", "Night", "--at", "2026-11-02T23:00:00+00:00")
|
||||
rows = self._show("2026-11-03T18:00:00+00:00")
|
||||
self.assertEqual((rows["food"]["last"], rows["food"]["hours_ago"]), ("Mittagessen", 5.5))
|
||||
self.assertEqual((rows["food"]["last"], rows["food"]["hours_ago"]), ("Lunch", 5.5))
|
||||
self.assertEqual(rows["sleep"]["hours_ago"], 19.0)
|
||||
self.assertIsNone(rows["psyche"]["last"])
|
||||
|
||||
def test_invalid_input_exits_2_and_keeps_the_profile(self):
|
||||
self.assertEqual(self._run("like", "work", "x").returncode, 2)
|
||||
self.assertEqual(self._run("done", "body", "x", "--at", "gestern").returncode, 2)
|
||||
self.assertEqual(self._run("done", "body", "x", "--at", "yesterday").returncode, 2)
|
||||
self.assertFalse(self.profile.exists())
|
||||
self.profile.parent.mkdir(parents=True)
|
||||
self.profile.write_text("{broken", encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pin the repository to English: no German text outside the operator's quoted trigger phrases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WORDS = (
|
||||
"und", "nicht", "oder", "wird", "werden", "wurde", "kein", "keine", "keinen", "eine", "einen", "einem", "einer",
|
||||
"ist", "sind", "auf", "für", "bitte", "noch", "schon", "alle", "aus", "zum", "zur", "bei", "beim", "nach", "über",
|
||||
"dieser", "diese", "dieses", "jetzt", "heißt", "sich", "auch", "wenn", "dann", "mehr", "kann", "muss", "soll",
|
||||
"gibt", "ohne", "vom", "von", "läuft", "wählen", "speichern", "löschen", "abbrechen", "schließen", "bearbeiten",
|
||||
"anzeigen", "öffnen", "zeigen", "aufnehmen", "vorlesen", "regeln", "regel", "regelwunsch", "steuerung",
|
||||
"ausführen", "ressourcen", "hierarchie", "freigabe", "freigeben", "ablehnen", "warten", "wartet", "offen",
|
||||
"erledigt", "neu", "neuer", "angenommen", "abgelehnt", "gestoppt", "abgelaufen", "unbekannt",
|
||||
"fehlgeschlagen", "verweigert", "besetzt", "abberufen", "aufgabe", "aufgaben", "entfernt", "entfernen", "gemergt",
|
||||
"bleibt", "stoppt", "erzwingt", "verwirft", "hintergrund", "kategorien", "ansicht", "quelle", "namen", "suchen",
|
||||
"anheften", "entscheiden", "entscheidungen", "nacht", "abend", "himmel", "sonnenuntergang", "sonnenaufgang",
|
||||
"aufnahme", "nachricht", "nachrichten", "einstellungen", "sprache", "stumm", "fertig", "fehler", "gesperrt",
|
||||
"signiert", "heute", "gestern", "stunden", "minuten", "sekunden", "warnung", "hinweis", "sofort", "zeit",
|
||||
"weiter", "kopieren", "kopiert", "starten", "gestartet", "beenden", "beendet",
|
||||
)
|
||||
GERMAN = re.compile(r"[äöüÄÖÜß]|\b(?:" + "|".join(WORDS) + r")\b", re.IGNORECASE)
|
||||
ALLOWED = (
|
||||
'"rechtlich prüfen"', '"lizenz prüfen"', '"aufräumen"', '"branches aufräumen"', '"worktrees aufräumen"',
|
||||
'"was soll ich tun"', '"was muss ich tun"', '"was fehlt noch"', '"welche skills gibt es"', '"skill übersicht"',
|
||||
'"übersicht"', '"zuständigkeiten"', '"verwaiste branches"', '"alte worktrees"', '"was kann weg"', '"ä"',
|
||||
"(Regeln → Firewall, Hierarchie → Hierarchy, Ressourcen → Resources,", "Steuerung → Controls, Ausführen → Run)",
|
||||
)
|
||||
SELF = Path(__file__).resolve().relative_to(REPO_ROOT).as_posix()
|
||||
|
||||
|
||||
def german_lines() -> list[str]:
|
||||
"""Returns:
|
||||
Every `path:line` of a tracked text file that carries German once the
|
||||
allowed trigger phrases are removed from it.
|
||||
"""
|
||||
files = subprocess.run(["git", "ls-files"], cwd=REPO_ROOT, capture_output=True, text=True, check=True).stdout.split()
|
||||
hits = []
|
||||
for name in files:
|
||||
if name == SELF:
|
||||
continue
|
||||
try:
|
||||
text = (REPO_ROOT / name).read_text(encoding="utf-8")
|
||||
except (UnicodeDecodeError, IsADirectoryError, FileNotFoundError):
|
||||
continue
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
for phrase in ALLOWED:
|
||||
line = line.replace(phrase, "")
|
||||
if GERMAN.search(line):
|
||||
hits.append(f"{name}:{number}")
|
||||
return hits
|
||||
|
||||
|
||||
class TestEnglish(unittest.TestCase):
|
||||
def test_no_german_outside_quoted_trigger_phrases(self):
|
||||
self.assertEqual(german_lines(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n".join(german_lines()))
|
||||
@@ -6,7 +6,7 @@ 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)]
|
||||
SLOTS = [(0, 420, "Morning"), (0, 539, "Morning"), (0, 540, None), (1, 419, None), (5, 480, None), (5, 1380, "Night"), (6, 90, "Night"), (6, 120, None), (4, 1380, None), (6, 1380, None)]
|
||||
PROBE = """
|
||||
const A = require(process.argv[1]);
|
||||
const at = (text) => Date.parse(text);
|
||||
|
||||
@@ -344,9 +344,9 @@ class TestPasskeyPage(unittest.TestCase):
|
||||
def test_the_section_and_a_failed_registration_name_the_keepassxc_localhost_setting(self):
|
||||
done = subprocess.run(["node", "-e", PASSKEY_PROBE, str(PASSKEY_PAGE)], capture_output=True, text=True, timeout=60, check=True)
|
||||
shown = json.loads(done.stdout)
|
||||
self.assertIn("nicht registriert: NotAllowedError", shown["failed"])
|
||||
self.assertIn("not registered: NotAllowedError", shown["failed"])
|
||||
for where in ("section", "failed"):
|
||||
for text in ("Nutzung von localhost mit Passkeys zulassen", "Allow using localhost with passkeys", "Passkeys request canceled.", "entsperrt"):
|
||||
for text in ("Allow using localhost with passkeys", "Passkeys request canceled.", "unlocked"):
|
||||
with self.subTest(where=where, text=text):
|
||||
self.assertIn(text, shown[where])
|
||||
|
||||
|
||||
@@ -124,16 +124,16 @@ class TestHubBoard(HubCase):
|
||||
for action, other in (("clear-mayday", "sunrise"), ("evening", "sunset"), ("sunset", "evening"), ("sunrise", "clear-mayday")):
|
||||
with self.subTest(action=action):
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {}, good)[0], 400)
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": None}, good)[:3:2], (400, b"not signed: no signature\n"))
|
||||
stranger = key(-7, "stranger").proof(self.board_proof(action, signer)["payload"], self.origin)
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": stranger}, good)[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.post_json(f"/api/board/{action}", {"proof": stranger}, good)[:3:2], (400, b"not signed: the signing key is not pinned\n"))
|
||||
proof = self.board_proof(action, signer)
|
||||
self.assertEqual(self.post_json(f"/api/board/{other}", {"proof": proof}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json(f"/api/board/{other}", {"proof": proof}, good)[:3:2], (400, b"not signed: the signature does not list this item\n"))
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
proof = self.board_proof("clear-mayday", signer)
|
||||
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[0], 200)
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
|
||||
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.post_json("/api/board/clear-mayday", {"proof": proof}, good)[:3:2], (400, b"not signed: this signature was already used for this item\n"))
|
||||
for payload in ({"action": "order"}, {"action": "broadcast"}, {"action": "reboot"}, {"action": 1}, {}, {"action": "evening", "extra": 1}):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(self.post_json("/api/board/sign", payload, good)[0], 400)
|
||||
@@ -189,7 +189,7 @@ class TestHubBoard(HubCase):
|
||||
{"name": "snooze", "arguments": {"ids": [ask_id], "hours": 500}},
|
||||
{"name": "view", "arguments": {"name": "funk", "extra": 1}},
|
||||
]}))
|
||||
status, _, body = self.post_json("/api/nl", {"text": "gib alles Grüne frei"}, self.guard(self.host))
|
||||
status, _, body = self.post_json("/api/nl", {"text": "approve everything green"}, 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"])
|
||||
|
||||
@@ -62,12 +62,12 @@ class TestHubCleanup(HubCase):
|
||||
done = branches["feature/done"]
|
||||
self.assertEqual(done["cleanup"], item)
|
||||
self.assertEqual(done["plan"], [
|
||||
"feature/done @ 5b1e6fd3a7c0: merged-clean, gemergt durch #12",
|
||||
f"entfernt den Worktree {item['worktree']}",
|
||||
"löscht den lokalen Branch feature/done",
|
||||
"bleibt: remote-branch (nur lokal)",
|
||||
"stoppt den Compose-Stack demo-feature-done ohne -v",
|
||||
"entfernt das Image demo:feature-done",
|
||||
"feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12",
|
||||
f"removes the worktree {item['worktree']}",
|
||||
"deletes the local branch feature/done",
|
||||
"stays: remote-branch (local only)",
|
||||
"stops the Compose stack demo-feature-done without -v",
|
||||
"removes the image demo:feature-done",
|
||||
])
|
||||
self.assertEqual((done["merged_by"], done["merged_into"], done["gone"]), ("#12", "main", True))
|
||||
self.assertEqual({name: entry["cleanup"] for name, entry in branches.items() if name != "feature/done"}, {"feature": None, "feature/kept": None, "main": None})
|
||||
@@ -76,7 +76,7 @@ class TestHubCleanup(HubCase):
|
||||
def test_signing_a_delete_needs_delete_ready_and_exactly_an_offered_item(self):
|
||||
item = self.ready(False)
|
||||
status, body = self.sign([item])
|
||||
self.assertEqual((status, body.decode()), (400, "löschen gesperrt: Secret Service locked\n"))
|
||||
self.assertEqual((status, body.decode()), (400, "delete blocked: Secret Service locked\n"))
|
||||
item = self.ready(True)
|
||||
kept = {**item, "branch": "feature/kept", "worktree": "", "tip": "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d"}
|
||||
for name, items in (("changed tip", [{**item, "tip": OTHER_TIP}]), ("skip reason", [kept]), ("pattern", [{**item, "branch": "feature/*"}]), ("twice", [item, item]), ("extra key", [{**item, "command": "rm -rf /"}])):
|
||||
|
||||
+17
-17
@@ -115,21 +115,21 @@ class TestHubComposer(HubCase):
|
||||
def test_an_operator_message_needs_the_passkey_and_carries_its_recording(self):
|
||||
self.claim("s1", "Skills session")
|
||||
voice = self.note(self.clip(b"\x01\x00"))
|
||||
payload = {"to": "Skills", "text": " bitte rebasen ", "upload": "", "class": "call", "voice": voice}
|
||||
payload = {"to": "Skills", "text": " please rebase ", "upload": "", "class": "call", "voice": voice}
|
||||
status, _, body = self.post_json("/api/messages/sign", payload, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["credentials"], [])
|
||||
unpinned = key(-7, "stranger").proof(json.loads(body)["payload"], self.origin)
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": unpinned}, self.guard(self.host))[:3:2], (400, b"nicht signiert: no key is pinned\n"))
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": unpinned}, self.guard(self.host))[:3:2], (400, b"not signed: no key is pinned\n"))
|
||||
signer = self.pin()
|
||||
proof = self.message_proof(payload, signer)
|
||||
for changed, reason in (
|
||||
({"proof": None}, b"nicht signiert: no signature\n"),
|
||||
({"proof": key(-7, "stranger").proof(proof["payload"], self.origin)}, b"nicht signiert: the signing key is not pinned\n"),
|
||||
({"proof": None}, b"not signed: no signature\n"),
|
||||
({"proof": key(-7, "stranger").proof(proof["payload"], self.origin)}, b"not signed: the signing key is not pinned\n"),
|
||||
({"proof": signer.proof(proof["payload"], "http://127.0.0.1:1")}, None),
|
||||
({"text": "bitte mergen", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||||
({"class": "post", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||||
({"voice": "", "proof": proof}, b"nicht signiert: the signature does not list this item\n"),
|
||||
({"text": "please merge", "proof": proof}, b"not signed: the signature does not list this item\n"),
|
||||
({"class": "post", "proof": proof}, b"not signed: the signature does not list this item\n"),
|
||||
({"voice": "", "proof": proof}, b"not signed: the signature does not list this item\n"),
|
||||
):
|
||||
with self.subTest(changed=list(changed)):
|
||||
status, _, answer = self.post_json("/api/messages", {**payload, **changed}, self.guard(self.host))
|
||||
@@ -141,10 +141,10 @@ class TestHubComposer(HubCase):
|
||||
self.assertEqual(status, 201, body)
|
||||
sent = json.loads(body)
|
||||
recording = f"{HOST_STATE}/voice/{voice}.wav"
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] bitte rebasen voice: {recording}"])])
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
verified = self.check(sent["id"], "bitte rebasen", "Skills session")["structuredContent"]
|
||||
self.assertEqual((verified["match"], verified["text"], verified["class"], verified["voice"]), (True, "bitte rebasen", "call", recording))
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] please rebase voice: {recording}"])])
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"not signed: this signature was already used for this item\n"))
|
||||
verified = self.check(sent["id"], "please rebase", "Skills session")["structuredContent"]
|
||||
self.assertEqual((verified["match"], verified["text"], verified["class"], verified["voice"]), (True, "please rebase", "call", recording))
|
||||
row = self.snapshot()["messages"][-1]
|
||||
self.assertEqual((row["id"], row["class"], row["voice"], row["signed"]), (sent["id"], "call", voice, True))
|
||||
self.assertEqual(len(self.scripts.calls), 1)
|
||||
@@ -176,18 +176,18 @@ class TestHubComposer(HubCase):
|
||||
self.claim("s1", "Skills session")
|
||||
signer = self.pin()
|
||||
voice = self.note(self.clip(b"\x01\x00"))
|
||||
status, _, body = self.say({"to": "Skills", "text": "bitte\n rebasen", "class": "call", "voice": voice}, signer)
|
||||
status, _, body = self.say({"to": "Skills", "text": "please\n rebase", "class": "call", "voice": voice}, signer)
|
||||
self.assertEqual(status, 201, body)
|
||||
sent = json.loads(body)["id"]
|
||||
row = self.scripts.calls[-1][1][-1].split("] ", 1)[1]
|
||||
for text, to in (("bitte rebasen", "Skills"), ("bitte rebasen", "Skills session"), (row, "Skills")):
|
||||
for text, to in (("please rebase", "Skills"), ("please rebase", "Skills session"), (row, "Skills")):
|
||||
with self.subTest(text=text, to=to):
|
||||
result = self.check(sent, text, to)
|
||||
self.assertFalse(result["isError"], result)
|
||||
found = result["structuredContent"]
|
||||
self.assertEqual((found["match"], found["text"], found["to"], found["class"]), (True, "bitte\n rebasen", "Skills", "call"))
|
||||
self.assertEqual((found["match"], found["text"], found["to"], found["class"]), (True, "please\n rebase", "Skills", "call"))
|
||||
self.assertEqual(found["voice"], f"{HOST_STATE}/voice/{voice}.wav")
|
||||
for text, to in (("bitte mergen", "Skills"), ("bitte rebasen", "Stage"), ("bitte rebasen", "*"), ("bitte", "Skills")):
|
||||
for text, to in (("please merge", "Skills"), ("please rebase", "Stage"), ("please rebase", "*"), ("please", "Skills")):
|
||||
with self.subTest(text=text, to=to):
|
||||
result = self.check(sent, text, to)
|
||||
self.assertTrue(result["isError"], result)
|
||||
@@ -256,7 +256,7 @@ class TestHubComposer(HubCase):
|
||||
payload = {"to": "Skills", "text": "hear this", "upload": "", "class": "call", "voice": voice}
|
||||
proof = self.message_proof(payload, signer)
|
||||
wav.write_bytes(self.clip(b"\x02\x00"))
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))[:3:2], (400, b"not signed: the signature does not list this item\n"))
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
wav.write_bytes(original)
|
||||
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||||
@@ -273,7 +273,7 @@ class TestHubComposer(HubCase):
|
||||
with mock.patch.object(signed.passkey, "serialization", None), mock.patch.object(tempfile, "tempdir", str(missing)):
|
||||
status, _, body = self.post_json("/api/messages", {**payload, "proof": proof}, self.guard(self.host))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertTrue(body.startswith(b"nicht signiert: "), body)
|
||||
self.assertTrue(body.startswith(b"not signed: "), body)
|
||||
self.assertEqual(self.scripts.calls, [])
|
||||
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class TestHubConsole(HubCase):
|
||||
|
||||
def test_the_page_draws_the_console_as_text_and_follows_its_end(self):
|
||||
html = self.index()
|
||||
for text in ('"/api/inbox/dismiss"', "console-out", "wartet auf den Runner", "Exit-Code", "textContent", "scrollHeight"):
|
||||
for text in ('"/api/inbox/dismiss"', "console-out", "waiting for the runner", "Exit code", "textContent", "scrollHeight"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIn(text, html)
|
||||
for name in ("page-console.js", "page-deny.js", "page-rule-request.js"):
|
||||
|
||||
@@ -80,9 +80,9 @@ class TestHubDeny(HubCase):
|
||||
denial = self.httpd.hub.messages.get(message_id)["denial"]
|
||||
self.httpd.hub.messages.change(message_id, {"denial": {**denial, "reason": "push anyway"}})
|
||||
self.assertTrue(self.tool("operator_message", {"id": message_id, "text": told, "to": "Skills session"})["isError"])
|
||||
logged = [row for row in self.events("kinds=approval")["events"] if row["title"].startswith("Ablehnung ask")]
|
||||
logged = [row for row in self.events("kinds=approval")["events"] if row["title"].startswith("Denial ask")]
|
||||
self.assertEqual(len(logged), 1)
|
||||
self.assertIn("call an Skills session", logged[0]["text"])
|
||||
self.assertIn("call to Skills session", logged[0]["text"])
|
||||
self.assertNotIn(ask_id, [item["id"] for item in self.snapshot()["inbox"]])
|
||||
|
||||
def test_every_class_but_mayday_reaches_the_agent_or_all(self):
|
||||
@@ -121,7 +121,7 @@ class TestHubDeny(HubCase):
|
||||
self.assertTrue(self.radio()[0][-1].endswith("Denied by the operator: operator command git push in /repo (from Skills). Reason: push after the review"), self.radio()[0])
|
||||
self.assertEqual(len(self.radio()), 1)
|
||||
texts = [row["text"] for row in self.events("kinds=approval")["events"]]
|
||||
self.assertTrue(any("kein Empfänger" in text for text in texts), texts)
|
||||
self.assertTrue(any("no recipient" in text for text in texts), texts)
|
||||
|
||||
def test_a_forged_denial_is_refused_before_anything_is_stored_or_sent(self):
|
||||
self.claim("s1", "Skills session")
|
||||
@@ -134,7 +134,7 @@ class TestHubDeny(HubCase):
|
||||
with self.subTest(name=name), mock.patch.object(batches, "UNDO", 0.05):
|
||||
status, _, body = self.approve(entries, proof)
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn("nicht signiert", body.decode())
|
||||
self.assertIn("not signed", body.decode())
|
||||
self.assertFalse(self.httpd.hub.batches.holds("ask", ask_id))
|
||||
time.sleep(0.2)
|
||||
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("pending", []))
|
||||
@@ -182,7 +182,7 @@ class TestHubDeny(HubCase):
|
||||
self.assertEqual(self.prepare([entry])["payload"]["items"], [signing.item_hash(signing.denial_item(subject, "call", "not now", ""))])
|
||||
with mock.patch.object(batches, "UNDO", 0.05):
|
||||
self.assertEqual(self.approve([entry], self.signed([entry], self.pin()))[0], 202)
|
||||
self.settle(lambda: any("kein Empfänger" in row["text"] for row in self.events("kinds=approval")["events"]))
|
||||
self.settle(lambda: any("no recipient" in row["text"] for row in self.events("kinds=approval")["events"]))
|
||||
self.assertEqual((self.ask_status(ask_id, "0"), self.radio()), ("denied", []))
|
||||
|
||||
def answer(self, signer: Key, item: dict, channel: str, reason: str) -> dict:
|
||||
|
||||
@@ -81,7 +81,7 @@ class TestHubGraph(HubCase):
|
||||
("rename", "s1", "IAM", "IAM"),
|
||||
("radio", "s1", "IAM", "IAM"),
|
||||
])
|
||||
self.assertEqual(rows[1]["title"], "Skills heißt jetzt IAM")
|
||||
self.assertEqual(rows[1]["title"], "Skills is now called IAM")
|
||||
self.assertAlmostEqual(rows[1]["ts"], now - 50, places=3)
|
||||
for name in ("Skills", "IAM"):
|
||||
with self.subTest(name=name):
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestHubInbox(HubCase):
|
||||
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"])
|
||||
self.assertEqual(inbox[risky]["warnings"], ["deletes recursively"])
|
||||
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)
|
||||
@@ -77,8 +77,8 @@ class TestHubInbox(HubCase):
|
||||
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))
|
||||
self.assertIn("Grant review: feat: green", titles)
|
||||
self.assertTrue(any(title.startswith("Undone: ") for title in titles))
|
||||
|
||||
def test_the_sign_endpoint_and_the_page_offer_the_pinned_keys_only(self):
|
||||
ask_id = self.create_ask(ASK)
|
||||
@@ -96,7 +96,7 @@ class TestHubInbox(HubCase):
|
||||
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-orchestrator/approvers.json", "nicht signiert"):
|
||||
for text in ("Register passkey", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-orchestrator/approvers.json", "not signed"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIn(text, html)
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ class TestRenameOnce(unittest.TestCase):
|
||||
self.assertEqual(counts, [0, 0])
|
||||
for build in (3000.0, 4000.0):
|
||||
book.observe([*claims, ("s1", "Skills", 300.0, "title")], build)
|
||||
self.assertEqual([row["title"] for row in names.Directory(book.listed()).renames(0, 5000)], ["i18n 2.0 heißt jetzt Skills"])
|
||||
self.assertEqual([row["title"] for row in names.Directory(book.listed()).renames(0, 5000)], ["i18n 2.0 is now called Skills"])
|
||||
|
||||
def test_a_name_pair_shows_once_per_session(self):
|
||||
entries = [{"session": "s1", "name": name, "first_seen": ts, "source": "claim"} for name, ts in (("A", 1), ("B", 2), ("A", 3), ("B", 4))]
|
||||
self.assertEqual([row["title"] for row in names.Directory(entries).renames(0, 10)], ["A heißt jetzt B", "B heißt jetzt A"])
|
||||
self.assertEqual([row["title"] for row in names.Directory(entries).renames(0, 10)], ["A is now called B", "B is now called A"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -34,16 +34,16 @@ class TestNavModule(unittest.TestCase):
|
||||
cls.data = json.loads(done.stdout)
|
||||
|
||||
def test_the_navigation_has_four_entries_and_stream_has_inbox_rules_and_radio(self):
|
||||
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]]
|
||||
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]]
|
||||
self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]])
|
||||
|
||||
def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_three(self):
|
||||
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
|
||||
self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared))
|
||||
self.assertEqual((self.data["taskViews"], self.data["tasks"]), ([["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]],) * 2)
|
||||
self.assertEqual((self.data["taskViews"], self.data["tasks"]), ([["list", "List"], ["board", "Board"], ["rasci", "RASCI"]],) * 2)
|
||||
|
||||
def test_only_agents_add_the_hierarchy_after_3d(self):
|
||||
self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchie"]])
|
||||
self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchy"]])
|
||||
self.assertEqual(self.data["icon"], "bi-diagram-2")
|
||||
|
||||
def test_hashes_route_and_the_old_ones_redirect(self):
|
||||
|
||||
@@ -8,8 +8,8 @@ from hub_support import AMBIENCE
|
||||
from kvb_orchestrator.settings import stream_origin
|
||||
|
||||
DLF = "https://st01.sslstream.dlf.de/dlf/01/high/aac/stream.aac?aggregator=web"
|
||||
OTHER = "https://radio.example/morgen"
|
||||
URLS = [DLF, "https://Radio.Example:443/zweiter?x=1", "http://10.0.0.5:8000/nacht", "https://other.example:8443/", "http://radio.example:80/x"]
|
||||
OTHER = "https://radio.example/morning"
|
||||
URLS = [DLF, "https://Radio.Example:443/second?x=1", "http://10.0.0.5:8000/night", "https://other.example:8443/", "http://radio.example:80/x"]
|
||||
PROBE = """
|
||||
const A = require(process.argv[1]);
|
||||
const [DLF, OTHER] = JSON.parse(process.argv[2]);
|
||||
|
||||
@@ -221,7 +221,7 @@ class TestRedirects(unittest.TestCase):
|
||||
|
||||
def test_only_https_urls_are_probed(self):
|
||||
before = len(cdn().dialed)
|
||||
self.assertEqual(self.resolver().resolve(["http://10.0.0.5:8000/nacht", STATION]), {"http://10.0.0.5:8000/nacht": ([], ""), STATION: (["https://*.cdn.test"], "")})
|
||||
self.assertEqual(self.resolver().resolve(["http://10.0.0.5:8000/night", STATION]), {"http://10.0.0.5:8000/night": ([], ""), STATION: (["https://*.cdn.test"], "")})
|
||||
self.assertEqual(len(cdn().dialed) - before, 2)
|
||||
|
||||
def test_media_sources_widen_one_label_and_pass_the_settings_check(self):
|
||||
@@ -284,9 +284,9 @@ class TestRedirectSettings(HubCase):
|
||||
self.assertEqual(set(prepared), {"payload", "challenge", "credentials"})
|
||||
self.assertEqual(prepared["payload"]["items"], [signing.item_hash(signing.stream_item(["https://attacker.radio.test"], [streams[0]["url"]]))])
|
||||
unsigned = self.post_json("/api/settings", {**self.payload(streams), "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"not signed: no signature\n"))
|
||||
stranger = {**self.payload(streams), "proof": key(-7, "stranger").proof(prepared["payload"], self.origin)}
|
||||
self.assertEqual(self.post_json("/api/settings", stranger, self.guard(self.host))[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", stranger, self.guard(self.host))[:3:2], (400, b"not signed: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertNotIn("attacker.radio.test", cdn().httpd.asked)
|
||||
self.assertFalse((self.state / "settings.json").exists())
|
||||
@@ -326,7 +326,7 @@ class TestRedirectSettings(HubCase):
|
||||
self.assertEqual(self.prepared(streams)["payload"]["items"], [signing.item_hash(signing.stream_item(["https://station.radio.test"], [other["url"]]))])
|
||||
before = self.dials()
|
||||
unsigned = self.post_json("/api/settings", {**self.payload(streams), "proof": None}, self.guard(self.host))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(unsigned[:3:2], (400, b"not signed: no signature\n"))
|
||||
self.assertEqual(self.dials(), before)
|
||||
self.assertEqual(self.httpd.hub.settings.get(), saved)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestHubRuleRequests(HubCase):
|
||||
with self.subTest(name=name):
|
||||
status, _, body = self.post_json(f"/api/rule-requests/{request_id}/accept", {"rule": rule, "assertion": assertion}, self.guard(self.host))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn("nicht signiert", body.decode())
|
||||
self.assertIn("not signed", body.decode())
|
||||
self.assertEqual((self.waited(request_id)["status"], self.httpd.hub.rules.listed()), ("pending", []))
|
||||
self.assertIsNotNone(self.item(request_id))
|
||||
now = int(time.time())
|
||||
|
||||
@@ -35,27 +35,27 @@ class TestHubSettings(HubCase):
|
||||
def test_a_new_stream_url_needs_the_passkey(self):
|
||||
good = self.guard(self.host)
|
||||
payload = self.settings_payload(STREAMS)
|
||||
self.assertEqual(self.post_json("/api/settings", payload, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", payload, good)[:3:2], (400, b"not signed: no signature\n"))
|
||||
self.assertFalse((self.state / "settings.json").exists())
|
||||
signer = self.pin()
|
||||
request = self.prepared(payload)["payload"]
|
||||
stranger = key(-7, "stranger").proof(request, self.origin)
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": stranger}, good)[:3:2], (400, b"nicht signiert: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": stranger}, good)[:3:2], (400, b"not signed: the signing key is not pinned\n"))
|
||||
self.assertEqual(self.media_src(), "media-src 'self'")
|
||||
proof = signer.proof(request, self.origin)
|
||||
status, _, body = self.post_json("/api/settings", {**payload, "proof": proof}, good)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://other.example:8443 https://radio.example")
|
||||
moved = self.settings_payload([{**STREAMS[0], "url": "https://radio.example/abend"}, STREAMS[1]])
|
||||
self.assertEqual(self.post_json("/api/settings", moved, good)[:3:2], (400, b"nicht signiert: no signature\n"))
|
||||
moved = self.settings_payload([{**STREAMS[0], "url": "https://radio.example/evening"}, STREAMS[1]])
|
||||
self.assertEqual(self.post_json("/api/settings", moved, good)[:3:2], (400, b"not signed: no signature\n"))
|
||||
status, _, body = self.save(moved, signer)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
|
||||
self.assertEqual(self.post_json("/api/settings", self.settings_payload([]), good)[0], 200)
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": proof}, good)[:3:2], (400, b"nicht signiert: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", {**payload, "proof": proof}, good)[:3:2], (400, b"not signed: this signature was already used for this item\n"))
|
||||
self.assertEqual(self.save(moved, signer)[0], 200)
|
||||
evil = self.settings_payload([*moved["streams"], {**STREAMS[0], "url": "https://evil.example/listen"}])
|
||||
self.assertEqual(self.post_json("/api/settings", {**evil, "proof": signer.proof(self.prepared(payload)["payload"], self.origin)}, good)[:3:2], (400, b"nicht signiert: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", {**evil, "proof": signer.proof(self.prepared(payload)["payload"], self.origin)}, good)[:3:2], (400, b"not signed: the signature does not list this item\n"))
|
||||
self.assertEqual(self.post_json("/api/settings", evil, good)[0], 400)
|
||||
self.assertEqual(self.media_src(), "media-src 'self' http://10.0.0.5:8000 https://radio.example")
|
||||
status, _, body = self.save(evil, signer)
|
||||
@@ -163,7 +163,7 @@ class TestHubSettings(HubCase):
|
||||
|
||||
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"}]
|
||||
streams = [*STREAMS, {**STREAMS[0], "name": "Same", "url": "https://Radio.Example:443/second?x=1"}]
|
||||
status, _, body = self.save(self.settings_payload(streams), self.pin())
|
||||
self.assertEqual(status, 200, body)
|
||||
origins = ["http://10.0.0.5:8000", "https://other.example:8443", "https://radio.example"]
|
||||
|
||||
@@ -57,7 +57,7 @@ const ctx = Object.fromEntries(["clearRect", "beginPath", "moveTo", "lineTo", "q
|
||||
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 style = {colors, font: "12px sans", strong: "bold 12px sans", level: "Depth", cursor: "As of", 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) => {
|
||||
|
||||
@@ -216,7 +216,7 @@ class TestPhraseology(unittest.TestCase):
|
||||
(("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"),
|
||||
(("security", "Maintainer", ["Skills", "Docs"], "SECURITE rotate"), "say-cure-ee-tay say-cure-ee-tay say-cure-ee-tay, all stations, this is Maintainer, rotate, over"),
|
||||
(("call", "Skills", ["Maintainer"], "ready"), "Maintainer, Maintainer, this is Skills, ready, over"),
|
||||
(("post", "Skills", ["Alpha", "Beta"], "ready"), "Alpha and Beta, Alpha and Beta, this is Skills, ready, over"),
|
||||
(("call", "Docs", ["Ops"], "MAYDAY is only a word here"), "Ops, Ops, this is Docs, MAYDAY is only a word here, over"),
|
||||
@@ -230,6 +230,18 @@ class TestPhraseology(unittest.TestCase):
|
||||
with self.subTest(arguments=arguments):
|
||||
self.assertEqual(phrasing.phrase(*arguments), expected)
|
||||
|
||||
def test_hashes_spell_in_nato_and_digits_in_icao_only_when_spoken(self):
|
||||
cases = (
|
||||
("8e180be", "eight echo one eight zero bravo echo"),
|
||||
("42305", "four two tree zero fife"),
|
||||
("fix 8E180BE, PR #1326", "fix eight echo one eight zero bravo echo, PR #one tree two six"),
|
||||
("8e180b decade deadbeef", "eight e one eight zero b decade deadbeef"),
|
||||
)
|
||||
for text, expected in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(phrasing.phrase("broadcast", "Docs", [], text), f"all stations, this is Docs, {expected}, out")
|
||||
self.assertEqual(phrasing.pronounced(phrasing.pronounced("8e180be 42305")), phrasing.pronounced("8e180be 42305"))
|
||||
|
||||
def test_a_phrase_fits_the_speech_limit_on_one_line(self):
|
||||
for cls in council.CLASSES:
|
||||
with self.subTest(cls=cls):
|
||||
|
||||
@@ -162,7 +162,7 @@ class TestHubState(HubCase):
|
||||
"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",
|
||||
"say-cure-ee-tay say-cure-ee-tay say-cure-ee-tay, 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"]])
|
||||
|
||||
@@ -149,10 +149,10 @@ class TestHubStream(HubCase):
|
||||
"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"),
|
||||
"Background: 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"),
|
||||
"Queue: gate · running": (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)
|
||||
|
||||
@@ -118,7 +118,7 @@ class TestHubSurface(HubCase):
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_the_stream_has_four_categories_and_transcripts_stay_out_by_default(self):
|
||||
self.assertEqual({key: value["label"] for key, value in events.VIEWS.items()}, {"fuer-dich": "Für dich", "funk": "Funk", "arbeit": "Arbeit", "transkripte": "Transkripte"})
|
||||
self.assertEqual({key: value["label"] for key, value in events.VIEWS.items()}, {"fuer-dich": "For you", "funk": "Radio", "arbeit": "Work", "transkripte": "Transcripts"})
|
||||
self.assertEqual(events.VIEWS["transkripte"]["kinds"], ["transcript"])
|
||||
self.assertNotIn("transcript", events.VIEWS["fuer-dich"]["kinds"] + events.VIEWS["funk"]["kinds"] + events.VIEWS["arbeit"]["kinds"])
|
||||
self.assertEqual(sorted(events.VIEWS["fuer-dich"]["kinds"]), sorted(events.VIEWS["funk"]["kinds"] + events.VIEWS["arbeit"]["kinds"]))
|
||||
@@ -139,13 +139,13 @@ class TestHubSurface(HubCase):
|
||||
|
||||
self.assertEqual(texts("deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done", "review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("agent:Docs"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("art:funk deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("art:review"), ["review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("seit:1h agent:Docs"), ["radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("seit:5m done"), ["radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("kind:radio deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("kind:review"), ["review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("since:1h agent:Docs"), ["radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("since:5m done"), ["radio:Ops:deploy done"])
|
||||
since = time.strftime("%Y-%m-%dT%H:%M", time.localtime(now - 600))
|
||||
self.assertEqual(texts(f"seit:{since} art:funk"), ["radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
for q in ("art:bogus", "seit:soon", "seit:5y", "agent:"):
|
||||
self.assertEqual(texts(f"since:{since} kind:Radio"), ["radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
for q in ("kind:bogus", "since:soon", "since:5y", "agent:"):
|
||||
with self.subTest(q=q):
|
||||
self.assertEqual(self.get(f"/api/events?q={quote(q)}")[0], 400)
|
||||
|
||||
|
||||
@@ -179,10 +179,10 @@ class TestHubTasks(HubCase):
|
||||
forged = {**self.signed([change(PRIO)], self.pin()), "assertion": {}}
|
||||
status, _, body = self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": forged}, self.guard(self.host))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn("nicht signiert", body.decode() if isinstance(body, bytes) else body)
|
||||
self.assertIn("not signed", body.decode() if isinstance(body, bytes) else body)
|
||||
snapshot = self.snapshot()
|
||||
self.assertEqual([job for job in snapshot["jobs"] if job["kind"] == "task"], [])
|
||||
self.assertNotIn("Aufgabe signiert geändert", json.dumps(snapshot, ensure_ascii=False))
|
||||
self.assertNotIn("Task change signed", json.dumps(snapshot, ensure_ascii=False))
|
||||
|
||||
def test_a_task_the_hub_cannot_sign_whole_is_not_editable(self):
|
||||
long_slug = "work/" + "x" * 600
|
||||
|
||||
Reference in new issue
Block a user