feat(orchestrator): Agents and Artifacts views with spawn tree, orbit, 3D and signed branch deletes
Phase 4 of requirement 001. Agents and Artifacts each offer Orbit, Chord, Swimlane, Tree and 3D: the spawn tree (page-spawn.js) and the artifacts orbit (page-artifacts.js, kvb_orchestrator/artifacts.py) replace the "folgt" placeholders, 3D runs on the vendored 3d-force-graph and three.js (MIT, LICENSE files kept, served from the image under the unchanged CSP), and a Bootstrap Icons sprite (icons.py, MIT) replaces the emoji glyphs, also in the meditation page. Signed branch deletes: the Tree view offers a delete only for merged-clean branches without a gc skip reason; the operator signs the exact item with the passkey, the hub writes a job without any command (kvb_orchestrator/cleanup.py), and the host path unit runs client/cleanup.py, which verifies the signature, dry-runs pkgmgr branch cleanup for that exact branch, refuses patterns, option names, changed tips, other worktrees and plans naming other branches, deletes, and rewrites the branch tree. pkgmgr runs with core.hooksPath=/dev/null, core.fsmonitor=false and the config hook events disabled through GIT_CONFIG_COUNT, because the cwd is a repository agents can write and pkgmgr releases before 4c8b137 delete without scan mode (test with a positive control that both hook kinds fire without it). 3D labels go into the tooltip as a text node: the vendored tooltip renders a string label as HTML. Tests: 503 OK, Chromium (CDP) and Firefox (BiDi) walks over every screen at three widths. MEDUSA acceptances (kvb-think-criticize skeptic 2026-11-07): WEB-FLASK-004 view.py:135 (file template, autoescape on, state via tojson), MEDUSA-MM-SCAN-058 server.py:69/:85 (substring pla in --templates and Places), RH005 client/cleanup.py:199 (raise SystemExit(main())), B704 icons.py:45 (sprite baked into the image, names checked against it), EXA002 page-artifacts.js:221 (openAgent only opens the drawer), STG002 page-constants.js:64 (U+2212 in a static label), PL002 page-graph3d.js:44 (CSS class space-tools; the real tooltip sink fixed), B603 client/cleanup.py:75 (argv without shell; hook hole closed). 🎯 Fix: 92 % 💥 Break: 5 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 87 🚦 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
db3c5264f2
commit
90c56cb786
64 files changed
+82820
-442
No files matched your search
@@ -401,6 +401,8 @@ 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). 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` every minute while the hub page polls; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off.
|
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). 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` every minute while the hub page polls; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
|
||||||
|
|
||||||
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
|
The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port <port> --timeout <seconds> --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap.
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ from three views down to any agent, branch or artefact.
|
|||||||
items, and "Löschen" needs the operator signature (`/api/sign`).
|
items, and "Löschen" needs the operator signature (`/api/sign`).
|
||||||
Rejected: a second click without signature, one click without preview.
|
Rejected: a second click without signature, one click without preview.
|
||||||
- **Execution**: the orchestrator writes a signed job into a host
|
- **Execution**: the orchestrator writes a signed job into a host
|
||||||
directory; a systemd user path unit runs `pkgmgr branch gc --apply` for
|
directory; a systemd user path unit runs `pkgmgr branch cleanup --apply
|
||||||
exactly those items and writes the result back. Rejected: an
|
--local` for exactly those items and writes the result back. Rejected: an
|
||||||
`operator.py` row (not direct).
|
`operator.py` row (not direct).
|
||||||
22. **Organisation update**: a new skill `kvb-org-update` tells every agent
|
22. **Organisation update**: a new skill `kvb-org-update` tells every agent
|
||||||
and client which skills changed and how to use them, as one
|
and client which skills changed and how to use them, as one
|
||||||
@@ -348,8 +348,8 @@ flowchart TD
|
|||||||
and tip sha per item, no command) into a host-mounted job directory and
|
and tip sha per item, no command) into a host-mounted job directory and
|
||||||
holds no git, token or key.
|
holds no git, token or key.
|
||||||
- [ ] A systemd user path unit verifies the signature on the host and runs
|
- [ ] A systemd user path unit verifies the signature on the host and runs
|
||||||
`pkgmgr branch gc --apply --local` for exactly the signed items, matched
|
`pkgmgr branch cleanup --apply --local` for exactly the signed items,
|
||||||
exactly, never as a pattern; `--local` removes the worktree, the local
|
matched exactly, never as a pattern; `--local` removes the worktree, the local
|
||||||
branch, the compose stack without `-v` and its images, never a remote
|
branch, the compose stack without `-v` and its images, never a remote
|
||||||
branch, and needs no SSH key.
|
branch, and needs no SSH key.
|
||||||
- [ ] The unit refuses, and changes nothing for, an item whose tip or state
|
- [ ] The unit refuses, and changes nothing for, an item whose tip or state
|
||||||
|
|||||||
@@ -38,5 +38,6 @@ COPY server.py /app/server.py
|
|||||||
COPY kvb_orchestrator /app/kvb_orchestrator
|
COPY kvb_orchestrator /app/kvb_orchestrator
|
||||||
COPY client/signing.py /app/client/signing.py
|
COPY client/signing.py /app/client/signing.py
|
||||||
COPY templates /app/templates
|
COPY templates /app/templates
|
||||||
|
COPY vendor /app/vendor
|
||||||
|
|
||||||
ENTRYPOINT ["python3", "/app/server.py"]
|
ENTRYPOINT ["python3", "/app/server.py"]
|
||||||
Executable
+199
@@ -0,0 +1,199 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Args:
|
||||||
|
--port: port of the Agent Orchestrator; the signature's origin is
|
||||||
|
http://localhost:<port>.
|
||||||
|
--jobs: the directory the hub writes signed jobs into, e.g.
|
||||||
|
~/.local/state/kvb-orchestrator/cleanup/jobs.
|
||||||
|
--results: the directory each job's result goes to as <id>.json.
|
||||||
|
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
|
||||||
|
--replay: this runner's replay store, outside the hub's reach, e.g.
|
||||||
|
~/.local/state/kvb-orchestrator-approvals/cleanup.json.
|
||||||
|
--tree: the tree file `pkgmgr branch tree` writes; it names the
|
||||||
|
repositories pkgmgr knows and is rewritten after a deletion.
|
||||||
|
--pkgmgr: the pkgmgr executable built from a signed tag.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Runs once over every job in --jobs, as the operator on the host: per item
|
||||||
|
it verifies the operator's passkey signature over repository, branch,
|
||||||
|
worktree and tip, then in the repository runs `pkgmgr branch cleanup
|
||||||
|
--json --local --branch <name>` as a dry run and, only when that plan
|
||||||
|
names exactly this branch as merged-clean without skip reason at the
|
||||||
|
signed tip and worktree, `pkgmgr branch cleanup --apply --json --local
|
||||||
|
--branch <name>`. Every other item is refused and nothing changes for
|
||||||
|
it. Writes each item's result, rewrites the tree file after a deletion
|
||||||
|
and removes the job. The exit code is 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import signing
|
||||||
|
import verifier
|
||||||
|
|
||||||
|
JOB = re.compile(r"[0-9a-f]{16}\.json")
|
||||||
|
ITEM_KEYS = ("repository", "branch", "worktree", "tip")
|
||||||
|
TIP = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
||||||
|
UNSAFE = re.compile(r"[*?\[\]\\\s\x00-\x1f\x7f]|^-|\.\.|@\{")
|
||||||
|
OUTPUT_LIMIT = 16 << 10
|
||||||
|
TIMEOUT = 600
|
||||||
|
# the cwd is a repository agents can write: its config may name hooks, and
|
||||||
|
# pkgmgr releases before 4c8b137 run git branch -D without scan mode
|
||||||
|
GIT_OVERRIDES = (
|
||||||
|
("core.hooksPath", "/dev/null"),
|
||||||
|
("core.fsmonitor", "false"),
|
||||||
|
("hook.reference-transaction.enabled", "false"),
|
||||||
|
("hook.post-checkout.enabled", "false"),
|
||||||
|
("hook.post-index-change.enabled", "false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def git_overrides() -> dict[str, str]:
|
||||||
|
env = {"GIT_CONFIG_COUNT": str(len(GIT_OVERRIDES))}
|
||||||
|
for index, (name, value) in enumerate(GIT_OVERRIDES):
|
||||||
|
env[f"GIT_CONFIG_KEY_{index}"] = name
|
||||||
|
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def known_repositories(tree: Path) -> set[str]:
|
||||||
|
try:
|
||||||
|
data = json.loads(tree.read_text(encoding="utf-8"))
|
||||||
|
return {entry["path"] for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)}
|
||||||
|
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]:
|
||||||
|
try:
|
||||||
|
done = subprocess.run([str(pkgmgr), *argv], cwd=cwd, env={**os.environ, **git_overrides()}, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace", timeout=TIMEOUT, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
return 127, "", str(error)
|
||||||
|
return done.returncode, done.stdout, done.stderr[-OUTPUT_LIMIT:]
|
||||||
|
|
||||||
|
|
||||||
|
def plan_items(stdout: str) -> list[dict] | None:
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
items = data.get("items") if isinstance(data, dict) else data
|
||||||
|
if not isinstance(items, list) or not all(isinstance(entry, dict) for entry in items):
|
||||||
|
return None
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def shaped(item: object) -> bool:
|
||||||
|
if not isinstance(item, dict) or set(item) != set(ITEM_KEYS) or not all(isinstance(item[key], str) for key in ITEM_KEYS):
|
||||||
|
return False
|
||||||
|
return item["repository"].startswith("/") and bool(TIP.fullmatch(item["tip"])) and (item["worktree"] == "" or item["worktree"].startswith("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def mismatch(entry: dict, item: dict) -> str | None:
|
||||||
|
if entry.get("state") != "merged-clean":
|
||||||
|
return f"state is {entry.get('state')}, not merged-clean"
|
||||||
|
if entry.get("skip"):
|
||||||
|
return f"gc skip reason: {entry['skip']}"
|
||||||
|
tip = entry.get("local_sha") or entry.get("head")
|
||||||
|
if tip != item["tip"]:
|
||||||
|
return f"tip changed: signed {item['tip'][:12]}, now {str(tip)[:12]}"
|
||||||
|
if (entry.get("worktree") or "") != item["worktree"]:
|
||||||
|
return f"worktree changed: signed {item['worktree'] or 'none'}, now {entry.get('worktree') or 'none'}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Cleanup:
|
||||||
|
def __init__(self, trust: verifier.Trust, tree: Path, pkgmgr: Path) -> None:
|
||||||
|
self.trust = trust
|
||||||
|
self.tree = tree
|
||||||
|
self.pkgmgr = pkgmgr
|
||||||
|
|
||||||
|
def item(self, item: dict, proof: object, known: set[str], now: float) -> dict:
|
||||||
|
"""Args:
|
||||||
|
item: one signed item with repository, branch, worktree and tip.
|
||||||
|
proof: the job's {"payload", "assertion"}.
|
||||||
|
known: the repositories the tree file says pkgmgr knows.
|
||||||
|
now: the current time in epoch seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The item with status done, refused or failed and the reason;
|
||||||
|
refused and failed items changed nothing.
|
||||||
|
"""
|
||||||
|
def refused(reason: str) -> dict:
|
||||||
|
return {**item, "status": "refused", "reason": reason}
|
||||||
|
|
||||||
|
try:
|
||||||
|
verifier.check(proof, signing.cleanup_item(item["repository"], item["branch"], item["worktree"], item["tip"]), "", self.trust, now)
|
||||||
|
except (signing.Refused, OSError) as error:
|
||||||
|
return refused(f"no valid operator signature: {error}")
|
||||||
|
if item["repository"] not in known or not Path(item["repository"]).is_dir():
|
||||||
|
return refused("unknown repository: pkgmgr does not list it")
|
||||||
|
if UNSAFE.search(item["branch"]) or not item["branch"]:
|
||||||
|
return refused("the branch name holds pattern or option characters; only an exact branch name is deleted")
|
||||||
|
selector = ["--json", "--local", "--branch", item["branch"]]
|
||||||
|
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", *selector], item["repository"])
|
||||||
|
plan = plan_items(stdout) if code == 0 else None
|
||||||
|
if plan is None:
|
||||||
|
return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}")
|
||||||
|
others = sorted({str(entry.get("branch")) for entry in plan} - {item["branch"]})
|
||||||
|
if others:
|
||||||
|
return refused(f"the dry run names other branches too, a pattern match: {', '.join(others)[:500]}")
|
||||||
|
if len(plan) != 1:
|
||||||
|
return refused(f"the dry run names {len(plan)} items for {item['branch']}")
|
||||||
|
reason = mismatch(plan[0], item)
|
||||||
|
if reason:
|
||||||
|
return refused(reason)
|
||||||
|
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", "--apply", *selector], item["repository"])
|
||||||
|
applied = plan_items(stdout)
|
||||||
|
status = "done" if code == 0 else "failed"
|
||||||
|
return {**item, "status": status, "reason": "" if code == 0 else stderr.strip()[-500:], "code": code, "applied": applied if applied is not None else []}
|
||||||
|
|
||||||
|
def job(self, path: Path, now: float) -> dict:
|
||||||
|
try:
|
||||||
|
job = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
return {"id": path.stem, "finished": now, "items": [], "error": f"unreadable job: {error}"}
|
||||||
|
items = job.get("items") if isinstance(job, dict) else None
|
||||||
|
if not isinstance(items, list) or not items or not all(map(shaped, items)) or job.get("id") != path.stem:
|
||||||
|
return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing deleted"}
|
||||||
|
known = known_repositories(self.tree)
|
||||||
|
results = [self.item(item, job.get("proof"), known, now) for item in items]
|
||||||
|
result = {"id": path.stem, "finished": time.time(), "items": results}
|
||||||
|
if any(entry["status"] == "done" for entry in results):
|
||||||
|
code, _, stderr = run(self.pkgmgr, ["branch", "tree", "--format", "json", "--output", str(self.tree)], str(self.tree.parent))
|
||||||
|
result["tree"] = {"code": code, "error": stderr.strip()[-500:]}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cycle(jobs: Path, results: Path, cleanup: Cleanup) -> int:
|
||||||
|
handled = 0
|
||||||
|
for path in sorted(jobs.iterdir()):
|
||||||
|
if not JOB.fullmatch(path.name) or not path.is_file():
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
continue
|
||||||
|
verifier.write_private(results / path.name, cleanup.job(path, time.time()))
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
handled += 1
|
||||||
|
return handled
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="cleanup")
|
||||||
|
parser.add_argument("--port", type=int, required=True)
|
||||||
|
for name in ("--jobs", "--results", "--approvers", "--replay", "--tree", "--pkgmgr"):
|
||||||
|
parser.add_argument(name, type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
|
||||||
|
args.results.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
cycle(args.jobs, args.results, Cleanup(trust, args.tree, args.pkgmgr))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -65,6 +65,10 @@ def operator_item(task: str, command: str, directory: str) -> dict:
|
|||||||
return {"kind": "operator", "id": task, "command": command, "directory": directory}
|
return {"kind": "operator", "id": task, "command": command, "directory": directory}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_item(repository: str, branch: str, worktree: str, tip: str) -> dict:
|
||||||
|
return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip}
|
||||||
|
|
||||||
|
|
||||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ services:
|
|||||||
- /tree/branch-tree.json
|
- /tree/branch-tree.json
|
||||||
- --timers
|
- --timers
|
||||||
- /timers
|
- /timers
|
||||||
|
- --vendor
|
||||||
|
- /app/vendor
|
||||||
|
- --cleanup
|
||||||
|
- /state/cleanup
|
||||||
- --llm-url
|
- --llm-url
|
||||||
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
||||||
- --llm-model
|
- --llm-model
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from .asking import ask_state
|
from .asking import ask_state
|
||||||
|
from .cleanup import cleanup_request
|
||||||
from .errors import Conflict, Missing, ToolError
|
from .errors import Conflict, Missing, ToolError
|
||||||
from .fields import decode, expect, field
|
from .fields import decode, expect, field
|
||||||
from .records import digest, shown
|
from .records import digest, shown
|
||||||
@@ -142,7 +143,9 @@ class Approving:
|
|||||||
def sign(self, body: bytes) -> dict:
|
def sign(self, body: bytes) -> dict:
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("items",))
|
expect(payload, ("items",))
|
||||||
hashes = [signing.item_hash(item["signed"]) for item in self.checked(payload["items"]) if item["signed"] is not None]
|
raw = payload["items"]
|
||||||
|
signed = self.deletable(raw) if cleanup_request(raw) else [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
|
||||||
|
hashes = [signing.item_hash(item) for item in signed]
|
||||||
credentials = [key["id"] for key in self.keys()]
|
credentials = [key["id"] for key in self.keys()]
|
||||||
if not hashes:
|
if not hashes:
|
||||||
return {"payload": None, "challenge": None, "credentials": credentials}
|
return {"payload": None, "challenge": None, "credentials": credentials}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .records import epoch, iso_epoch, read_json, shown
|
||||||
|
from .register import ENDED, status_of
|
||||||
|
from .tree import inside, owns
|
||||||
|
|
||||||
|
PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b")
|
||||||
|
GATES = ("gate", "test")
|
||||||
|
LANE_LIMIT = 200
|
||||||
|
SHARED_SHOWN = 20
|
||||||
|
TITLE_LIMIT = 160
|
||||||
|
|
||||||
|
|
||||||
|
def artefacts_of(task: str, row: dict) -> list[dict]:
|
||||||
|
listed = row.get("artifacts")
|
||||||
|
found = []
|
||||||
|
for entry in listed if isinstance(listed, list) else []:
|
||||||
|
ref = entry.get("ref") if isinstance(entry, dict) else None
|
||||||
|
if isinstance(ref, str) and ":" in ref.strip(":"):
|
||||||
|
kind, _, name = ref.partition(":")
|
||||||
|
found.append({"task": task, "ref": ref, "kind": kind, "name": name, "added": shown(entry.get("added")), "ended": status_of(row) in ENDED})
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def lane(paths: list[str], reviews: list[dict], history: list[dict], jobs: list[dict]) -> list[dict]:
|
||||||
|
"""Args:
|
||||||
|
paths: the worktree paths of one branch.
|
||||||
|
reviews: the hub's reviews; an approved one stands for the commit
|
||||||
|
the agent makes right after the approval.
|
||||||
|
history: queue-history.jsonl rows; test and gate kinds count as gates.
|
||||||
|
jobs: the runner jobs; a push command counts as a push.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The branch's swimlane events, oldest first, each with ts, kind
|
||||||
|
(commit, review, gate or push), title and ok (true, false or None).
|
||||||
|
"""
|
||||||
|
def mine(path: object) -> bool:
|
||||||
|
return isinstance(path, str) and any(inside(path, folder) for folder in paths)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for review in (review for review in reviews if mine(review.get("worktree"))):
|
||||||
|
created, title = iso_epoch(review.get("created")), shown(review.get("title"))[:TITLE_LIMIT]
|
||||||
|
events.append({"ts": created, "kind": "review", "title": title, "ok": None})
|
||||||
|
if review.get("gate") in ("pass", "fail"):
|
||||||
|
events.append({"ts": created, "kind": "gate", "title": f"Gate {review['gate']}: {title}", "ok": review["gate"] == "pass"})
|
||||||
|
if review.get("status") == "approved":
|
||||||
|
events.append({"ts": iso_epoch(review.get("decided")), "kind": "commit", "title": title, "ok": True})
|
||||||
|
for row in (row for row in history if mine(row.get("workdir")) and str(row.get("kind", "")).startswith(GATES)):
|
||||||
|
events.append({"ts": epoch(row.get("end")) or epoch(row.get("start")), "kind": "gate", "title": shown(row.get("kind")), "ok": row.get("exit") == 0})
|
||||||
|
for job in (job for job in jobs if mine(job.get("directory")) and PUSH.search(str(job.get("command", "")))):
|
||||||
|
finished = job.get("status") in ("done", "failed", "refused")
|
||||||
|
when = epoch(job.get("finished")) or epoch(job.get("started")) or epoch(job.get("created_ts"))
|
||||||
|
events.append({"ts": when, "kind": "push", "title": shown(job.get("command"))[:TITLE_LIMIT], "ok": job.get("status") == "done" if finished else None})
|
||||||
|
return sorted((event for event in events if event["ts"] is not None), key=lambda event: event["ts"])[-LANE_LIMIT:]
|
||||||
|
|
||||||
|
|
||||||
|
class Touched:
|
||||||
|
def __init__(self, stored: Callable[[str, str], Path]) -> None:
|
||||||
|
"""Args:
|
||||||
|
stored: maps a review id and suffix to its stored file; the
|
||||||
|
.json lists the files the review touched and never changes,
|
||||||
|
so each is read once.
|
||||||
|
"""
|
||||||
|
self.stored = stored
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.known: dict[str, frozenset[str]] = {}
|
||||||
|
|
||||||
|
def of(self, review_id: str) -> frozenset[str]:
|
||||||
|
with self.lock:
|
||||||
|
if review_id not in self.known:
|
||||||
|
data = read_json(self.stored(review_id, ".json"))
|
||||||
|
files = data.get("files") if isinstance(data, dict) else None
|
||||||
|
self.known[review_id] = frozenset(entry["path"] for entry in files or [] if isinstance(entry, dict) and isinstance(entry.get("path"), str) and entry["path"])
|
||||||
|
return self.known[review_id]
|
||||||
|
|
||||||
|
|
||||||
|
def enrich(scan: dict, tasks: dict[str, dict], reviews: list[dict], touched: Touched, history: list[dict], jobs: list[dict]) -> dict:
|
||||||
|
"""Returns:
|
||||||
|
The scan with each worktree's register artefacts, the tasks whose
|
||||||
|
artefacts belong to no worktree, each branch's touched files and
|
||||||
|
swimlane events, and per repository the file overlaps between
|
||||||
|
branches and the merges the gc classification names.
|
||||||
|
"""
|
||||||
|
placed: set[str] = set()
|
||||||
|
repos = []
|
||||||
|
for entry in scan["repos"]:
|
||||||
|
branches, files = [], {}
|
||||||
|
for item in entry["branches"]:
|
||||||
|
trees = []
|
||||||
|
for tree in item["worktrees"]:
|
||||||
|
held = [(task, row) for task, row in sorted(tasks.items()) if owns(row, tree["path"], item["name"])]
|
||||||
|
placed.update(task for task, _ in held)
|
||||||
|
trees.append({**tree, "artefacts": [found for task, row in held for found in artefacts_of(task, row)]})
|
||||||
|
paths = [tree["path"] for tree in trees]
|
||||||
|
files[item["name"]] = frozenset().union(*(touched.of(review["id"]) for review in reviews if isinstance(review.get("worktree"), str) and any(inside(review["worktree"], path) for path in paths)))
|
||||||
|
branches.append({**item, "worktrees": trees, "files": len(files[item["name"]]), "events": lane(paths, reviews, history, jobs)})
|
||||||
|
overlaps = []
|
||||||
|
for a, b in itertools.combinations(sorted(files), 2):
|
||||||
|
shared = sorted(files[a] & files[b])
|
||||||
|
if shared:
|
||||||
|
overlaps.append({"a": a, "b": b, "count": len(shared), "files": shared[:SHARED_SHOWN]})
|
||||||
|
merges = [{"from": item["name"], "into": item["merged_into"] or entry.get("default") or "", "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]]
|
||||||
|
repos.append({**entry, "branches": branches, "overlaps": overlaps, "merges": merges})
|
||||||
|
loose = [{"task": task, "title": shown(row.get("title")), "status": status_of(row), "artefacts": artefacts_of(task, row)} for task, row in sorted(tasks.items()) if task not in placed and artefacts_of(task, row)]
|
||||||
|
return {**scan, "repos": repos, "tasks": loose}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .errors import Missing, ToolError
|
||||||
|
from .fields import decode, expect
|
||||||
|
from .records import read_json, stamp
|
||||||
|
from .tree import offered
|
||||||
|
|
||||||
|
ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip")
|
||||||
|
JOB_KEYS = ("repository", "branch", "worktree", "tip")
|
||||||
|
JOB_VERSION = 1
|
||||||
|
ITEM_LIMIT = 100
|
||||||
|
RESULTS_SHOWN = 20
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Spool:
|
||||||
|
"""Args:
|
||||||
|
root: the host-mounted directory; the hub writes signed jobs into
|
||||||
|
jobs/ by rename, the host runner writes each result into
|
||||||
|
results/<id>.json and removes the job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def jobs(self) -> Path:
|
||||||
|
return self.root / "jobs"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def results(self) -> Path:
|
||||||
|
return self.root / "results"
|
||||||
|
|
||||||
|
def ready(self) -> Spool:
|
||||||
|
for folder in (self.root, self.jobs, self.results):
|
||||||
|
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def put(self, job_id: str, job: dict) -> None:
|
||||||
|
# written beside jobs/ and renamed in: the path unit wakes on a non-empty jobs/, never on a half-written file
|
||||||
|
with tempfile.NamedTemporaryFile("w", dir=self.root, prefix=f".{job_id}-", delete=False, encoding="utf-8") as handle:
|
||||||
|
json.dump(job, handle, sort_keys=True)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(handle.name, self.jobs / f"{job_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_request(raw: object) -> bool:
|
||||||
|
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == "cleanup" for entry in raw)
|
||||||
|
|
||||||
|
|
||||||
|
class Cleaning:
|
||||||
|
def deletable(self, raw: object) -> list[dict]:
|
||||||
|
"""Args:
|
||||||
|
raw: the items the page chose, each {"kind": "cleanup",
|
||||||
|
"repository", "branch", "worktree", "tip"} as /api/tree
|
||||||
|
offered it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The signed items, each still a merged-clean branch without a gc
|
||||||
|
skip reason at the same tip in the current tree file.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, list) or not 1 <= len(raw) <= ITEM_LIMIT:
|
||||||
|
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
|
||||||
|
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'}")
|
||||||
|
known = offered(scan)
|
||||||
|
chosen = []
|
||||||
|
for entry in raw:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ToolError("every item must be an object with kind, repository, branch, worktree and tip")
|
||||||
|
expect(entry, ITEM_KEYS)
|
||||||
|
if entry not in known:
|
||||||
|
raise ToolError(f"{entry.get('branch')!s:.200} in {entry.get('repository')!s:.200} is no merged-clean branch without skip reason at that tip in the tree file")
|
||||||
|
if entry in chosen:
|
||||||
|
raise ToolError("items must be distinct")
|
||||||
|
chosen.append(entry)
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
def cleanup(self, body: bytes) -> dict:
|
||||||
|
payload = decode(body)
|
||||||
|
expect(payload, ("items", "proof"))
|
||||||
|
items = self.deletable(payload["items"])
|
||||||
|
proof = self.covered(payload["proof"], items, time.time())
|
||||||
|
job_id = secrets.token_hex(8)
|
||||||
|
job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{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"}})
|
||||||
|
return {"id": job_id, "status": "queued", "items": job["items"]}
|
||||||
|
|
||||||
|
def cleanup_state(self, job_id: str) -> dict:
|
||||||
|
result = read_json(self.spool.results / f"{job_id}.json")
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("items"), list):
|
||||||
|
return {**result, "id": job_id, "status": "done"}
|
||||||
|
if (self.spool.jobs / f"{job_id}.json").is_file():
|
||||||
|
return {"id": job_id, "status": "queued", "items": []}
|
||||||
|
raise Missing(job_id)
|
||||||
|
|
||||||
|
def cleanup_results(self) -> list[dict]:
|
||||||
|
try:
|
||||||
|
paths = sorted(self.spool.results.glob("*.json"), key=lambda path: path.stat().st_mtime)[-RESULTS_SHOWN:]
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
found = [read_json(path) for path in paths]
|
||||||
|
return [result for result in found if isinstance(result, dict) and isinstance(result.get("items"), list)]
|
||||||
@@ -34,7 +34,12 @@ RUNNER_ROUTE = re.compile(r"/api/runner/([0-9a-f]{16})/(claim|report)")
|
|||||||
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
|
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
|
||||||
SIGN_ROUTE = "/api/sign"
|
SIGN_ROUTE = "/api/sign"
|
||||||
RULE_SIGN_ROUTE = "/api/rules/sign"
|
RULE_SIGN_ROUTE = "/api/rules/sign"
|
||||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE)
|
CLEANUP_ROUTE = "/api/cleanup"
|
||||||
|
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
|
||||||
|
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
||||||
|
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
|
||||||
|
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
||||||
|
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE)
|
||||||
|
|
||||||
|
|
||||||
class Handler(Exchange):
|
class Handler(Exchange):
|
||||||
@@ -52,10 +57,16 @@ class Handler(Exchange):
|
|||||||
item = ITEM.fullmatch(path)
|
item = ITEM.fullmatch(path)
|
||||||
entry = ENTRY.fullmatch(path)
|
entry = ENTRY.fullmatch(path)
|
||||||
proof = PROOF_ROUTE.fullmatch(path)
|
proof = PROOF_ROUTE.fullmatch(path)
|
||||||
|
vendored = VENDOR_ROUTE.fullmatch(path)
|
||||||
|
cleanup = CLEANUP_STATE.fullmatch(path)
|
||||||
if self.host() is None:
|
if self.host() is None:
|
||||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
||||||
elif path == "/":
|
elif path == "/":
|
||||||
self.index()
|
self.index()
|
||||||
|
elif vendored and vendored.group(1) in VENDOR:
|
||||||
|
self.vendored(VENDOR[vendored.group(1)])
|
||||||
|
elif cleanup:
|
||||||
|
self.answer(lambda: hub.cleanup_state(cleanup.group(1)), HTTPStatus.OK)
|
||||||
elif proof:
|
elif proof:
|
||||||
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
||||||
elif path == "/api/state":
|
elif path == "/api/state":
|
||||||
@@ -112,6 +123,8 @@ class Handler(Exchange):
|
|||||||
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
|
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
|
||||||
if path == RULE_SIGN_ROUTE:
|
if path == RULE_SIGN_ROUTE:
|
||||||
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
|
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
|
||||||
|
if path == CLEANUP_ROUTE:
|
||||||
|
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
|
||||||
if inbox:
|
if inbox:
|
||||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||||
if undo:
|
if undo:
|
||||||
@@ -188,6 +201,10 @@ class Handler(Exchange):
|
|||||||
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
|
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
|
||||||
self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers)
|
self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers)
|
||||||
|
|
||||||
|
def vendored(self, name: str) -> None:
|
||||||
|
headers = {"Content-Security-Policy": PLAIN_CSP, "Cache-Control": "private, max-age=86400"}
|
||||||
|
self.attempt((self.server.hub.vendor / name).read_bytes, lambda data: self.send(HTTPStatus.OK, SCRIPT_TYPE, data, headers))
|
||||||
|
|
||||||
def stored(self, review_id: str, name: str) -> None:
|
def stored(self, review_id: str, name: str) -> None:
|
||||||
suffix, content_type, csp = STORED[name]
|
suffix, content_type, csp = STORED[name]
|
||||||
reviews = self.server.hub.reviews
|
reviews = self.server.hub.reviews
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import jinja2
|
import jinja2
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from .approving import Approving
|
from .approving import Approving
|
||||||
|
from .artifacts import Touched
|
||||||
from .asking import Asking
|
from .asking import Asking
|
||||||
from .batches import Pending
|
from .batches import Pending
|
||||||
|
from .cleanup import Cleaning, Spool
|
||||||
from .engines import Engines, Speech
|
from .engines import Engines, Speech
|
||||||
from .events import Log, Watch
|
from .events import Log, Watch
|
||||||
|
from .icons import sheet
|
||||||
from .inbox import Inbox
|
from .inbox import Inbox
|
||||||
from .jobs import Jobs
|
from .jobs import Jobs
|
||||||
from .language import Language, Model
|
from .language import Language, Model
|
||||||
@@ -34,6 +38,8 @@ from .view import View
|
|||||||
class Places:
|
class Places:
|
||||||
tree_file: Path
|
tree_file: Path
|
||||||
timers: Path
|
timers: Path
|
||||||
|
vendor: Path
|
||||||
|
cleanup: Path
|
||||||
|
|
||||||
|
|
||||||
def environment(templates: Path) -> jinja2.Environment:
|
def environment(templates: Path) -> jinja2.Environment:
|
||||||
@@ -49,7 +55,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
|
||||||
council: Path
|
council: Path
|
||||||
pages: Path
|
pages: Path
|
||||||
public_port: int
|
public_port: int
|
||||||
@@ -75,6 +81,10 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
|
|||||||
settings: Settings
|
settings: Settings
|
||||||
speech: Speech
|
speech: Speech
|
||||||
approvers: Path
|
approvers: Path
|
||||||
|
vendor: Path
|
||||||
|
icons: Markup
|
||||||
|
spool: Spool
|
||||||
|
touched: Touched
|
||||||
|
|
||||||
def hosts(self) -> tuple[str, str]:
|
def hosts(self) -> tuple[str, str]:
|
||||||
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
|
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
|
||||||
@@ -86,7 +96,12 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
|
|||||||
def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_state: Path, transcripts: Path, scripts: Runner, templates: Path, places: Places, model: Model, engines: Engines, approvers: Path) -> Hub:
|
def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_state: Path, transcripts: Path, scripts: Runner, templates: Path, places: Places, model: Model, engines: Engines, approvers: Path) -> Hub:
|
||||||
uploads = state / "uploads"
|
uploads = state / "uploads"
|
||||||
uploads.mkdir(exist_ok=True)
|
uploads.mkdir(exist_ok=True)
|
||||||
|
reviews = Reviews(state)
|
||||||
return Hub(
|
return Hub(
|
||||||
|
vendor=places.vendor,
|
||||||
|
icons=sheet(places.vendor, templates),
|
||||||
|
spool=Spool(places.cleanup).ready(),
|
||||||
|
touched=Touched(reviews.stored),
|
||||||
approvers=approvers,
|
approvers=approvers,
|
||||||
speech=Speech(engines, state / "tts"),
|
speech=Speech(engines, state / "tts"),
|
||||||
rules=Ledger(state / "rules.json"),
|
rules=Ledger(state / "rules.json"),
|
||||||
@@ -103,7 +118,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
|||||||
council=council,
|
council=council,
|
||||||
pages=pages,
|
pages=pages,
|
||||||
public_port=public_port,
|
public_port=public_port,
|
||||||
reviews=Reviews(state),
|
reviews=reviews,
|
||||||
asks=Ledger(state / "asks.json"),
|
asks=Ledger(state / "asks.json"),
|
||||||
decisions=Ledger(state / "decisions.json"),
|
decisions=Ledger(state / "decisions.json"),
|
||||||
messages=Ledger(state / "messages.json"),
|
messages=Ledger(state / "messages.json"),
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
|
from .errors import ToolError
|
||||||
|
|
||||||
|
SPRITE = Path("bootstrap-icons") / "bootstrap-icons.svg"
|
||||||
|
SYMBOL = re.compile(r'<symbol class="bi bi-[a-z0-9-]+" viewBox="0 0 16 16" id="([a-z0-9-]+)">(.*?)</symbol>', re.DOTALL)
|
||||||
|
NAME = re.compile(r'"#?bi-([a-z0-9]+(?:-[a-z0-9]+)*)"')
|
||||||
|
SOURCES = ("*.js", "*.j2")
|
||||||
|
|
||||||
|
|
||||||
|
def symbols(sprite: Path) -> dict[str, str]:
|
||||||
|
return dict(SYMBOL.findall(sprite.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def used(templates: Path) -> list[str]:
|
||||||
|
names: set[str] = set()
|
||||||
|
for pattern in SOURCES:
|
||||||
|
for path in sorted(templates.glob(pattern)):
|
||||||
|
names.update(NAME.findall(path.read_text(encoding="utf-8")))
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
|
|
||||||
|
def sheet(vendor: Path, templates: Path) -> Markup:
|
||||||
|
"""Args:
|
||||||
|
vendor: the directory holding the vendored Bootstrap Icons sprite.
|
||||||
|
templates: the page sources; every string literal "bi-<name>" or
|
||||||
|
"#bi-<name>" in them names an icon the page shows.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A hidden inline SVG holding exactly those symbols, each as
|
||||||
|
bi-<name>, so the page draws them with <use href="#bi-<name>"> and
|
||||||
|
loads nothing.
|
||||||
|
"""
|
||||||
|
known = symbols(vendor / SPRITE)
|
||||||
|
names = used(templates)
|
||||||
|
missing = [name for name in names if name not in known]
|
||||||
|
if missing:
|
||||||
|
raise ToolError(f"icons missing from {vendor / SPRITE}: {', '.join(missing)}")
|
||||||
|
body = "".join(f'<symbol id="bi-{name}" viewBox="0 0 16 16">{known[name]}</symbol>' for name in names)
|
||||||
|
return Markup(f'<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true" focusable="false">{body}</svg>')
|
||||||
@@ -2,13 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from .artifacts import enrich
|
||||||
from .council import HUMAN, agent, read_claims
|
from .council import HUMAN, agent, read_claims
|
||||||
from .crons import cron_state, queue_rows, timer_rows
|
from .crons import cron_state, queue_rows, timer_rows
|
||||||
from .errors import ToolError
|
from .errors import ToolError
|
||||||
from .fields import single
|
from .fields import single
|
||||||
from .lifetimes import edge_times, lifetime, session_gone, subagent_rows
|
from .lifetimes import edge_times, lifetime, session_gone, subagent_rows
|
||||||
from .names import PSEUDO, SESSION_ID, Directory, title_sightings
|
from .names import PSEUDO, SESSION_ID, Directory, title_sightings
|
||||||
from .records import epoch, iso_epoch
|
from .records import epoch, iso_epoch, read_jsonl
|
||||||
from .register import REGISTER, read_register
|
from .register import REGISTER, read_register
|
||||||
from .tree import assign
|
from .tree import assign
|
||||||
|
|
||||||
@@ -45,7 +46,10 @@ class Sources:
|
|||||||
|
|
||||||
def repositories(self) -> dict:
|
def repositories(self) -> dict:
|
||||||
tasks = read_register(self.council / REGISTER)
|
tasks = read_register(self.council / REGISTER)
|
||||||
return assign(self.tree.get(), tasks, read_claims(self.council), self.reviews.listed())
|
reviews = self.reviews.listed()
|
||||||
|
scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews)
|
||||||
|
history = read_jsonl(self.council / "queue-history.jsonl")
|
||||||
|
return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results()}
|
||||||
|
|
||||||
def schedules(self, claims: list[dict], now: float) -> list[dict]:
|
def schedules(self, claims: list[dict], now: float) -> list[dict]:
|
||||||
beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
|
beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from .council import agent
|
from .council import agent
|
||||||
from .records import iso_epoch
|
from .records import iso_epoch
|
||||||
from .register import ENDED, status_of
|
from .register import ENDED, status_of
|
||||||
|
from .signed import signing
|
||||||
|
|
||||||
LABEL_PARTS = 3
|
LABEL_PARTS = 3
|
||||||
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
|
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
|
||||||
@@ -31,19 +32,47 @@ def worktree(item: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def branch(item: dict) -> dict:
|
def dry_run(item: dict) -> list[str]:
|
||||||
|
"""Returns:
|
||||||
|
The lines `pkgmgr branch cleanup --local` would act on for one tree
|
||||||
|
item, read from the gc plan the tree file carries; a remote branch
|
||||||
|
always stays.
|
||||||
|
"""
|
||||||
|
tip = (item["local_sha"] or item["head"] or "")[:12]
|
||||||
|
merged = f", gemergt durch {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']}")
|
||||||
|
elif action == "local-branch":
|
||||||
|
lines.append(f"löscht den lokalen Branch {item['branch']}")
|
||||||
|
elif action.startswith("remote"):
|
||||||
|
lines.append(f"bleibt: {action} (nur lokal)")
|
||||||
|
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"]]
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def branch(item: dict, path: str) -> dict:
|
||||||
remote = next(iter(item["remotes"].values()), "")
|
remote = next(iter(item["remotes"].values()), "")
|
||||||
|
head = item["local_sha"] or item["head"] or remote
|
||||||
|
open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"])
|
||||||
return {
|
return {
|
||||||
"name": item["branch"],
|
"name": item["branch"],
|
||||||
"head": item["local_sha"] or item["head"] or remote,
|
"head": head,
|
||||||
"upstream": item["upstream"] or "",
|
"upstream": item["upstream"] or "",
|
||||||
"ahead": item["ahead"] or 0,
|
"ahead": item["ahead"] or 0,
|
||||||
"behind": item["behind"] or 0,
|
"behind": item["behind"] or 0,
|
||||||
"gone": item["gone"],
|
"gone": item["gone"],
|
||||||
"state": item["state"],
|
"state": item["state"],
|
||||||
"merged_by": item["merged_by"],
|
"merged_by": item["merged_by"],
|
||||||
|
"merged_into": item["merged_into"],
|
||||||
"skip": item["skip"],
|
"skip": item["skip"],
|
||||||
"reason": item["reason"],
|
"reason": item["reason"],
|
||||||
|
"plan": dry_run(item),
|
||||||
|
"cleanup": signing.cleanup_item(path, item["branch"], item["worktree"] or "", item["local_sha"]) if open_to_delete else None,
|
||||||
"worktrees": [worktree(item)] if item["worktree"] else [],
|
"worktrees": [worktree(item)] if item["worktree"] else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +82,15 @@ def repo(entry: dict) -> dict:
|
|||||||
return {
|
return {
|
||||||
**{key: value for key, value in entry.items() if key != "items"},
|
**{key: value for key, value in entry.items() if key != "items"},
|
||||||
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]),
|
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]),
|
||||||
"branches": [branch(item) for item in items if item["branch"]],
|
"branches": [branch(item, entry["path"]) for item in items if item["branch"]],
|
||||||
"detached": [worktree(item) for item in items if item["detached"]],
|
"detached": [worktree(item) for item in items if item["detached"]],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def offered(scan: dict) -> list[dict]:
|
||||||
|
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None]
|
||||||
|
|
||||||
|
|
||||||
def read_tree(path: Path) -> dict:
|
def read_tree(path: Path) -> dict:
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
@@ -75,6 +108,13 @@ def inside(path: str, folder: str) -> bool:
|
|||||||
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
|
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
|
||||||
|
|
||||||
|
|
||||||
|
def owns(row: dict, path: str, name: str) -> bool:
|
||||||
|
workdir = row.get("workdir")
|
||||||
|
if isinstance(workdir, str):
|
||||||
|
return workdir.startswith("/") and inside(workdir, path)
|
||||||
|
return row.get("branch") == name
|
||||||
|
|
||||||
|
|
||||||
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
|
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
|
||||||
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
|
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
|
||||||
holders: dict[str, set[str]] = {}
|
holders: dict[str, set[str]] = {}
|
||||||
@@ -92,10 +132,7 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list
|
|||||||
for tree in item["worktrees"]:
|
for tree in item["worktrees"]:
|
||||||
workers = set()
|
workers = set()
|
||||||
for task, row in current.items():
|
for task, row in current.items():
|
||||||
workdir, name = row.get("workdir"), row.get("branch")
|
if owns(row, tree["path"], item["name"]):
|
||||||
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
|
|
||||||
by_branch = not isinstance(workdir, str) and name == item["name"]
|
|
||||||
if by_dir or by_branch:
|
|
||||||
workers |= holders[task]
|
workers |= holders[task]
|
||||||
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
||||||
branches.append({**item, "worktrees": trees})
|
branches.append({**item, "worktrees": trees})
|
||||||
|
|||||||
@@ -132,5 +132,5 @@ class View:
|
|||||||
def render(self, nonce: str) -> tuple[str, str]:
|
def render(self, nonce: str) -> tuple[str, str]:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
state = self.state(now - RADIO_WINDOW, now, now, 0)
|
state = self.state(now - RADIO_WINDOW, now, now, 0)
|
||||||
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state)
|
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state, icons=self.icons)
|
||||||
return page, INDEX_CSP.format(nonce, " ".join(["'self'", *state["media"]]))
|
return page, INDEX_CSP.format(nonce, " ".join(["'self'", *state["media"]]))
|
||||||
@@ -22,6 +22,11 @@
|
|||||||
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
|
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
|
||||||
read-only; read again whenever it changes.
|
read-only; read again whenever it changes.
|
||||||
--timers: directory holding the systemd user units (*.timer).
|
--timers: directory holding the systemd user units (*.timer).
|
||||||
|
--vendor: directory holding the vendored Bootstrap Icons sprite,
|
||||||
|
3d-force-graph and three with their licence texts.
|
||||||
|
--cleanup: host-mounted directory the hub writes signed branch deletions
|
||||||
|
into (jobs/) and reads their results from (results/); the host's
|
||||||
|
kvb-orchestrator-cleanup path unit runs them.
|
||||||
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
|
--llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the
|
||||||
natural-language command line off.
|
natural-language command line off.
|
||||||
--llm-model: model name sent to that endpoint; empty turns it off.
|
--llm-model: model name sent to that endpoint; empty turns it off.
|
||||||
@@ -61,7 +66,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--bind", required=True)
|
parser.add_argument("--bind", required=True)
|
||||||
for name in ("--listen-port", "--public-port"):
|
for name in ("--listen-port", "--public-port"):
|
||||||
parser.add_argument(name, type=int, required=True)
|
parser.add_argument(name, type=int, required=True)
|
||||||
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--llm-key-file", "--approvers")
|
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--llm-key-file", "--approvers")
|
||||||
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
|
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
|
||||||
parser.add_argument(name, type=Path, required=True)
|
parser.add_argument(name, type=Path, required=True)
|
||||||
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
||||||
@@ -77,7 +82,7 @@ def main() -> int:
|
|||||||
transcripts=args.transcripts,
|
transcripts=args.transcripts,
|
||||||
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
||||||
templates=args.templates,
|
templates=args.templates,
|
||||||
places=Places(tree_file=args.tree_file, timers=args.timers),
|
places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup),
|
||||||
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
|
model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)),
|
||||||
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
|
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
|
||||||
approvers=args.approvers,
|
approvers=args.approvers,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Agent Orchestrator cleanup: wake on a signed branch deletion the Agent Orchestrator wrote
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
DirectoryNotEmpty=%h/.local/state/kvb-orchestrator/cleanup/jobs
|
||||||
|
MakeDirectory=true
|
||||||
|
DirectoryMode=0700
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Agent Orchestrator cleanup: verify the operator signature, then delete exactly the signed merged branches locally through pkgmgr
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=PATH=/usr/bin:/bin
|
||||||
|
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
|
||||||
|
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/cleanup.py --port ${KVB_ORCHESTRATOR_PORT} --jobs %h/.local/state/kvb-orchestrator/cleanup/jobs --results %h/.local/state/kvb-orchestrator/cleanup/results --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/cleanup.json --tree %h/.local/state/pkgmgr/tree/branch-tree.json --pkgmgr %h/.local/share/kvb-orchestrator/pkgmgr/bin/pkgmgr
|
||||||
|
TimeoutStartSec=1800
|
||||||
@@ -46,7 +46,11 @@ h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01e
|
|||||||
.subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); }
|
.subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); }
|
||||||
.subtabs button { font-size: var(--fs-sm); }
|
.subtabs button { font-size: var(--fs-sm); }
|
||||||
.tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; }
|
.tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; }
|
||||||
.icon-btn { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
|
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
.bi { flex: none; width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; }
|
||||||
|
.icon-only { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; }
|
||||||
|
.btn.small.icon-only { min-width: 30px; padding: 4px 8px; font-size: var(--fs-md); }
|
||||||
|
.bi.caret { width: .7em; height: .7em; opacity: .7; }
|
||||||
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
|
.btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
|
||||||
.tabs button:hover { background: var(--hover); color: var(--text); }
|
.tabs button:hover { background: var(--hover); color: var(--text); }
|
||||||
.tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
|
.tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
|
||||||
@@ -256,6 +260,23 @@ svg .mark:hover { opacity: 1; }
|
|||||||
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
|
svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; }
|
||||||
svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
|
svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); }
|
||||||
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
|
svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
|
||||||
|
svg .node.a-repo circle, svg .node.sun circle { fill: color-mix(in srgb, var(--warn) 70%, var(--panel)); stroke: var(--warn); stroke-width: 3; }
|
||||||
|
svg .node.a-worktree circle { fill: var(--sunken); stroke: var(--muted); }
|
||||||
|
svg .node.a-worktree.dirty circle { fill: var(--warn-soft); stroke: var(--warn); }
|
||||||
|
svg .node.satellite circle { fill: var(--accent); stroke: none; }
|
||||||
|
svg .node.cargo circle { fill: var(--teal); stroke: none; }
|
||||||
|
svg .node.tone-ok circle { fill: var(--ok-soft); stroke: var(--ok); }
|
||||||
|
svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); }
|
||||||
|
svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); }
|
||||||
|
svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); }
|
||||||
|
svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); }
|
||||||
|
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
|
||||||
|
svg .edge.merge { stroke-dasharray: 5 5; }
|
||||||
|
svg .lane-mark { stroke-width: 1.2; }
|
||||||
|
svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); }
|
||||||
|
svg .lane-mark.tone-bad { fill: var(--bad); stroke: var(--bad); }
|
||||||
|
svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); }
|
||||||
|
svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); }
|
||||||
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
|
input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); }
|
||||||
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
.custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
|
.avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; }
|
||||||
@@ -358,16 +379,40 @@ body.sky-look .sky:active { cursor: grabbing; }
|
|||||||
.sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
|
.sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
|
||||||
.sky-panel[hidden] { display: none; }
|
.sky-panel[hidden] { display: none; }
|
||||||
.compass { min-width: 220px; font-weight: 620; font-variant-numeric: tabular-nums; text-align: center; }
|
.compass { min-width: 220px; font-weight: 620; font-variant-numeric: tabular-nums; text-align: center; }
|
||||||
|
.states { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
||||||
|
.state { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); font-weight: 650; line-height: 20px; white-space: nowrap; }
|
||||||
|
.state[data-tone="ok"] { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.state[data-tone="bad"] { background: var(--bad-soft); color: var(--bad); }
|
||||||
|
.state[data-tone="warn"] { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.state[data-tone="info"] { background: var(--info-soft); color: var(--info); }
|
||||||
|
.state[data-tone="violet"] { background: var(--violet-soft); color: var(--violet); }
|
||||||
|
.wt-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; width: 100%; }
|
||||||
|
.leaves { display: grid; gap: 4px; width: 100%; margin: 0; padding: 0 0 0 18px; list-style: none; }
|
||||||
|
.leaf { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-sm); }
|
||||||
|
.hint { color: var(--muted); font-size: var(--fs-xs); }
|
||||||
|
.delete-all { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
|
.dry-run { margin: 4px 0 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-family: var(--mono); font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.spawn-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 18px; list-style: none; }
|
||||||
|
.spawn-tree.root { padding-left: 0; }
|
||||||
|
.spawn-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 5px 8px; border-radius: 8px; }
|
||||||
|
.spawn-row:hover { background: var(--hover); }
|
||||||
|
.graph3d { position: relative; width: 100%; height: clamp(360px, 68vh, 820px); overflow: hidden; border-radius: var(--radius-sm); cursor: grab; touch-action: none; }
|
||||||
|
.graph3d:active { cursor: grabbing; }
|
||||||
|
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
|
||||||
|
.graph3d .scene-container { position: relative; }
|
||||||
|
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{{ icons }}
|
||||||
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
|
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
|
||||||
<div class="head" id="head">
|
<div class="head" id="head">
|
||||||
<header class="top">
|
<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="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">
|
<div class="tools">
|
||||||
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle">⌘K</button>
|
<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-btn" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen">⚙</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
@@ -438,10 +483,16 @@ body.sky-look .sky:active { cursor: grabbing; }
|
|||||||
|
|
||||||
{% include "page-space-steer.js" %}
|
{% include "page-space-steer.js" %}
|
||||||
|
|
||||||
{% include "page-space-scene.js" %}
|
{% include "page-spawn.js" %}
|
||||||
|
|
||||||
|
{% include "page-graph3d.js" %}
|
||||||
|
|
||||||
|
{% include "page-artifacts.js" %}
|
||||||
|
|
||||||
{% include "page-tree.js" %}
|
{% include "page-tree.js" %}
|
||||||
|
|
||||||
|
{% include "page-cleanup.js" %}
|
||||||
|
|
||||||
{% include "page-time.js" %}
|
{% include "page-time.js" %}
|
||||||
|
|
||||||
{% include "page-pulses.js" %}
|
{% include "page-pulses.js" %}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ const Nav = (() => {
|
|||||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||||
|
const ICONS = {
|
||||||
|
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
|
||||||
|
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d",
|
||||||
|
};
|
||||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||||
const known = (view) => VIEWS.some(([id]) => id === view);
|
const known = (view) => VIEWS.some(([id]) => id === view);
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ const Nav = (() => {
|
|||||||
return {screen: "stream", view: null, redirect: null};
|
return {screen: "stream", view: null, redirect: null};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {TABS, SUBS, VIEWS, route, hashOf, tabOf};
|
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
|
||||||
})();
|
})();
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports) {
|
||||||
module.exports = Nav;
|
module.exports = Nav;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
ui.agent = key;
|
ui.agent = key;
|
||||||
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
|
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 file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
|
||||||
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
|
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Senden")}, icon("bi-send"));
|
||||||
const classes = choice("to-one", TO_ONE, "post");
|
const classes = choice("to-one", TO_ONE, "post");
|
||||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
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; prüfbar mit operator_message."),
|
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||||
drawer.replaceChildren(
|
drawer.replaceChildren(
|
||||||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||||||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")),
|
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-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
|
||||||
form);
|
form);
|
||||||
drawer.hidden = false;
|
drawer.hidden = false;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
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: "Benachrichtigungen an", denied: "Benachrichtigungen blockiert", default: "Benachrichtigungen aktivieren", unsupported: "Benachrichtigungen fehlen im Browser"};
|
||||||
button.textContent = labels[permission] || labels.default;
|
relabel(button, "bi-bell", labels[permission] || labels.default);
|
||||||
button.disabled = permission !== "default";
|
button.disabled = permission !== "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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 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("/");
|
||||||
|
const epochOf = (text) => {
|
||||||
|
const value = Date.parse(text);
|
||||||
|
return Number.isFinite(value) ? value / 1000 : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function artifactModel(data) {
|
||||||
|
const nodes = [];
|
||||||
|
const links = [];
|
||||||
|
const add = (node) => {
|
||||||
|
nodes.push(node);
|
||||||
|
if (node.parent) {
|
||||||
|
links.push({source: node.parent, target: node.id, kind: "contains"});
|
||||||
|
}
|
||||||
|
return node.id;
|
||||||
|
};
|
||||||
|
const leaves = (holder, wt, depth) => {
|
||||||
|
for (const name of wt.workers || []) {
|
||||||
|
add({id: `agent:${name}@${wt.path}`, kind: "agent", label: name, depth, ts: holder.ts, parent: holder.id, data: {name}, repo: holder.repo});
|
||||||
|
}
|
||||||
|
for (const found of wt.artefacts || []) {
|
||||||
|
add({id: `artefact:${holder.id}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth, ts: epochOf(found.added) || holder.ts, parent: holder.id, data: found, repo: holder.repo});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const repo of data.repos || []) {
|
||||||
|
const base = epochOf(repo.scanned_at) || data.scanned || nowSeconds();
|
||||||
|
const rid = add({id: `repo:${repo.path}`, kind: "repo", label: repo.name, depth: 0, ts: base, parent: null, data: repo, repo: repo.path});
|
||||||
|
for (const branch of repo.branches) {
|
||||||
|
const last = branch.events && branch.events.length ? branch.events[branch.events.length - 1].ts : base;
|
||||||
|
const bid = add({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name, depth: 1, ts: last, parent: rid, data: branch, repo: repo.path});
|
||||||
|
for (const wt of branch.worktrees) {
|
||||||
|
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 2, ts: last, parent: bid, data: {...wt, branch: branch.name}, repo: repo.path};
|
||||||
|
add(holder);
|
||||||
|
leaves(holder, wt, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const wt of repo.detached) {
|
||||||
|
const holder = {id: `wt:${wt.path}`, kind: "worktree", label: tail(wt.path, 2), depth: 1, ts: base, parent: rid, data: {...wt, branch: ""}, repo: repo.path};
|
||||||
|
add(holder);
|
||||||
|
leaves(holder, wt, 2);
|
||||||
|
}
|
||||||
|
for (const merge of repo.merges || []) {
|
||||||
|
links.push({source: `branch:${repo.path}\u0000${merge.from}`, target: `branch:${repo.path}\u0000${merge.into}`, kind: "merge", by: merge.by});
|
||||||
|
}
|
||||||
|
for (const overlap of repo.overlaps || []) {
|
||||||
|
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const task of data.tasks || []) {
|
||||||
|
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
|
||||||
|
for (const found of task.artefacts) {
|
||||||
|
add({id: `artefact:${tid}\u0000${found.task}\u0000${found.ref}`, kind: "artefact", label: found.name, depth: 1, ts: epochOf(found.added) || nowSeconds(), parent: tid, data: found, repo: ""});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ids = new Set(nodes.map((node) => node.id));
|
||||||
|
return {nodes, links: links.filter((link) => ids.has(link.source) && ids.has(link.target)), byId: new Map(nodes.map((node) => [node.id, node]))};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chosenRepo() {
|
||||||
|
const repos = tree.data && !tree.data.error ? tree.data.repos : [];
|
||||||
|
const weight = (repo) => repo.branches.length + repo.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0);
|
||||||
|
const found = repos.find((repo) => repo.path === art.repo) || [...repos].sort((a, b) => weight(b) - weight(a))[0] || null;
|
||||||
|
art.repo = found ? found.path : "";
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artSelect(id) {
|
||||||
|
art.node = art.node === id ? null : id;
|
||||||
|
drawArtifacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function artMark(node, at, radius, extra, caption) {
|
||||||
|
const [kindName] = ART_KINDS[node.kind];
|
||||||
|
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" ");
|
||||||
|
return s("g", {
|
||||||
|
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`,
|
||||||
|
on: {
|
||||||
|
click: () => artSelect(node.id),
|
||||||
|
keydown: (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
artSelect(node.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, s("title", {}, `${node.label} · ${kindName}`), s("circle", {r: radius}), caption ? s("text", {class: "label", y: radius + 14, "text-anchor": "middle"}, caption) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function around(base, index, count, distance) {
|
||||||
|
const angle = (base.angle || 0) + ((index - (count - 1) / 2) * Math.PI) / Math.max(count + 2, 5);
|
||||||
|
return {x: base.x + distance * Math.cos(angle), y: base.y + distance * Math.sin(angle), angle};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rings = repo.branches.length > 16 ? [200, 310] : [250];
|
||||||
|
svg.append(...rings.map((r) => s("circle", {class: "ring", r})));
|
||||||
|
const layer = s("g", {});
|
||||||
|
repo.branches.forEach((branch, index) => {
|
||||||
|
const ring = rings[index % rings.length];
|
||||||
|
const angle = -Math.PI / 2 + (2 * Math.PI * index) / Math.max(repo.branches.length, 1);
|
||||||
|
const at = {x: ring * Math.cos(angle), y: ring * Math.sin(angle), angle};
|
||||||
|
const radius = Math.min(11 + 4 * Math.log2(1 + branch.ahead), 30);
|
||||||
|
const node = model.byId.get(`branch:${repo.path}\u0000${branch.name}`);
|
||||||
|
branch.worktrees.forEach((wt, slot) => {
|
||||||
|
const moon = around(at, slot, branch.worktrees.length, radius + 18);
|
||||||
|
const holder = model.byId.get(`wt:${wt.path}`);
|
||||||
|
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
|
||||||
|
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
|
||||||
|
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
|
||||||
|
});
|
||||||
|
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
|
||||||
|
});
|
||||||
|
layer.append(artMark(model.byId.get(`repo:${repo.path}`), {x: 0, y: 0, angle: 0}, 34, "sun", repo.name.split("/").pop()));
|
||||||
|
svg.append(layer);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artChord(repo) {
|
||||||
|
const radius = 300;
|
||||||
|
const branches = repo.branches;
|
||||||
|
const spots = new Map(branches.map((branch, index) => {
|
||||||
|
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 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")}`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const merge of repo.merges) {
|
||||||
|
const from = spots.get(merge.from);
|
||||||
|
const to = spots.get(merge.into);
|
||||||
|
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}`)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
|
const at = spots.get(branch.name);
|
||||||
|
const cos = Math.cos(at.angle);
|
||||||
|
return s("text", {class: "label", x: fixed(at.x * 1.07), y: fixed(at.y * 1.07), dy: "0.35em", "text-anchor": cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"}, short(branch.name).slice(0, 26));
|
||||||
|
});
|
||||||
|
svg.append(arcs, s("g", {}, marks), s("g", {}, labels));
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artSwimlane(repo) {
|
||||||
|
const lanes = repo.branches;
|
||||||
|
const begin = state.since;
|
||||||
|
const end = Math.max(state.until, state.since + 1);
|
||||||
|
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());
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
for (let index = 0; index <= 6; index += 1) {
|
||||||
|
const ts = begin + ((end - begin) * index) / 6;
|
||||||
|
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8}));
|
||||||
|
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
|
||||||
|
}
|
||||||
|
const marks = s("g", {});
|
||||||
|
lanes.forEach((branch, index) => {
|
||||||
|
const y = top + index * lane + lane / 2;
|
||||||
|
const shapes = {commit: `M-4,0 a4,4 0 1,0 8,0 a4,4 0 1,0 -8,0`, review: "M-4,-4 h8 v8 h-8 z", gate: "M0,-5 L5,4 L-5,4 z", push: "M0,-6 L5,0 L2,0 L2,5 L-2,5 L-2,0 L-5,0 z"};
|
||||||
|
for (const event of branch.events.filter((entry) => entry.ts >= begin && entry.ts <= end)) {
|
||||||
|
const tone = event.ok === false ? "bad" : event.kind === "push" ? "info" : event.kind === "commit" ? "ok" : event.ok ? "ok" : "neutral";
|
||||||
|
marks.append(s("g", {class: `lane-mark tone-${tone}`, transform: `translate(${fixed(x(event.ts))} ${y})`},
|
||||||
|
s("title", {}, `${clock.format(stampOf(event.ts))} · ${LANE_MARKS[event.kind][0]}: ${event.title}`), s("path", {d: shapes[event.kind]})));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
svg.append(marks);
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 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]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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.")];
|
||||||
|
}
|
||||||
|
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]]),
|
||||||
|
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]]),
|
||||||
|
h("h3", {}, "Trockenlauf"), 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)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
|
||||||
|
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.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))));
|
||||||
|
} 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);
|
||||||
|
} else {
|
||||||
|
body.push(meta([["Titel", 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))),
|
||||||
|
...body,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactsScreen() {
|
||||||
|
const view = ui.views.artifacts;
|
||||||
|
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
|
||||||
|
art.repo = event.target.value;
|
||||||
|
art.node = null;
|
||||||
|
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 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) => {
|
||||||
|
tree.q = event.target.value;
|
||||||
|
drawArtifacts();
|
||||||
|
}}}) : null;
|
||||||
|
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
|
||||||
|
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll};
|
||||||
|
window.setTimeout(() => {
|
||||||
|
drawArtifacts();
|
||||||
|
fetchTree();
|
||||||
|
}, 0);
|
||||||
|
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
|
||||||
|
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
|
||||||
|
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside);
|
||||||
|
return h("div", {class: "graph"}, controls, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTree() {
|
||||||
|
if (tree.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tree.busy = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/tree", {cache: "no-store"});
|
||||||
|
if (response.ok) {
|
||||||
|
tree.data = await response.json();
|
||||||
|
tree.model = artifactModel(tree.data);
|
||||||
|
} else {
|
||||||
|
tree.data = {repos: [], scanned: null, error: `/api/tree antwortet ${response.status}`};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ui.offline = true;
|
||||||
|
tree.data = tree.data || {repos: [], scanned: null, error: "Hub nicht erreichbar"};
|
||||||
|
}
|
||||||
|
tree.busy = false;
|
||||||
|
drawArtifacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawArtifacts() {
|
||||||
|
if (!tree.dom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tree.dom.view === "tree") {
|
||||||
|
drawTree();
|
||||||
|
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))}`;
|
||||||
|
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.aside.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const model = tree.model;
|
||||||
|
if (art.node !== null && !model.byId.has(art.node)) {
|
||||||
|
art.node = null;
|
||||||
|
}
|
||||||
|
tree.dom.aside.replaceChildren(...artPanel(model));
|
||||||
|
if (tree.dom.view === "3d") {
|
||||||
|
stage3d("artifacts", tree.dom.stage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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."));
|
||||||
|
}
|
||||||
@@ -42,9 +42,9 @@
|
|||||||
text.value = "";
|
text.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return h("details", {class: "menu", id: "to-all"}, h("summary", {}, "Funk an alle ▾"), h("div", {class: "menu-box"},
|
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."),
|
text, classes, h("p", {class: "muted"}, "Newsletter kommt als Post am Ende des Zuges; Broadcast, Pan-Pan, Security und Mayday sofort."),
|
||||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Senden", "btn soft", send))));
|
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Senden", "btn soft", send, "Senden an alle"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openToAll() {
|
function openToAll() {
|
||||||
@@ -59,12 +59,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function agentsToolbar() {
|
function agentsToolbar() {
|
||||||
const clear = btn("Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
|
const clear = iconBtn("bi-life-preserver", "Mayday aufheben", "btn small danger", () => confirmAct("clear-mayday"));
|
||||||
const mode = h("span", {});
|
const mode = h("span", {});
|
||||||
const night = h("details", {class: "menu"}, h("summary", {}, "Tag/Nacht ▾"), h("div", {class: "menu-box"}, mode,
|
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||||
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
||||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
||||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, btn("Priorität", "btn small", prioritySheet), night), clear, mode};
|
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", prioritySheet), night), clear, mode};
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawToolbar() {
|
function drawToolbar() {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
window.addEventListener("hashchange", apply);
|
window.addEventListener("hashchange", apply);
|
||||||
window.addEventListener("resize", measure);
|
window.addEventListener("resize", measure);
|
||||||
window.addEventListener("resize", redrawSky);
|
window.addEventListener("resize", redrawSky);
|
||||||
window.addEventListener("resize", requestSpace);
|
window.addEventListener("resize", refresh3d);
|
||||||
skyPanel();
|
skyPanel();
|
||||||
radio.element = h("audio", {preload: "none"});
|
radio.element = h("audio", {preload: "none"});
|
||||||
radio.element.addEventListener("error", () => {
|
radio.element.addEventListener("error", () => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const CLEANUP_POLL = 2000;
|
||||||
|
const CLEANUP_PATIENCE = 900000;
|
||||||
|
const RESULT_LABELS = {done: ["gelöscht", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||||
|
|
||||||
|
function planOf(item) {
|
||||||
|
const repo = (tree.data.repos || []).find((entry) => entry.path === item.repository);
|
||||||
|
const entry = repo ? repo.branches.find((candidate) => candidate.name === item.branch) : null;
|
||||||
|
return {repo, entry};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteDialog(items) {
|
||||||
|
if (!items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
const {repo, entry} = planOf(item);
|
||||||
|
return h("li", {class: "dry-run"},
|
||||||
|
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch)),
|
||||||
|
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");
|
||||||
|
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),
|
||||||
|
outcome,
|
||||||
|
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||||
|
], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSigned(items, outcome) {
|
||||||
|
const signed = await proofFor(items, "Löschen");
|
||||||
|
if (signed === null) {
|
||||||
|
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||||
|
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);
|
||||||
|
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.`));
|
||||||
|
followCleanup(result.data.id, outcome, Date.now());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function followCleanup(id, outcome, began) {
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {cache: "no-store"});
|
||||||
|
data = response.ok ? await response.json() : null;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!data || data.status !== "done") {
|
||||||
|
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
|
||||||
|
window.setTimeout(() => followCleanup(id, outcome, began), CLEANUP_POLL);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_LABELS[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
||||||
|
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||||
|
const [label, tone] = RESULT_LABELS[item.status] || [item.status, "neutral"];
|
||||||
|
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item.branch), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
||||||
|
})));
|
||||||
|
const good = !data.error && data.items.every((item) => item.status === "done");
|
||||||
|
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
|
||||||
|
fetchTree();
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
const NS = "http://www.w3.org/2000/svg";
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
const NONCE = document.currentScript ? document.currentScript.nonce : "";
|
||||||
|
const ACTIVE_BEAT = 900;
|
||||||
const DAY = 86400;
|
const DAY = 86400;
|
||||||
const CHAIR = "Maintainer";
|
const CHAIR = "Maintainer";
|
||||||
const HUMAN = "Operator";
|
const HUMAN = "Operator";
|
||||||
@@ -7,6 +9,7 @@
|
|||||||
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
|
const PARTS = [["user", "Nutzer"], ["assistant", "Assistent"], ["tool_call", "Werkzeugaufruf"], ["tool_result", "Werkzeugergebnis"], ["system", "System"]];
|
||||||
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
|
const 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 KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
|
||||||
|
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
|
||||||
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
|
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Entscheidung", operator: "Operator"};
|
||||||
const NIGHT = {day: ["Tag", "ok"], evening: ["Abend", "warn"], sunset: ["Nacht", "violet"]};
|
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 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)?"]];
|
||||||
@@ -42,15 +45,11 @@
|
|||||||
const STAGE_LABELS = {night: "Nacht", astronomical: "Astronomische Dämmerung", nautical: "Nautische Dämmerung", civil: "Bürgerliche Dämmerung", golden: "Goldene Stunde", day: "Tag"};
|
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 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 COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
|
||||||
const AXES = [["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
|
|
||||||
const SUBS_TTL = 60000;
|
const SUBS_TTL = 60000;
|
||||||
const TURN = 10;
|
const TURN = 10;
|
||||||
const NUDGE = 40;
|
|
||||||
const ZOOM = 1.2;
|
const ZOOM = 1.2;
|
||||||
const GLIDE = 320;
|
const GLIDE = 320;
|
||||||
const PLANE = 450;
|
|
||||||
const SAVE_DELAY = 700;
|
const SAVE_DELAY = 700;
|
||||||
const TRACK_RETRY = 5000;
|
|
||||||
const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
|
const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
|
||||||
const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5];
|
const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5];
|
||||||
const BASE = 110;
|
const BASE = 110;
|
||||||
@@ -62,7 +61,7 @@
|
|||||||
direct: [[740, 0, 0.2]],
|
direct: [[740, 0, 0.2]],
|
||||||
radio: [[520, 0, 0.07]],
|
radio: [[520, 0, 0.07]],
|
||||||
};
|
};
|
||||||
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "3D-Graph und Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Umschalt+Pfeile", "3D-Graph: verschieben (auch Umschalt+Ziehen)"], ["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
|
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["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 calm = window.matchMedia("(prefers-reduced-motion: reduce)");
|
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
||||||
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
}
|
}
|
||||||
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
|
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
|
||||||
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
|
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
|
||||||
|
const icon = (name, cls) => s("svg", {class: cls ? `bi ${cls}` : "bi", "aria-hidden": "true", focusable: "false"}, s("use", {href: `#${name}`}));
|
||||||
|
const caret = () => icon("bi-caret-down-fill", "caret");
|
||||||
|
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 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(", ") : "alle");
|
||||||
const fixed = (value) => value.toFixed(1);
|
const fixed = (value) => value.toFixed(1);
|
||||||
@@ -91,18 +95,22 @@
|
|||||||
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
|
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relabel(button, name, label, hint) {
|
||||||
|
button.replaceChildren(icon(name));
|
||||||
|
button.setAttribute("aria-label", label);
|
||||||
|
button.title = hint || label;
|
||||||
|
}
|
||||||
|
|
||||||
function copyButton(text) {
|
function copyButton(text) {
|
||||||
const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren");
|
const button = iconBtn("bi-clipboard", "Kopieren", "btn small ghost", null, "Kopieren: in die Zwischenablage");
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
button.textContent = "Kopiert";
|
relabel(button, "bi-clipboard-check", "Kopiert");
|
||||||
} catch {
|
} catch {
|
||||||
button.textContent = "Nicht möglich";
|
relabel(button, "bi-x-circle", "Nicht möglich");
|
||||||
}
|
}
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => relabel(button, "bi-clipboard", "Kopieren"), 1600);
|
||||||
button.textContent = "Kopieren";
|
|
||||||
}, 1600);
|
|
||||||
});
|
});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
@@ -118,7 +126,7 @@
|
|||||||
function openSheet(title, body, wide) {
|
function openSheet(title, body, wide) {
|
||||||
const sheet = document.getElementById("sheet");
|
const sheet = document.getElementById("sheet");
|
||||||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||||||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")),
|
h("div", {class: "sheet-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-body"}, body)));
|
h("div", {class: "sheet-body"}, body)));
|
||||||
sheet.hidden = false;
|
sheet.hidden = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const kinds = activeKinds();
|
const kinds = activeKinds();
|
||||||
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {type: "button", class: "chip preset", "aria-pressed": String(filter.view === id), on: {click: () => useView(id)}}, view.label)));
|
stream.dom.categories.replaceChildren(...Object.entries(state.views).map(([id, view]) => h("button", {
|
||||||
|
type: "button", class: "chip preset icon-only", ...named(view.label), "aria-pressed": String(filter.view === id), on: {click: () => useView(id)},
|
||||||
|
}, icon(VIEW_ICONS[id] || "bi-funnel"))));
|
||||||
const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: {
|
const mine = userViews.map((view) => h("span", {class: "chip", "aria-pressed": String(!filter.view && filter.saved === view.name), role: "button", tabindex: 0, on: {
|
||||||
click: () => useSaved(view),
|
click: () => useSaved(view),
|
||||||
keydown: (event) => {
|
keydown: (event) => {
|
||||||
@@ -111,11 +113,11 @@
|
|||||||
useSaved(view);
|
useSaved(view);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}}, view.name, h("button", {type: "button", class: "x", "aria-label": `${view.name} löschen`, on: {click: (event) => {
|
}}, view.name, h("button", {type: "button", class: "x", ...named(`${view.name} löschen`), on: {click: (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
dropView(view.name);
|
dropView(view.name);
|
||||||
}}}, "×")));
|
}}}, icon("bi-x-lg"))));
|
||||||
stream.dom.saved.replaceChildren(...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
|
stream.dom.saved.replaceChildren(...mine, iconBtn("bi-save", "Ansicht speichern", "btn small ghost", saveView));
|
||||||
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
|
const kindChips = KINDS.map(([id, label]) => h("button", {type: "button", class: "chip", "aria-pressed": String(kinds.has(id)), on: {click: () => {
|
||||||
customize();
|
customize();
|
||||||
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
||||||
@@ -141,11 +143,11 @@
|
|||||||
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
||||||
stream.dom.parts.replaceChildren(...parts);
|
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}`,
|
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", "aria-label": "Filter entfernen", on: {click: () => {
|
h("button", {type: "button", class: "x", ...named("Filter entfernen"), on: {click: () => {
|
||||||
filter.sub = "";
|
filter.sub = "";
|
||||||
filter.session = "";
|
filter.session = "";
|
||||||
filter.agent = "";
|
filter.agent = "";
|
||||||
refilter();
|
refilter();
|
||||||
}}}, "×")) : null;
|
}}}, icon("bi-x-lg"))) : null;
|
||||||
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
|
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
|
||||||
}
|
}
|
||||||
@@ -157,28 +157,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function legend() {
|
function legend() {
|
||||||
const axis = graph3d() ? depth() : "none";
|
|
||||||
const words = {
|
|
||||||
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
|
|
||||||
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
|
|
||||||
spawn: "Ebenen = Spawn-Tiefe · Bogen = Nachrichten im Zeitraum, Zahl im Stream",
|
|
||||||
};
|
|
||||||
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
|
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
|
||||||
axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"),
|
h("span", {class: "muted"}, "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit"));
|
||||||
h("span", {class: "muted"}, words[axis]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function side() {
|
function side() {
|
||||||
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten";
|
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf einen Knoten: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.")];
|
||||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)];
|
|
||||||
}
|
}
|
||||||
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
|
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
|
||||||
return [
|
return [
|
||||||
h("div", {class: "panel-head"},
|
h("div", {class: "panel-head"},
|
||||||
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
|
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
|
||||||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")),
|
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => select(node.id))),
|
||||||
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
|
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
|
||||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||||
@@ -188,22 +180,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewSwitch(screen) {
|
function viewSwitch(screen) {
|
||||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {type: "button", "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)}}, name)));
|
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.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]))));
|
||||||
}
|
}
|
||||||
|
|
||||||
const later = (what) => emptyState(`${what} folgt`, "Diese Ansicht ist geplant und kommt mit dem nächsten Ausbau.");
|
|
||||||
|
|
||||||
function agentsScreen() {
|
function agentsScreen() {
|
||||||
const view = ui.views.agents;
|
const view = ui.views.agents;
|
||||||
const stageBox = h("div", {class: "stage"});
|
const stageBox = h("div", {class: "stage"});
|
||||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||||
const toolbar = agentsToolbar();
|
const toolbar = agentsToolbar();
|
||||||
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
|
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
||||||
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
|
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
|
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
||||||
}
|
|
||||||
|
|
||||||
function artifactsScreen() {
|
|
||||||
const view = ui.views.artifacts;
|
|
||||||
return h("div", {class: "graph"}, h("div", {class: "card controls"}, viewSwitch("artifacts")), view === "tree" ? treeScreen() : later(`Artifacts ${Nav.VIEWS.find(([id]) => id === view)[1]}`));
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
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"],
|
||||||
|
];
|
||||||
|
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"};
|
||||||
|
const DEPTH_GAP = 90;
|
||||||
|
const TIME_DEPTH = 640;
|
||||||
|
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""};
|
||||||
|
|
||||||
|
const layoutOf = () => space.views.graph.axis;
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tag = document.createElement("script");
|
||||||
|
tag.src = src;
|
||||||
|
tag.nonce = NONCE;
|
||||||
|
tag.async = true;
|
||||||
|
tag.addEventListener("load", resolve);
|
||||||
|
tag.addEventListener("error", () => reject(new Error(`${src} nicht geladen`)));
|
||||||
|
document.head.append(tag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceGraph() {
|
||||||
|
if (!g3.lib) {
|
||||||
|
g3.lib = loadScript("/vendor/3d-force-graph.min.js").then(() => window.ForceGraph3D);
|
||||||
|
}
|
||||||
|
return g3.lib;
|
||||||
|
}
|
||||||
|
|
||||||
|
function threeModule() {
|
||||||
|
if (!g3.three) {
|
||||||
|
g3.three = import("/vendor/three.module.js").catch(() => null);
|
||||||
|
}
|
||||||
|
return g3.three;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutTools(tab) {
|
||||||
|
const group = h("div", {class: "segmented", role: "group", "aria-label": "Anordnung"}, 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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseLayout(tab, id, group) {
|
||||||
|
for (const button of group.querySelectorAll("button")) {
|
||||||
|
button.setAttribute("aria-pressed", String(button.dataset.layout === id));
|
||||||
|
}
|
||||||
|
if (id !== layoutOf()) {
|
||||||
|
setView("graph", {...space.views.graph, axis: id}, false);
|
||||||
|
g3.sig = "";
|
||||||
|
if (ui.screen === tab) {
|
||||||
|
stage3d(tab, g3.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentModel() {
|
||||||
|
const depthOf = {operator: 0, chair: 1, session: 2, subagent: 3};
|
||||||
|
const nodes = state.graph.nodes.map((node) => ({
|
||||||
|
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: (node.timeline[0] || {}).since || state.since,
|
||||||
|
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
|
||||||
|
}));
|
||||||
|
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
|
||||||
|
const walk = (twig, parent, depth) => {
|
||||||
|
nodes.push({id: twig.id, kind: "twig", label: twig.label, depth, ts: twig.row.started, parent, session: twig.session, sub: twig.row.id});
|
||||||
|
twig.children.forEach((child) => walk(child, twig.id, depth + 1));
|
||||||
|
};
|
||||||
|
twigs(node.id).forEach((twig) => walk(twig, node.id, depthOf[node.kind] + 1));
|
||||||
|
}
|
||||||
|
const ids = new Set(nodes.map((node) => node.id));
|
||||||
|
const links = nodes.filter((node) => node.parent && ids.has(node.parent)).map((node) => ({source: node.parent, target: node.id, kind: "spawn"}));
|
||||||
|
for (const link of aggregate().filter((entry) => entry.target !== "*" && ids.has(entry.source) && ids.has(entry.target) && entry.source !== entry.target)) {
|
||||||
|
links.push({source: link.source, target: link.target, kind: tone(link.cls), count: link.count});
|
||||||
|
}
|
||||||
|
return {nodes, links};
|
||||||
|
}
|
||||||
|
|
||||||
|
function model3d(tab) {
|
||||||
|
if (tab === "agents") {
|
||||||
|
return agentModel();
|
||||||
|
}
|
||||||
|
const built = tree.model || {nodes: [], links: []};
|
||||||
|
return {nodes: built.nodes.map((node) => ({id: node.id, kind: node.kind, label: node.label, depth: node.depth, ts: node.ts, parent: node.parent})), links: built.links.map((link) => ({...link}))};
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(nodes, layout) {
|
||||||
|
const times = nodes.map((node) => node.ts).filter(Number.isFinite);
|
||||||
|
const [low, high] = [Math.min(...times), Math.max(...times)];
|
||||||
|
const span = high - low;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (layout === "spawn") {
|
||||||
|
node.fz = -node.depth * DEPTH_GAP;
|
||||||
|
} else if (layout === "time") {
|
||||||
|
node.fz = span > 0 && Number.isFinite(node.ts) ? ((node.ts - low) / span - 0.5) * TIME_DEPTH : 0;
|
||||||
|
} else {
|
||||||
|
delete node.fz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function colors3d() {
|
||||||
|
const css = window.getComputedStyle(document.documentElement);
|
||||||
|
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
|
||||||
|
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text"]);
|
||||||
|
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
|
||||||
|
return g3.colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawPlanes(layout, nodes) {
|
||||||
|
const graph = g3.graph;
|
||||||
|
const scene = graph.scene();
|
||||||
|
for (const plane of g3.planes) {
|
||||||
|
scene.remove(plane);
|
||||||
|
}
|
||||||
|
g3.planes = [];
|
||||||
|
if (layout === "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const THREE = await threeModule();
|
||||||
|
if (!THREE || g3.graph !== graph) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const levels = [...new Set(nodes.map((node) => node.fz))].filter(Number.isFinite).sort((a, b) => a - b);
|
||||||
|
const shown = layout === "spawn" ? levels : [levels[0], levels[levels.length - 1]].filter(Number.isFinite);
|
||||||
|
for (const z of new Set(shown)) {
|
||||||
|
const grid = new THREE.GridHelper(520, 8, g3.colors["line-strong"], g3.colors["line-strong"]);
|
||||||
|
grid.rotation.x = Math.PI / 2;
|
||||||
|
grid.position.z = z;
|
||||||
|
grid.material.transparent = true;
|
||||||
|
grid.material.opacity = 0.18;
|
||||||
|
scene.add(grid);
|
||||||
|
g3.planes.push(grid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick3d(tab, node) {
|
||||||
|
if (tab === "artifacts") {
|
||||||
|
artSelect(node.id);
|
||||||
|
} else if (node.kind === "twig") {
|
||||||
|
focusSub(node.session, node.sub);
|
||||||
|
} else {
|
||||||
|
select(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function signature3d(tab, data) {
|
||||||
|
return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth]), data.links.map((link) => [link.source, link.target, link.kind])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stage3d(tab, host) {
|
||||||
|
if (!host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tab === "agents") {
|
||||||
|
loadSpawn();
|
||||||
|
}
|
||||||
|
const data = model3d(tab);
|
||||||
|
const sig = signature3d(tab, data);
|
||||||
|
if (g3.host === host && g3.sig === sig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g3.host !== host) {
|
||||||
|
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 …"));
|
||||||
|
}
|
||||||
|
g3.sig = sig;
|
||||||
|
const box = host.querySelector(".graph3d");
|
||||||
|
let Graph = null;
|
||||||
|
try {
|
||||||
|
Graph = await forceGraph();
|
||||||
|
} catch (error) {
|
||||||
|
g3.failed = String(error.message || error);
|
||||||
|
}
|
||||||
|
if (g3.host !== host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Graph) {
|
||||||
|
host.replaceChildren(emptyState("3D nicht geladen", g3.failed || "3d-force-graph fehlt."));
|
||||||
|
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}`));
|
||||||
|
g3.host = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const colors = colors3d();
|
||||||
|
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
|
||||||
|
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1))
|
||||||
|
.nodeColor((node) => g3.colors[KIND_COLORS[node.kind]] || colors.text).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
|
||||||
|
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
|
||||||
|
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
|
||||||
|
g3.graph.warmupTicks(calm.matches ? 160 : 80);
|
||||||
|
if (calm.matches) {
|
||||||
|
g3.graph.cooldownTicks(0);
|
||||||
|
} else {
|
||||||
|
g3.graph.cooldownTime(4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const layout = layoutOf();
|
||||||
|
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)" : ""}`;
|
||||||
|
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data));
|
||||||
|
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose3d() {
|
||||||
|
if (g3.graph) {
|
||||||
|
g3.graph.pauseAnimation();
|
||||||
|
if (typeof g3.graph._destructor === "function") {
|
||||||
|
g3.graph._destructor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g3.graph = null;
|
||||||
|
g3.host = null;
|
||||||
|
g3.sig = "";
|
||||||
|
g3.planes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh3d() {
|
||||||
|
if (!g3.graph || !g3.host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!g3.host.isConnected) {
|
||||||
|
dispose3d();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
colors3d();
|
||||||
|
const box = g3.host.querySelector(".graph3d");
|
||||||
|
g3.graph.width(box.clientWidth).height(box.clientHeight).nodeColor(g3.graph.nodeColor()).linkColor(g3.graph.linkColor());
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
function undoToast(batch, label) {
|
function undoToast(batch, label) {
|
||||||
const left = h("span", {class: "muted"});
|
const left = h("span", {class: "muted"});
|
||||||
const button = h("button", {type: "button", class: "btn small"}, "Rückgängig");
|
const button = h("button", {type: "button", class: "btn small", ...named("Rückgängig")}, icon("bi-arrow-counterclockwise"), "Rückgängig");
|
||||||
const box = h("div", {class: "toast wait", role: "status"}, h("strong", {}, label), h("div", {class: "undo-row"}, left, h("span", {class: "spacer"}), button));
|
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;
|
let timer = 0;
|
||||||
const count = () => {
|
const count = () => {
|
||||||
@@ -117,11 +117,11 @@
|
|||||||
}
|
}
|
||||||
const buttons = [];
|
const buttons = [];
|
||||||
if (item.kind === "review" || item.kind === "ask") {
|
if (item.kind === "review" || item.kind === "ask") {
|
||||||
buttons.push(btn("Freigeben", "btn small primary", () => approveOne(item)), btn("Ablehnen", "btn small danger", () => rejectOne(item)));
|
buttons.push(iconBtn("bi-check-lg", "Freigeben", "btn small primary", () => approveOne(item)), iconBtn("bi-x-lg", "Ablehnen", "btn small danger", () => rejectOne(item)));
|
||||||
} else if (item.kind === "decision" && item.recommended) {
|
} else if (item.kind === "decision" && item.recommended) {
|
||||||
buttons.push(btn(`Empfehlung: ${item.recommended}`, "btn small primary", () => approveOne(item)));
|
buttons.push(h("button", {type: "button", class: "btn small primary", ...named(`Empfehlung: ${item.recommended}`), on: {click: () => approveOne(item)}}, icon("bi-check-lg"), item.recommended));
|
||||||
} else if (item.kind === "operator" && item.runnable) {
|
} else if (item.kind === "operator" && item.runnable) {
|
||||||
buttons.push(btn("Ausführen", "btn small primary", () => approveOne(item)));
|
buttons.push(iconBtn("bi-play-fill", "Ausführen", "btn small primary", () => approveOne(item)));
|
||||||
}
|
}
|
||||||
return buttons;
|
return buttons;
|
||||||
}
|
}
|
||||||
@@ -153,10 +153,10 @@
|
|||||||
actionsFor(item),
|
actionsFor(item),
|
||||||
item.kind === "operator" ? copyButton(item.command) : null,
|
item.kind === "operator" ? copyButton(item.command) : null,
|
||||||
h("span", {class: "spacer"}),
|
h("span", {class: "spacer"}),
|
||||||
btn("1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned)),
|
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: eine Stunde zurückstellen"),
|
||||||
btn("morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned)),
|
iconBtn("bi-sunrise", "morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "morgen: bis morgen 8 Uhr zurückstellen"),
|
||||||
item.until ? btn("wecken", "btn small ghost", () => mark(item, 0, item.pinned)) : null,
|
item.until ? iconBtn("bi-alarm", "wecken", "btn small ghost", () => mark(item, 0, item.pinned), "wecken: wieder zeigen") : null,
|
||||||
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||||
}
|
}
|
||||||
|
|
||||||
function inboxScreen() {
|
function inboxScreen() {
|
||||||
@@ -188,8 +188,8 @@
|
|||||||
ui.snoozed = event.target.checked;
|
ui.snoozed = event.target.checked;
|
||||||
drawInbox();
|
drawInbox();
|
||||||
}}});
|
}}});
|
||||||
const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`);
|
const 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", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.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));
|
||||||
inboxDom.replaceChildren(
|
inboxDom.replaceChildren(
|
||||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
|
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} zurückgestellt`) : null),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/state?${windowQuery()}${wantsTrack() ? "&track=1" : ""}`, {cache: "no-store"});
|
const response = await fetch(`/api/state?${windowQuery()}`, {cache: "no-store"});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(String(response.status));
|
throw new Error(String(response.status));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
button.setAttribute("aria-pressed", String(settings.music.on));
|
button.setAttribute("aria-pressed", String(settings.music.on));
|
||||||
button.textContent = settings.music.on ? "♪ Musik an" : "♪ Musik";
|
relabel(button, "bi-music-note-beamed", settings.music.on ? "Musik an" : "Musik", settings.music.on ? "Musik an: generative Musik ausschalten" : "Musik: generative Musik einschalten");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMusic() {
|
function toggleMusic() {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
|
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,
|
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
|
||||||
h("div", {class: "actions"},
|
h("div", {class: "actions"},
|
||||||
h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"),
|
h("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("span", {class: "spacer"}),
|
h("span", {class: "spacer"}),
|
||||||
item ? actionsFor(item).map((node) => {
|
item ? actionsFor(item).map((node) => {
|
||||||
node.addEventListener("click", closeSheet);
|
node.addEventListener("click", closeSheet);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
function tabButton(id, label, hint) {
|
function tabButton(id, label, hint) {
|
||||||
const badge = Object.hasOwn(state.badges, id) ? h("span", {class: "badge", hidden: true}) : null;
|
const badge = Object.hasOwn(state.badges, id) ? h("span", {class: "badge", hidden: true}) : null;
|
||||||
const button = h("button", {type: "button", id: `tab-${id}`, "aria-controls": "main", title: hint ? `${label} (${hint})` : label, on: {click: () => go(id, null)}}, label, badge);
|
const button = h("button", {type: "button", id: `tab-${id}`, class: "icon-only", "aria-controls": "main", ...named(label, hint ? `${label} (${hint})` : label), on: {click: () => go(id, null)}}, icon(Nav.ICONS[id]), badge);
|
||||||
navButtons.set(id, {button, badge});
|
navButtons.set(id, {button, badge});
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
function signature() {
|
function signature() {
|
||||||
if (ui.screen === "agents") {
|
if (ui.screen === "agents") {
|
||||||
return JSON.stringify([ui.views.agents, depth()]);
|
return JSON.stringify([ui.views.agents]);
|
||||||
}
|
}
|
||||||
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
|
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
|
||||||
}
|
}
|
||||||
@@ -70,9 +70,13 @@
|
|||||||
main.replaceChildren(screenOf());
|
main.replaceChildren(screenOf());
|
||||||
drawn.screen = ui.screen;
|
drawn.screen = ui.screen;
|
||||||
drawn.sig = sig;
|
drawn.sig = sig;
|
||||||
|
if (g3.host && !g3.host.isConnected) {
|
||||||
|
dispose3d();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stage();
|
stage();
|
||||||
drawSide();
|
drawSide();
|
||||||
|
drawSpawn();
|
||||||
drawToolbar();
|
drawToolbar();
|
||||||
drawInbox();
|
drawInbox();
|
||||||
drawStatus();
|
drawStatus();
|
||||||
@@ -89,7 +93,7 @@
|
|||||||
const banner = document.getElementById("mayday");
|
const banner = document.getElementById("mayday");
|
||||||
const silence = state.silence;
|
const silence = state.silence;
|
||||||
banner.hidden = !silence;
|
banner.hidden = !silence;
|
||||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), btn("aufheben", "btn small", () => confirmAct("clear-mayday"))] : []));
|
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)")] : []));
|
||||||
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
||||||
window.setTimeout(measure, 0);
|
window.setTimeout(measure, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
if (space.edits === space.saved) {
|
if (space.edits === space.saved) {
|
||||||
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
|
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
|
||||||
space.glides = {graph: null, sky: null};
|
space.glides = {graph: null, sky: null};
|
||||||
requestSpace();
|
refresh3d();
|
||||||
}
|
}
|
||||||
sky.mode = "";
|
sky.mode = "";
|
||||||
drawSky();
|
drawSky();
|
||||||
@@ -56,15 +56,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ambience() {
|
function ambience() {
|
||||||
const tool = (id, label, title, run) => h("button", {type: "button", id, class: "btn small", title, on: {click: run}}, label);
|
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"},
|
const section = h("section", {}, h("h3", {}, "Ambiente"), h("div", {class: "toolbar"},
|
||||||
tool("sky-look", "🔭 Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
|
tool("sky-look", "bi-stars", "Himmel", "Himmel ansehen: ziehen dreht den Blick (Taste h)", () => {
|
||||||
closeSheet();
|
closeSheet();
|
||||||
skyLook(true);
|
skyLook(true);
|
||||||
}),
|
}),
|
||||||
tool("music-toggle", "♪ Musik", "Generative Musik an oder aus", toggleMusic),
|
tool("music-toggle", "bi-music-note-beamed", "Musik", "Musik: generative Musik an oder aus", toggleMusic),
|
||||||
tool("leitstand", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
tool("leitstand", "bi-display", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
||||||
tool("notify", "Benachrichtigungen", "Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
tool("notify", "bi-bell", "Benachrichtigungen", "Benachrichtigungen: Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
musicButton();
|
musicButton();
|
||||||
notifyLabel();
|
notifyLabel();
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`);
|
root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`);
|
||||||
root.style.colorScheme = mode;
|
root.style.colorScheme = mode;
|
||||||
root.dataset.mode = mode;
|
root.dataset.mode = mode;
|
||||||
requestSpace();
|
refresh3d();
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawMoon(paint, at, radius, moon) {
|
function drawMoon(paint, at, radius, moon) {
|
||||||
@@ -191,16 +191,16 @@
|
|||||||
|
|
||||||
function skyPanel() {
|
function skyPanel() {
|
||||||
const panel = document.getElementById("sky-panel");
|
const panel = document.getElementById("sky-panel");
|
||||||
const step = (text, label, run) => h("button", {type: "button", class: "btn small", title: label, "aria-label": label, on: {click: run}}, text);
|
const step = (name, label, run) => iconBtn(name, label, "btn small", run);
|
||||||
space.compass = h("span", {class: "compass", "aria-live": "polite"});
|
space.compass = h("span", {class: "compass", "aria-live": "polite"});
|
||||||
panel.append(space.compass,
|
panel.append(space.compass,
|
||||||
step("◀", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
step("bi-caret-left-fill", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
||||||
step("▶", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
step("bi-caret-right-fill", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
||||||
step("▲", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
step("bi-caret-up-fill", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
||||||
step("▼", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
step("bi-caret-down-fill", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
||||||
step("−", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
step("bi-zoom-out", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||||
step("+", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
step("bi-zoom-in", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||||
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
step("bi-arrow-counterclockwise", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||||
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
|
step("bi-check-lg", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||||
steer(sky.canvas, "sky", skyHooks);
|
steer(sky.canvas, "sky", skyHooks);
|
||||||
}
|
}
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
function shownCursor() {
|
|
||||||
const glide = space.cursor;
|
|
||||||
if (glide.to === null) {
|
|
||||||
return state.until;
|
|
||||||
}
|
|
||||||
const progress = calm.matches ? 1 : Math.min((performance.now() - glide.start) / PLANE, 1);
|
|
||||||
return progress >= 1 ? glide.to : glide.from + (glide.to - glide.from) * Space.ease(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sceneInput() {
|
|
||||||
const span = [Math.min(Math.max(state.since, state.first), state.until), state.until];
|
|
||||||
const range = ui.asOf !== null && ui.preset !== -1 ? [Math.min(state.first, span[0]), Math.max(state.now, span[1])] : span;
|
|
||||||
const lives = {};
|
|
||||||
const subagents = {};
|
|
||||||
for (const [key, entry] of space.subs) {
|
|
||||||
if (entry.data) {
|
|
||||||
lives[key] = [entry.data.since, entry.data.until];
|
|
||||||
subagents[key] = entry.data.subagents;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
axis: depth(), range, span, cursor: shownCursor(), nodes: state.graph.nodes, track: state.graph.track, now: Object.fromEntries(layout(state.graph.nodes)),
|
|
||||||
lives, subagents, radio: state.radio, grants: state.grants, chair: chairKey(), cap: Space.ARC_CAP,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSpace() {
|
|
||||||
const input = sceneInput();
|
|
||||||
space.sceneCursor = input.cursor;
|
|
||||||
space.scene = Space.scene(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function spaceLabel() {
|
|
||||||
const hint = AXES.find(([id]) => id === depth())[2];
|
|
||||||
return `3D-Graph, ${hint}. Pfeiltasten drehen, Umschalt+Pfeile verschieben, Plus und Minus zoomen, 0 setzt zurück. Alle Weltlinien stehen darunter als Schaltflächen.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stageSpace() {
|
|
||||||
if (!graphDom.space) {
|
|
||||||
const canvas = h("canvas", {class: "space", tabindex: 0, role: "application", "aria-roledescription": "3D-Graph", "aria-label": spaceLabel(), "aria-describedby": "space-help"});
|
|
||||||
steer(canvas, "graph", graphHooks);
|
|
||||||
graphDom.space = {canvas, note: h("div", {class: "space-note", "aria-live": "polite"}), list: h("div", {class: "space-list"}), data: null, version: -1, node: undefined, words: "", open: false};
|
|
||||||
graphDom.stage.replaceChildren(canvas, graphDom.space.note, graphDom.space.list);
|
|
||||||
}
|
|
||||||
if (space.cursor.to !== state.until) {
|
|
||||||
space.cursor = {from: space.cursor.to === null ? state.until : shownCursor(), to: state.until, start: performance.now()};
|
|
||||||
}
|
|
||||||
if (wantsTrack() && !state.graph.track.length && Date.now() - space.trackAsked > TRACK_RETRY) {
|
|
||||||
space.trackAsked = Date.now();
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
buildSpace();
|
|
||||||
const held = graphDom.space;
|
|
||||||
if (held.data !== state || held.version !== space.version || held.node !== ui.node) {
|
|
||||||
held.data = state;
|
|
||||||
held.version = space.version;
|
|
||||||
held.node = ui.node;
|
|
||||||
drawSpaceList();
|
|
||||||
refreshSubs();
|
|
||||||
}
|
|
||||||
drawSpaceNote();
|
|
||||||
requestSpace();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawSpaceNote() {
|
|
||||||
const built = space.scene;
|
|
||||||
const format = (ts) => absolute.format(stampOf(ts));
|
|
||||||
const axis = built.axis === "time" ? `Zeitachse ${format(built.range[0])} – ${format(built.range[1])}` : `Stand ${format(built.span[1])}`;
|
|
||||||
const words = `${built.lines.length} Weltlinien · ${built.arcs.length} Bögen · ${axis}${built.dropped ? ` · ${built.dropped} weitere Ereignisse nur im Stream` : ""}`;
|
|
||||||
if (graphDom.space.words === words) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
graphDom.space.words = words;
|
|
||||||
const dropped = built.dropped ? [h("span", {}, "Gezeichnet: Mayday und Pan-Pan, Spawns, Sicherheit, Freigaben, dann die neuesten."), btn("Funk im Stream", "btn small soft", () => useView("radio"))] : [];
|
|
||||||
graphDom.space.note.replaceChildren(h("span", {}, words), ...dropped);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawSpaceList() {
|
|
||||||
const built = space.scene;
|
|
||||||
const held = graphDom.space;
|
|
||||||
const chip = (line) => {
|
|
||||||
const text = line.kind === "twig" ? short(line.title) : Space.nameAt(line.timeline, built.span[1]);
|
|
||||||
const former = [...new Set(line.timeline.map((entry) => entry.name))];
|
|
||||||
const hint = line.kind === "twig" ? `${line.title} · Transkript im Stream zeigen` : `${NODE_KINDS[line.kind]}${former.length > 1 ? ` · Namen: ${former.join(" → ")}` : ""}`;
|
|
||||||
return h("button", {type: "button", class: "chip", title: hint, "aria-label": `${text}: ${hint}`, "aria-pressed": String(line.node !== null && line.node === ui.node), on: {click: () => pickLine(line)}}, line.kind === "twig" ? text : [face(text, false), text]);
|
|
||||||
};
|
|
||||||
const twigs = built.lines.filter((line) => line.kind === "twig");
|
|
||||||
const more = twigs.length ? h("details", {open: held.open}, h("summary", {}, `${twigs.length} Subagenten aus den Transkripten`), h("div", {class: "chips"}, twigs.map(chip))) : null;
|
|
||||||
if (more) {
|
|
||||||
more.addEventListener("toggle", () => {
|
|
||||||
held.open = more.open;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
held.list.replaceChildren(h("div", {class: "chips", role: "group", "aria-label": "Weltlinien"}, built.lines.filter((line) => line.kind !== "twig").map(chip)), more || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSubs() {
|
|
||||||
if (!graph3d() || space.loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
|
|
||||||
.filter((node) => !space.subs.has(node.id) || Date.now() - space.subs.get(node.id).at > SUBS_TTL);
|
|
||||||
if (!due.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
space.loading = true;
|
|
||||||
await Promise.all(due.map(async (node) => {
|
|
||||||
let data = null;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
|
|
||||||
data = response.ok ? await response.json() : null;
|
|
||||||
} catch {
|
|
||||||
data = null;
|
|
||||||
}
|
|
||||||
space.subs.set(node.id, {at: Date.now(), data});
|
|
||||||
}));
|
|
||||||
space.loading = false;
|
|
||||||
space.version += 1;
|
|
||||||
stage();
|
|
||||||
}
|
|
||||||
|
|
||||||
function spaceStyle() {
|
|
||||||
if (!space.style || space.styleKey !== sky.mode) {
|
|
||||||
const css = window.getComputedStyle(document.documentElement);
|
|
||||||
const value = (name) => css.getPropertyValue(`--${name}`).trim();
|
|
||||||
const roles = {line: "line-strong", accent: "accent", muted: "muted", violet: "violet", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", panel: "panel", text: "text"};
|
|
||||||
space.styleKey = sky.mode;
|
|
||||||
space.style = {
|
|
||||||
colors: Object.fromEntries(Object.entries(roles).map(([role, name]) => [role, value(name)])),
|
|
||||||
font: `560 ${value("fs-sm")} ${value("sans")}`, strong: `700 ${value("fs-sm")} ${value("sans")}`, level: "Tiefe",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const built = space.scene;
|
|
||||||
return {
|
|
||||||
...space.style, selected: ui.node, cursor: live() ? "Jetzt" : "Stand",
|
|
||||||
format: (ts) => (built.range[1] - built.range[0] > DAY ? absolute : clock).format(stampOf(ts)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestSpace() {
|
|
||||||
if (!space.frame) {
|
|
||||||
space.frame = window.requestAnimationFrame(paintSpace);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paintSpace() {
|
|
||||||
space.frame = 0;
|
|
||||||
if (!graph3d() || !graphDom || !graphDom.space || !space.scene) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canvas = graphDom.space.canvas;
|
|
||||||
const paint = canvas.getContext("2d");
|
|
||||||
if (!paint) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cursor = shownCursor();
|
|
||||||
if (cursor !== space.sceneCursor) {
|
|
||||||
buildSpace();
|
|
||||||
}
|
|
||||||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
const width = canvas.clientWidth;
|
|
||||||
const height = canvas.clientHeight;
|
|
||||||
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
|
||||||
canvas.width = Math.round(width * ratio);
|
|
||||||
canvas.height = Math.round(height * ratio);
|
|
||||||
}
|
|
||||||
paint.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
||||||
space.cam = Space.camera(seenView("graph"), width, height);
|
|
||||||
Space.paint(paint, space.scene, space.cam, spaceStyle());
|
|
||||||
if (space.glides.graph || cursor !== space.cursor.to) {
|
|
||||||
requestSpace();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickLine(line) {
|
|
||||||
if (line.kind === "twig") {
|
|
||||||
focusSub(line.session, line.sub);
|
|
||||||
} else {
|
|
||||||
select(line.node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSpace(x, y) {
|
|
||||||
const hit = space.scene && space.cam ? Space.pick(space.scene, space.cam, x, y, 10) : null;
|
|
||||||
if (!hit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hit.kind === "line") {
|
|
||||||
pickLine(hit.line);
|
|
||||||
} else if (hit.arc.kind === "spawn") {
|
|
||||||
focusSub(hit.arc.source, hit.arc.sub);
|
|
||||||
} else if (hit.arc.kind === "grant") {
|
|
||||||
select(hit.arc.target);
|
|
||||||
} else {
|
|
||||||
useSaved({name: "", kinds: ["radio"], waiting: false, q: "", agent: hit.arc.row >= 0 ? state.radio[hit.arc.row].from : nameOf(hit.arc.source)});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,10 @@
|
|||||||
const depth = () => (space.views.graph.axis === "spawn" ? "spawn" : "time");
|
|
||||||
|
|
||||||
function spaceTools() {
|
|
||||||
return h("div", {class: "space-tools"},
|
|
||||||
h("div", {class: "segmented", role: "group", "aria-label": "Tiefenachse"}, AXES.map(([id, label, hint]) => h("button", {type: "button", "aria-pressed": String(depth() === id), title: hint, "aria-label": `Tiefenachse ${label}: ${hint}`, on: {click: () => chooseAxis(id)}}, label))),
|
|
||||||
h("button", {type: "button", class: "btn small ghost", title: "Drehung, Zoom und Verschiebung zurücksetzen (Taste 0)", "aria-label": "3D-Ansicht zurücksetzen", on: {click: () => resetView("graph")}}, "⟲ Ansicht"),
|
|
||||||
h("span", {class: "muted", id: "space-help"}, "Ziehen dreht · Umschalt+Ziehen verschiebt · Rad oder zwei Finger zoomen · Pfeile, + / −, 0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const graph3d = () => ui.screen === "agents" && ui.views.agents === "3d";
|
|
||||||
const wantsTrack = () => graph3d() && depth() === "time";
|
|
||||||
|
|
||||||
function chooseAxis(id) {
|
|
||||||
if (id === depth()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setView("graph", {...space.views.graph, axis: id}, false);
|
|
||||||
render(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stage() {
|
function stage() {
|
||||||
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
graphDom.pulses = null;
|
graphDom.pulses = null;
|
||||||
if (graph3d()) {
|
if (ui.views.agents === "3d") {
|
||||||
stageSpace();
|
stage3d("agents", graphDom.stage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
||||||
@@ -59,9 +39,7 @@
|
|||||||
space.edits += 1;
|
space.edits += 1;
|
||||||
window.clearTimeout(space.timer);
|
window.clearTimeout(space.timer);
|
||||||
space.timer = window.setTimeout(persistViews, SAVE_DELAY);
|
space.timer = window.setTimeout(persistViews, SAVE_DELAY);
|
||||||
if (kind === "graph") {
|
if (kind === "sky") {
|
||||||
requestSpace();
|
|
||||||
} else {
|
|
||||||
redrawSky();
|
redrawSky();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,16 +146,6 @@
|
|||||||
element.addEventListener("keydown", (event) => steerKey(event, kind, hooks));
|
element.addEventListener("keydown", (event) => steerKey(event, kind, hooks));
|
||||||
}
|
}
|
||||||
|
|
||||||
const graphHooks = {
|
|
||||||
rotate: (dx, dy) => nudge("graph", (view) => ({...view, yaw: view.yaw - dx * 0.4, pitch: view.pitch + dy * 0.4}), false),
|
|
||||||
pan: (dx, dy) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + dx / (space.cam ? space.cam.scale : 1), pan_y: view.pan_y + dy / (space.cam ? space.cam.scale : 1)}), false),
|
|
||||||
zoom: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), false),
|
|
||||||
turnStep: (x, y) => nudge("graph", (view) => ({...view, yaw: view.yaw - x * TURN, pitch: view.pitch + y * TURN}), true),
|
|
||||||
panStep: (x, y) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + x * NUDGE, pan_y: view.pan_y + y * NUDGE}), true),
|
|
||||||
zoomStep: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), true),
|
|
||||||
pick: (x, y) => pickSpace(x, y),
|
|
||||||
};
|
|
||||||
|
|
||||||
const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight);
|
const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight);
|
||||||
|
|
||||||
const skyHooks = {
|
const skyHooks = {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
const spawn = {subs: new Map(), loading: false, dom: null};
|
||||||
|
const SPAWN_KINDS = {operator: "bi-person-circle", chair: "bi-award", session: "bi-robot", subagent: "bi-cpu", twig: "bi-cpu"};
|
||||||
|
|
||||||
|
function sessionRunning(node) {
|
||||||
|
const life = (spawn.subs.get(node.id) || {}).data;
|
||||||
|
if (life && life.died) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return node.claims.some((claim) => claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function twigs(session) {
|
||||||
|
const data = (spawn.subs.get(session) || {}).data;
|
||||||
|
const rows = data ? data.subagents : [];
|
||||||
|
const roots = [];
|
||||||
|
const open = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const twig = {id: `${session}/${row.id}`, kind: "twig", label: `${row.type}: ${row.description}`, running: !row.finished, row, session, children: []};
|
||||||
|
open.length = Math.min(open.length, Math.max(row.depth - 1, 0));
|
||||||
|
(open.length ? open[open.length - 1].children : roots).push(twig);
|
||||||
|
open.push(twig);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnTree() {
|
||||||
|
const nodes = state.graph.nodes;
|
||||||
|
const chair = nodes.find((node) => node.kind === "chair");
|
||||||
|
const entry = (node) => ({
|
||||||
|
id: node.id, kind: node.kind, label: node.label, node,
|
||||||
|
running: node.kind === "operator" ? null : node.kind === "subagent" ? node.tasks.some((task) => task.status === "running") : sessionRunning(node),
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
const root = entry(nodes.find((node) => node.kind === "operator"));
|
||||||
|
const byId = new Map([[root.id, root]]);
|
||||||
|
if (chair) {
|
||||||
|
byId.set(chair.id, entry(chair));
|
||||||
|
root.children.push(byId.get(chair.id));
|
||||||
|
}
|
||||||
|
for (const node of nodes.filter((item) => item.kind === "session")) {
|
||||||
|
byId.set(node.id, entry(node));
|
||||||
|
(byId.get(chairKey()) || root).children.push(byId.get(node.id));
|
||||||
|
}
|
||||||
|
for (const node of nodes.filter((item) => item.kind === "subagent")) {
|
||||||
|
(byId.get(node.parent) || byId.get(chairKey()) || root).children.push(entry(node));
|
||||||
|
}
|
||||||
|
for (const [id, held] of byId) {
|
||||||
|
if (held.kind === "session" || held.kind === "chair") {
|
||||||
|
held.children.push(...twigs(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnFlat(root) {
|
||||||
|
const found = [];
|
||||||
|
const walk = (entry, depth, parent) => {
|
||||||
|
found.push({entry, depth, parent});
|
||||||
|
entry.children.forEach((child) => walk(child, depth + 1, entry));
|
||||||
|
};
|
||||||
|
walk(root, 0, null);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSpawn() {
|
||||||
|
if (spawn.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const due = state.graph.nodes.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id))
|
||||||
|
.filter((node) => !spawn.subs.has(node.id) || Date.now() - spawn.subs.get(node.id).at > SUBS_TTL);
|
||||||
|
if (!due.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawn.loading = true;
|
||||||
|
await Promise.all(due.map(async (node) => {
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/subagents?session=${encodeURIComponent(node.id)}`, {cache: "no-store"});
|
||||||
|
data = response.ok ? await response.json() : null;
|
||||||
|
} catch {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
spawn.subs.set(node.id, {at: Date.now(), data});
|
||||||
|
}));
|
||||||
|
spawn.loading = false;
|
||||||
|
drawSpawn();
|
||||||
|
stage();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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]);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnScreen() {
|
||||||
|
const list = h("div", {class: "card spawn"});
|
||||||
|
spawn.dom = {list};
|
||||||
|
window.setTimeout(() => {
|
||||||
|
drawSpawn();
|
||||||
|
loadSpawn();
|
||||||
|
}, 0);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSpawn() {
|
||||||
|
if (!spawn.dom || !spawn.dom.list.isConnected) {
|
||||||
|
spawn.dom = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = spawnTree();
|
||||||
|
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("ul", {class: "spawn-tree root"}, spawnItem(root)));
|
||||||
|
}
|
||||||
@@ -60,13 +60,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function micButton(field) {
|
function micButton(field) {
|
||||||
const button = h("button", {type: "button", class: "btn small ghost icon-btn mic", "aria-pressed": "false", "aria-label": "Spracheingabe: gedrückt halten und sprechen", title: "Gedrückt halten und sprechen; der Text landet im Feld und wird nicht gesendet"}, "🎙");
|
const 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 reason = !window.isSecureContext || !navigator.mediaDevices
|
const reason = !window.isSecureContext || !navigator.mediaDevices
|
||||||
? "Das Mikrofon braucht einen sicheren Kontext: öffne den Hub über 127.0.0.1 oder localhost"
|
? "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}` : "";
|
: !window.MediaRecorder ? "Dieser Browser kann nicht aufnehmen" : !state.speech.stt ? `Spracheingabe ist aus: ${state.speech.stt_off}` : "";
|
||||||
if (reason) {
|
if (reason) {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.title = reason;
|
button.title = `Spracheingabe: ${reason}`;
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
const press = (event) => {
|
const press = (event) => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||||
const tree = {data: null, busy: false, q: "", open: new Set(), dom: null};
|
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
||||||
const palette = {items: [], index: 0, input: null, list: null, plan: null, box: null};
|
const palette = {items: [], index: 0, input: null, list: null, plan: null, box: null};
|
||||||
const known = {inbox: null, alarms: null, radio: null, batches: new Set()};
|
const known = {inbox: null, alarms: null, radio: null, batches: new Set()};
|
||||||
const motion = {current: new Map(), from: new Map(), target: new Map(), start: 0, duration: 0, frame: 0, asOf: undefined};
|
const motion = {current: new Map(), from: new Map(), target: new Map(), start: 0, duration: 0, frame: 0, asOf: undefined};
|
||||||
@@ -43,9 +43,7 @@
|
|||||||
const speech = {pending: [], busy: false, warned: 0};
|
const speech = {pending: [], busy: false, warned: 0};
|
||||||
const mic = {recorder: null, held: false, busy: false, timer: 0};
|
const mic = {recorder: null, held: false, busy: false, timer: 0};
|
||||||
const space = {
|
const space = {
|
||||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0,
|
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0, look: false, compass: null,
|
||||||
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0,
|
|
||||||
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
|
|
||||||
};
|
};
|
||||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||||
let settings = state.settings;
|
let settings = state.settings;
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
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];
|
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") {
|
if (entry.kind === "review") {
|
||||||
return [entry.text ? h("pre", {class: "notes"}, entry.text) : null, h("div", {class: "line-actions"}, btn("Diff ansehen", "btn small soft", () => reviewSheet(entry.ref.id)))];
|
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)))];
|
||||||
}
|
}
|
||||||
if (entry.kind === "cron") {
|
if (entry.kind === "cron") {
|
||||||
const ref = entry.ref;
|
const ref = entry.ref;
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
||||||
const focus = h("div", {class: "chips"});
|
const focus = h("div", {class: "chips"});
|
||||||
const list = h("div", {});
|
const list = h("div", {});
|
||||||
const more = h("details", {class: "menu"}, h("summary", {}, "Filter ▾"), h("div", {class: "menu-box end"},
|
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()));
|
h("h3", {}, "Arten"), kinds, h("h3", {}, "Transkript-Teile"), parts, h("h3", {}, "Gespeicherte Ansichten"), saved, h("h3", {}, "Eigener Zeitraum"), customRange()));
|
||||||
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
|
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
||||||
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
|
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 slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
|
||||||
const play = h("button", {type: "button", class: "btn small soft"}, "▶ Wiedergabe");
|
const play = iconBtn("bi-play-fill", "Wiedergabe", "btn small soft", null);
|
||||||
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
const liveButton = h("button", {type: "button", class: "btn small ghost"}, "Live");
|
||||||
const label = h("span", {class: "window-label"});
|
const label = h("span", {class: "window-label"});
|
||||||
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
const resume = ui.asOf !== null && ui.asOf < state.now - 60;
|
const resume = ui.asOf !== null && ui.asOf < state.now - 60;
|
||||||
let progress = resume ? (ui.asOf - began) / span : 0;
|
let progress = resume ? (ui.asOf - began) / span : 0;
|
||||||
ui.playing = true;
|
ui.playing = true;
|
||||||
timeDom.play.textContent = "❚❚ Pause";
|
relabel(timeDom.play, "bi-pause-fill", "Pause");
|
||||||
const advance = async () => {
|
const advance = async () => {
|
||||||
if (!ui.playing) {
|
if (!ui.playing) {
|
||||||
return;
|
return;
|
||||||
@@ -127,6 +127,6 @@
|
|||||||
ui.playing = false;
|
ui.playing = false;
|
||||||
window.clearTimeout(replay);
|
window.clearTimeout(replay);
|
||||||
if (timeDom) {
|
if (timeDom) {
|
||||||
timeDom.play.textContent = "▶ Wiedergabe";
|
relabel(timeDom.play, "bi-play-fill", "Wiedergabe");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +1,84 @@
|
|||||||
function treeScreen() {
|
const chip = (name, label, tone, count) => h("span", {class: "state", "data-tone": tone, role: "img", ...named(label)}, icon(name), count === undefined ? null : String(count));
|
||||||
const search = h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
|
|
||||||
tree.q = event.target.value;
|
function branchStates(entry) {
|
||||||
drawTree();
|
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,
|
||||||
const count = h("span", {class: "muted"});
|
entry.state === "unmerged" ? chip("bi-signpost-split", "nicht gemergt", "neutral") : null,
|
||||||
const list = h("div", {class: "repos"});
|
entry.state === "protected" ? chip("bi-shield-lock", "geschützt", "violet") : null,
|
||||||
tree.dom = {list, count};
|
entry.state === "stale" ? chip("bi-hourglass-bottom", "veraltet", "warn") : null,
|
||||||
window.setTimeout(() => {
|
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} Commits ungepusht`, "info", entry.ahead) : null,
|
||||||
drawTree();
|
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} Commits zurück`, "warn", entry.behind) : null,
|
||||||
fetchTree();
|
entry.gone ? chip("bi-cloud-slash", "Upstream weg", "bad") : null,
|
||||||
}, 0);
|
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "ohne Upstream", "neutral") : null,
|
||||||
return h("div", {}, h("div", {class: "filters"}, search, count, btn("Neu laden", "btn small soft", fetchTree)), list);
|
entry.skip ? chip("bi-slash-circle", `gc überspringt: ${entry.skip}`, "bad") : null,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTree() {
|
function worktreeStates(wt) {
|
||||||
if (tree.busy) {
|
const counts = [wt.staged, wt.unstaged, wt.untracked];
|
||||||
return;
|
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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
tree.busy = true;
|
|
||||||
try {
|
function deleteButton(entry) {
|
||||||
const response = await fetch("/api/tree", {cache: "no-store"});
|
if (!entry.cleanup) {
|
||||||
if (response.ok) {
|
return null;
|
||||||
tree.data = await response.json();
|
|
||||||
}
|
}
|
||||||
} catch {
|
const ready = tree.data.delete_ready === true;
|
||||||
ui.offline = 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);
|
||||||
|
button.disabled = !ready;
|
||||||
|
return h("span", {class: "hint", title: hint}, button);
|
||||||
}
|
}
|
||||||
tree.busy = false;
|
|
||||||
drawTree();
|
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);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
function worktreeRow(wt) {
|
function worktreeRow(wt) {
|
||||||
const review = wt.review;
|
const review = wt.review;
|
||||||
return h("div", {class: "wt"},
|
return h("div", {class: "wt"},
|
||||||
|
h("div", {class: "wt-head"},
|
||||||
|
h("span", {class: "states"}, worktreeStates(wt)),
|
||||||
h("code", {}, wt.path),
|
h("code", {}, wt.path),
|
||||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||||
wt.dirty === null ? pill("fehlt", "neutral") : wt.dirty ? pill("geändert", "warn") : pill("sauber", "ok"),
|
|
||||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
|
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,
|
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||||
copyButton(wt.path));
|
copyButton(wt.path)),
|
||||||
|
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultOf(repo, entry) {
|
||||||
|
const found = (tree.data.cleanups || []).flatMap((result) => result.items.map((item) => ({...item, finished: result.finished}))).filter((item) => item.repository === repo.path && item.branch === entry.name);
|
||||||
|
return found.length ? found[found.length - 1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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"];
|
||||||
|
return chip(name, `${label}${result.reason ? `: ${result.reason}` : ""}`, tone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchRow(repo, entry) {
|
||||||
|
const result = resultOf(repo, entry);
|
||||||
|
return h("div", {class: "branch-row"},
|
||||||
|
h("div", {class: "branch-name"}, h("strong", {}, entry.name), h("span", {class: "states"}, branchStates(entry)), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : null,
|
||||||
|
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), deleteButton(entry)),
|
||||||
|
entry.worktrees.map(worktreeRow));
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawTree() {
|
function drawTree() {
|
||||||
@@ -47,9 +87,11 @@
|
|||||||
}
|
}
|
||||||
if (!tree.data || tree.data.error) {
|
if (!tree.data || tree.data.error) {
|
||||||
tree.dom.count.textContent = "";
|
tree.dom.count.textContent = "";
|
||||||
tree.dom.list.replaceChildren(emptyState(tree.busy ? "Lese Repositories …" : "Kein Tree", tree.data ? tree.data.error : "pkgmgr schreibt den Tree jede Minute, solange diese Seite offen ist."));
|
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."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
tree.dom.removeAll.replaceChildren(deleteAll());
|
||||||
const q = tree.q.trim().toLowerCase();
|
const q = tree.q.trim().toLowerCase();
|
||||||
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
|
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
|
||||||
const repos = [];
|
const repos = [];
|
||||||
@@ -60,21 +102,22 @@
|
|||||||
repos.push({repo, branches, detached});
|
repos.push({repo, branches, detached});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
|
const tasks = (tree.data.tasks || []).filter((task) => hit(task.task, task.title, ...task.artefacts.map((found) => found.ref)));
|
||||||
if (!repos.length) {
|
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}`}`;
|
||||||
|
if (!repos.length && !tasks.length && !gone.length) {
|
||||||
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
|
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tree.dom.list.replaceChildren(...repos.map(({repo, branches, detached}) => {
|
const cards = repos.map(({repo, branches, detached}) => {
|
||||||
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
|
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
|
||||||
const dirty = trees.filter((wt) => wt.dirty).length;
|
const dirty = trees.filter((wt) => wt.dirty).length;
|
||||||
|
const merged = repo.branches.filter((entry) => entry.cleanup).length;
|
||||||
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
|
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
|
||||||
h("summary", {}, h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), dirty ? pill(`${dirty} geändert`, "warn") : null),
|
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
|
||||||
branches.map((entry) => h("div", {class: "branch-row"},
|
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),
|
||||||
h("div", {class: "branch-name"}, h("strong", {}, entry.name), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : h("span", {class: "muted"}, "ohne Upstream"),
|
branches.map((entry) => branchRow(repo, entry)),
|
||||||
entry.ahead ? pill(`${entry.ahead} voraus`, "info") : null, entry.behind ? pill(`${entry.behind} zurück`, "warn") : null, entry.gone ? pill("Upstream weg", "bad") : null,
|
|
||||||
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10))),
|
|
||||||
entry.worktrees.map(worktreeRow))),
|
|
||||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
||||||
details.addEventListener("toggle", () => {
|
details.addEventListener("toggle", () => {
|
||||||
if (details.open) {
|
if (details.open) {
|
||||||
@@ -84,5 +127,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return details;
|
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)),
|
||||||
|
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)),
|
||||||
|
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 || "");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Version 1.80.1 3d-force-graph - https://github.com/vasturiano/3d-force-graph
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).ForceGraph3D=t()}(this,function(){"use strict";!function(e,t){void 0===t&&(t={});var n=t.insertAt;if("undefined"!=typeof document){var i=document.head||document.getElementsByTagName("head")[0],r=document.createElement("style");r.type="text/css","top"===n&&i.firstChild?i.insertBefore(r,i.firstChild):i.appendChild(r),r.styleSheet?r.styleSheet.cssText=e:r.appendChild(document.createTextNode(e))}}(".graph-info-msg {\n top: 50%;\n width: 100%;\n text-align: center;\n color: lavender;\n opacity: 0.7;\n font-size: 22px;\n position: absolute;\n font-family: Sans-serif;\n}\n\n.scene-container .clickable {\n cursor: pointer;\n}\n\n.scene-container .grabbable {\n cursor: move;\n cursor: grab;\n cursor: -moz-grab;\n cursor: -webkit-grab;\n}\n\n.scene-container .grabbable:active {\n cursor: grabbing;\n cursor: -moz-grabbing;\n cursor: -webkit-grabbing;\n}");const e="186",t=0,n=1,i=2,r=0,s=1,a=2,o=3,l=1,u=3,c=0,h=1,d=2,p=100,f=101,m=102,g=103,_=104,v=200,y=201,b=202,x=203,S=204,T=205,M=206,w=207,E=208,A=209,R=210,C=211,N=212,P=213,L=214,D=0,I=1,U=2,F=3,O=4,B=5,k=6,z=7,V=0,G=1,H=2,W=1,j=2,$=3,X=4,q=5,Y=6,K=7,Z=301,Q=302,J=303,ee=304,te=306,ne=1e3,ie=1001,re=1002,se=1003,ae=1004,oe=1005,le=1006,ue=1007,ce=1008,he=1009,de=1010,pe=1011,fe=1012,me=1013,ge=1014,_e=1015,ve=1016,ye=1017,be=1018,xe=1020,Se=35902,Te=35899,Me=1021,we=1022,Ee=1023,Ae=1026,Re=1027,Ce=1028,Ne=1029,Pe=1030,Le=1031,De=1032,Ie=1033,Ue=33776,Fe=33777,Oe=33778,Be=33779,ke=35840,ze=35841,Ve=35842,Ge=35843,He=36196,We=37492,je=37496,$e=37488,Xe=37489,qe=37490,Ye=37491,Ke=37808,Ze=37809,Qe=37810,Je=37811,et=37812,tt=37813,nt=37814,it=37815,rt=37816,st=37817,at=37818,ot=37819,lt=37820,ut=37821,ct=36492,ht=36494,dt=36495,pt=36283,ft=36284,mt=36285,gt=36286,_t="",vt="srgb",yt="srgb-linear",bt="linear",xt="srgb",St=7680,Tt=512,Mt=513,wt=514,Et=515,At=516,Rt=517,Ct=518,Nt=519,Pt=35044,Lt=35048,Dt="300 es",It=2e3,Ut=2001,Ft="compute",Ot="render",Bt="depthTextureCompare",kt=0,zt=1,Vt=2;function Gt(e){return ArrayBuffer.isView(e)&&!(e instanceof DataView)}function Ht(e){return document.createElementNS("http://www.w3.org/1999/xhtml",e)}function Wt(){const e=Ht("canvas");return e.style.display="block",e}const jt={};function $t(...e){const t="THREE."+e.shift();console.log(t,...e)}function Xt(e){const t=e[0];if("string"==typeof t&&t.startsWith("TSL:")){const t=e[1];t&&t.isStackTrace?e[0]+=" "+t.getLocation():e[1]='Stack trace not available. Enable "THREE.Node.captureStackTrace" to capture stack traces.'}return e}function qt(...e){const t="THREE."+(e=Xt(e)).shift();{const n=e[0];n&&n.isStackTrace?console.warn(n.getError(t)):console.warn(t,...e)}}function Yt(...e){const t="THREE."+(e=Xt(e)).shift();{const n=e[0];n&&n.isStackTrace?console.error(n.getError(t)):console.error(t,...e)}}function Kt(...e){const t=e.join(" ");t in jt||(jt[t]=!0,qt(...e))}function Zt(){return"undefined"!=typeof self&&void 0!==self.scheduler&&void 0!==self.scheduler.yield?self.scheduler.yield():new Promise(e=>{requestAnimationFrame(e)})}const Qt={[D]:1,[U]:6,[O]:7,[F]:5,[I]:0,[k]:2,[z]:4,[B]:3};class Jt{addEventListener(e,t){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[e]&&(n[e]=[]),-1===n[e].indexOf(t)&&n[e].push(t)}hasEventListener(e,t){const n=this._listeners;return void 0!==n&&(void 0!==n[e]&&-1!==n[e].indexOf(t))}removeEventListener(e,t){const n=this._listeners;if(void 0===n)return;const i=n[e];if(void 0!==i){const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}dispatchEvent(e){const t=this._listeners;if(void 0===t)return;const n=t[e.type];if(void 0!==n){e.target=this;const t=n.slice(0);for(let n=0,i=t.length;n<i;n++)t[n].call(this,e);e.target=null}}}const en=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","Line truncated
|
||||||
|
// <http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef (WCAG Version 2)
|
||||||
|
// Analyze the 2 colors and returns the color contrast defined by (WCAG Version 2)
|
||||||
|
op.readability=function(e,t){var n=op(e),i=op(t);return(Math.max(n.getLuminance(),i.getLuminance())+.05)/(Math.min(n.getLuminance(),i.getLuminance())+.05)},op.isReadable=function(e,t,n){var i,r,s=op.readability(e,t);switch(r=!1,(i=function(e){var t,n;t=((e=e||{level:"AA",size:"small"}).level||"AA").toUpperCase(),n=(e.size||"small").toLowerCase(),"AA"!==t&&"AAA"!==t&&(t="AA");"small"!==n&&"large"!==n&&(n="small");return{level:t,size:n}}(n)).level+i.size){case"AAsmall":case"AAAlarge":r=s>=4.5;break;case"AAlarge":r=s>=3;break;case"AAAsmall":r=s>=7}return r},op.mostReadable=function(e,t,n){var i,r,s,a,o=null,l=0;r=(n=n||{}).includeFallbackColors,s=n.level,a=n.size;for(var u=0;u<t.length;u++)(i=op.readability(e,t[u]))>l&&(l=i,o=op(t[u]));return op.isReadable(e,o,{level:s,size:a})||!r?o:(n.includeFallbackColors=!1,op.mostReadable(e,["#fff","#000"],n))};var Mp=op.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"},wp=op.hexNames=function(e){var t={};for(var n in e)e.hasOwnProperty(n)&&(t[e[n]]=n);return t}(Mp);function Ep(e){return e=parseFloat(e),(isNaN(e)||e<0||e>1)&&(e=1),e}function Ap(e,t){(function(e){return"string"==typeof e&&-1!=e.indexOf(".")&&1===parseFloat(e)})(e)&&(e="100%");var n=function(e){return"string"==typeof e&&-1!=e.indexOf("%")}(e);return e=Math.min(t,Math.max(0,parseFloat(e))),n&&(e=parseInt(e*t,10)/100),Math.abs(e-t)<1e-6?1:e%t/parseFloat(t)}function Rp(e){return Math.min(1,Math.max(0,e))}function Cp(e){return parseInt(e,16)}function Np(e){return 1==e.length?"0"+e:""+e}function Pp(e){return e<=1&&(e=100*e+"%"),e}function Lp(e){return Math.round(255*parseFloat(e)).toString(16)}function Dp(e){return Cp(e)/255}var Ip,Up,Fp,Op=(Up="[\\s|\\(]+("+(Ip="(?:[-\\+]?\\d*\\.\\d+%?)|(?:[-\\+]?\\d+%?)")+")[,|\\s]+("+Ip+")[,|\\s]+("+Ip+")\\s*\\)?",Fp="[\\s|\\(]+("+Ip+")[,|\\s]+("+Ip+")[,|\\s]+("+Ip+")[,|\\s]+("+Ip+")\\s*\\)?",{CSS_UNIT:new RegExp(Ip),rgb:new RegExp("rgb"+Up),rgba:new RegExp("rgba"+Fp),hsl:new RegExp("hsl"+Up),hsla:new RegExp("hsla"+Fp),hsv:new RegExp("hsv"+Up),hsva:new RegExp("hsva"+Fp),hex3:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex4:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex8:/^#?([0-9a-fA-F]Line truncated
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017 Vasco Asturiano
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2019-2024 The Bootstrap Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol class="bi bi-0-circle" viewBox="0 0 16 16" id="0-circle"><path d="M7.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8"/></symbol><symbol class="bi bi-0-circle-fill" viewBox="0 0 16 16" id="0-circle-fill"><path d="M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-8.012 4.158c1.858 0 2.96-1.582 2.96-3.99V7.84c0-2.426-1.079-3.996-2.936-3.996-1.864 0-2.965 1.588-2.965 3.996v.328c0 2.42 1.09 3.99 2.941 3.99"/></symbol><symbol class="bi bi-0-square" viewBox="0 0 16 16" id="0-square"><path d="M7.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-0-square-fill" viewBox="0 0 16 16" id="0-square-fill"><path d="M8 4.951c-1.008 0-1.629 1.09-1.629 2.895v.31c0 1.81.627 2.895 1.629 2.895s1.623-1.09 1.623-2.895v-.31c0-1.8-.621-2.895-1.623-2.895"/><path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm5.988 12.158c-1.851 0-2.941-1.57-2.941-3.99V7.84c0-2.408 1.101-3.996 2.965-3.996 1.857 0 2.935 1.57 2.935 3.996v.328c0 2.408-1.101 3.99-2.959 3.99"/></symbol><symbol class="bi bi-1-circle" viewBox="0 0 16 16" id="1-circle"><path d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/></symbol><symbol class="bi bi-1-circle-fill" viewBox="0 0 16 16" id="1-circle-fill"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M9.283 4.002H7.971L6.072 5.385v1.271l1.834-1.318h.065V12h1.312z"/></symbol><symbol class="bi bi-1-square" viewBox="0 0 16 16" id="1-square"><path d="M9.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-1-square-fill" viewBox="0 0 16 16" id="1-square-fill"><path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zm7.283 4.002V12H7.971V5.338h-.065L6.072 6.656V5.385l1.899-1.383z"/></symbol><symbol class="bi bi-123" viewBox="0 0 16 16" id="123"><path d="M2.873 11.297V4.142H1.699L0 5.379v1.137l1.64-1.18h.06v5.961zm3.213-5.09v-.063c0-.618.44-1.169 1.196-1.169.676 0 1.174.44 1.174 1.106 0 .624-.42 1.101-.807 1.526L4.99 10.553v.744h4.78v-.99H6.643v-.069L8.41 8.252c.65-.724 1.237-1.332 1.237-2.27C9.646 4.849 8.723 4 7.308 4c-1.573 0-2.36 1.064-2.36 2.15v.057zm6.559 1.883h.786c.823 0 1.374.481 1.379 1.179.01.707-.55 1.216-1.421 1.21-.77-.005-1.326-.419-1.379-.953h-1.095c.042 1.053.938 1.918 2.464 1.918 1.478 0 2.642-.839 2.62-2.144-.02-1.143-.922-1.651-1.551-1.714v-.063c.535-.09 1.347-.66 1.326-1.678-.026-1.053-.933-1.855-2.359-1.845-1.5.005-2.317.88-2.348 1.898h1.116c.032-.498.498-.944 1.206-.944.703 0 1.206.435 1.206 1.07.005.64-.504 1.106-1.2 1.106h-.75z"/></symbol><symbol class="bi bi-2-circle" viewBox="0 0 16 16" id="2-circle"><path d="M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306"/></symbol><symbol class="bi bi-2-circle-fill" viewBox="0 0 16 16" id="2-circle-fill"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M6.646 6.24c0-.691.493-1.306 1.336-1.306.756 0 1.313.492 1.313 1.236 0 .697-.469 1.23-.902 1.705l-2.971 3.293V12h5.344v-1.107H7.268v-.077l1.974-2.22.096-.107c.688-.763 1.287-1.428 1.287-2.43 0-1.266-1.031-2.215-2.613-2.215-1.758 0-2.637 1.19-2.637 2.402v.065h1.271v-.07Z"/></symbol><symbol class="bi bi-2-square" viewBox="0 0 16 16" id="2-square"><path d="M6.646 6.24v.07H5.375v-.064c0-1.213.879-2.402 2.637-2.402 1.582 0 2.613.949 2.613 2.215 0 1.002-.6 1.667-1.287 2.43l-.096.107-1.974 2.22v.077h3.498V12H5.422v-.832l2.97-3.293c.434-.475.903-1.008.903-1.705 0-.744-.557-1.236-1.313-1.236-.843 0-1.336.615-1.336 1.306"/><path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm15 0a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1z"/></symbol><symbol class="bi bi-2-square-fill" viewBox="0 0 Line truncated
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright © 2010-2026 three.js authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
+60586
File diff suppressed because it is too large.
Load diff
+19719
File diff suppressed because it is too large.
Load diff
+13
-5
@@ -3,8 +3,9 @@
|
|||||||
# install migrate, then copy orchestrator/ to ~/.local/share/kvb-orchestrator,
|
# install migrate, then copy orchestrator/ to ~/.local/share/kvb-orchestrator,
|
||||||
# write its .env, build and start the container, register the MCP
|
# write its .env, build and start the container, register the MCP
|
||||||
# server for Claude Code, build pkgmgr from the newest signed tag of
|
# server for Claude Code, build pkgmgr from the newest signed tag of
|
||||||
# PKGMGR_REPO and enable the tree timer; re-enable the runner and
|
# PKGMGR_REPO and enable the tree timer and the cleanup path unit;
|
||||||
# re-register the hook when the migration took them over
|
# re-enable the runner and re-register the hook when the migration
|
||||||
|
# took them over
|
||||||
# migrate move every trace of the earlier name to the kvb-orchestrator names
|
# migrate move every trace of the earlier name to the kvb-orchestrator names
|
||||||
# once: container, image, code, state, approvals, pinned keys, MCP
|
# once: container, image, code, state, approvals, pinned keys, MCP
|
||||||
# server, PermissionRequest hook, runner unit; a second run changes
|
# server, PermissionRequest hook, runner unit; a second run changes
|
||||||
@@ -33,6 +34,7 @@ LOCAL_SETTINGS="${HOME}/.claude/settings.local.json"
|
|||||||
HOOK_TIMEOUT=110
|
HOOK_TIMEOUT=110
|
||||||
RUNNER="${NAME}-runner.service"
|
RUNNER="${NAME}-runner.service"
|
||||||
TREE_UNIT="${NAME}-tree"
|
TREE_UNIT="${NAME}-tree"
|
||||||
|
CLEANUP_UNIT="${NAME}-cleanup"
|
||||||
TREE_DIR="${HOME}/.local/state/pkgmgr/tree"
|
TREE_DIR="${HOME}/.local/state/pkgmgr/tree"
|
||||||
PKGMGR_REPO="${PKGMGR_REPO:-https://github.com/kevinveenbirkenbach/package-manager.git}"
|
PKGMGR_REPO="${PKGMGR_REPO:-https://github.com/kevinveenbirkenbach/package-manager.git}"
|
||||||
IMAGE="${NAME}:local"
|
IMAGE="${NAME}:local"
|
||||||
@@ -146,7 +148,7 @@ install_hub() {
|
|||||||
cp -a "${REPO_ROOT}/orchestrator/." "${HUB}/"
|
cp -a "${REPO_ROOT}/orchestrator/." "${HUB}/"
|
||||||
# Python loads a cached .pyc whose header matches the source mtime and size, and cp -a keeps the mtime: one planted in the sandbox-writable tree would run on the host
|
# Python loads a cached .pyc whose header matches the source mtime and size, and cp -a keeps the mtime: one planted in the sandbox-writable tree would run on the host
|
||||||
find "${HUB}" -name __pycache__ -type d -prune -exec rm -rf {} +
|
find "${HUB}" -name __pycache__ -type d -prune -exec rm -rf {} +
|
||||||
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py"
|
chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py"
|
||||||
log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.py"
|
log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.py"
|
||||||
log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL=<blob>'; until then every executor refuses"
|
log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL=<blob>'; until then every executor refuses"
|
||||||
printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \
|
printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \
|
||||||
@@ -214,10 +216,11 @@ tree_timer() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
build_pkgmgr || return 0
|
build_pkgmgr || return 0
|
||||||
cp -f "${HUB}/systemd/${TREE_UNIT}.service" "${HUB}/systemd/${TREE_UNIT}.timer" "${UNITS}/"
|
cp -f "${HUB}/systemd/${TREE_UNIT}.service" "${HUB}/systemd/${TREE_UNIT}.timer" "${HUB}/systemd/${CLEANUP_UNIT}.service" "${HUB}/systemd/${CLEANUP_UNIT}.path" "${UNITS}/"
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user enable --now "${TREE_UNIT}.timer"
|
systemctl --user enable --now "${TREE_UNIT}.timer" "${CLEANUP_UNIT}.path"
|
||||||
log "skills: ${TREE_UNIT}.timer writes ${TREE_DIR}/branch-tree.json every 60 s while the Agent Orchestrator page polls"
|
log "skills: ${TREE_UNIT}.timer writes ${TREE_DIR}/branch-tree.json every 60 s while the Agent Orchestrator page polls"
|
||||||
|
log "skills: ${CLEANUP_UNIT}.path deletes the merged branches the operator signs in the Artifacts Tree, locally only"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_all() {
|
install_all() {
|
||||||
@@ -287,6 +290,11 @@ check() {
|
|||||||
else
|
else
|
||||||
todo "${TREE_UNIT}.timer off: make orchestrator-install with a signed pkgmgr tag that has 'branch tree'"
|
todo "${TREE_UNIT}.timer off: make orchestrator-install with a signed pkgmgr tag that has 'branch tree'"
|
||||||
fi
|
fi
|
||||||
|
if [[ "$(systemctl --user is-active "${CLEANUP_UNIT}.path" 2>/dev/null || true)" == "active" ]]; then
|
||||||
|
ok "${CLEANUP_UNIT}.path active"
|
||||||
|
else
|
||||||
|
todo "${CLEANUP_UNIT}.path off: deleting merged branches from the Artifacts Tree waits for make orchestrator-install"
|
||||||
|
fi
|
||||||
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
|
free="$(df --output=avail -BG / | tail -1 | tr -dc '0-9')"
|
||||||
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
|
if ((free >= FLOOR_GB)); then ok "/ has ${free} GB free"; else fail "/ has ${free} GB free, floor ${FLOOR_GB} GB"; fi
|
||||||
return "${FAILED}"
|
return "${FAILED}"
|
||||||
|
|||||||
@@ -13,13 +13,17 @@
|
|||||||
#progress { position: fixed; left: 0; bottom: 0; height: 3px; width: 0; background: linear-gradient(90deg, #9cf, #c9f); box-shadow: 0 0 12px #9cf9; opacity: .7; }
|
#progress { position: fixed; left: 0; bottom: 0; height: 3px; width: 0; background: linear-gradient(90deg, #9cf, #c9f); box-shadow: 0 0 12px #9cf9; opacity: .7; }
|
||||||
#level { position: fixed; top: 1rem; right: 1rem; opacity: 0; transition: opacity .6s; letter-spacing: .2em; text-shadow: 0 0 10px #fff8; }
|
#level { position: fixed; top: 1rem; right: 1rem; opacity: 0; transition: opacity .6s; letter-spacing: .2em; text-shadow: 0 0 10px #fff8; }
|
||||||
button { font: inherit; font-size: 1.4rem; letter-spacing: .4em; text-transform: lowercase; padding: .8rem 2.4rem; border-radius: 2rem; border: 1px solid #fff6; background: #fff1; color: #fff; cursor: pointer; text-shadow: 0 0 12px #fff8; }
|
button { font: inherit; font-size: 1.4rem; letter-spacing: .4em; text-transform: lowercase; padding: .8rem 2.4rem; border-radius: 2rem; border: 1px solid #fff6; background: #fff1; color: #fff; cursor: pointer; text-shadow: 0 0 12px #fff8; }
|
||||||
|
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
.bi { width: 1.4em; height: 1.4em; fill: currentColor; vertical-align: -.25em; filter: drop-shadow(0 0 8px #fff8); }
|
||||||
|
#level .bi { width: 1em; height: 1em; margin-right: .4em; vertical-align: -.125em; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true" focusable="false"><symbol id="bi-brightness-high" viewBox="0 0 16 16"><path d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6m0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0m9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708"/></symbol><symbol id="bi-play-fill" viewBox="0 0 16 16"><path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393"/></symbol></svg>
|
||||||
<canvas id="view"></canvas>
|
<canvas id="view"></canvas>
|
||||||
<div id="ui"><div id="breath"></div><button id="start">Start</button></div>
|
<div id="ui"><div id="breath"></div><button id="start" title="Start" aria-label="Start"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-play-fill"></use></svg></button></div>
|
||||||
<div id="clock"></div>
|
<div id="clock"></div>
|
||||||
<div id="level"></div>
|
<div id="level" title="Brightness" aria-label="Brightness"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-brightness-high"></use></svg><span id="level-text"></span></div>
|
||||||
<div id="progress"></div>
|
<div id="progress"></div>
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"use strict";
|
||||||
@@ -124,6 +128,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const levelEl = document.getElementById("level");
|
const levelEl = document.getElementById("level");
|
||||||
|
const levelText = document.getElementById("level-text");
|
||||||
const progressEl = document.getElementById("progress");
|
const progressEl = document.getElementById("progress");
|
||||||
const BRIGHTNESS = "kvb-coach-meditation-brightness";
|
const BRIGHTNESS = "kvb-coach-meditation-brightness";
|
||||||
const stored = () => {
|
const stored = () => {
|
||||||
@@ -153,7 +158,7 @@ addEventListener("keydown", (event) => {
|
|||||||
brightness = Math.min(1.6, Math.max(0.2, Math.round((brightness + step) * 10) / 10));
|
brightness = Math.min(1.6, Math.max(0.2, Math.round((brightness + step) * 10) / 10));
|
||||||
showBrightness();
|
showBrightness();
|
||||||
const saved = store(brightness);
|
const saved = store(brightness);
|
||||||
levelEl.textContent = `☀ ${Math.round(brightness * 100)} %${saved ? "" : " (not saved)"}`;
|
levelText.textContent = `${Math.round(brightness * 100)} %${saved ? "" : " (not saved)"}`;
|
||||||
levelEl.style.opacity = "1";
|
levelEl.style.opacity = "1";
|
||||||
clearTimeout(levelTimer);
|
clearTimeout(levelTimer);
|
||||||
levelTimer = setTimeout(() => { levelEl.style.opacity = "0"; }, 1200);
|
levelTimer = setTimeout(() => { levelEl.style.opacity = "0"; }, 1200);
|
||||||
|
|||||||
@@ -44,6 +44,70 @@
|
|||||||
"reason": "dirty: 1 paths",
|
"reason": "dirty: 1 paths",
|
||||||
"result": null
|
"result": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"branch": "feature/done",
|
||||||
|
"worktree": "@ROOT@/worktrees/github.com/me/demo/feature-done",
|
||||||
|
"head": "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4",
|
||||||
|
"detached": false,
|
||||||
|
"locked": null,
|
||||||
|
"worktree_state": "clean",
|
||||||
|
"staged": 0,
|
||||||
|
"unstaged": 0,
|
||||||
|
"untracked": 0,
|
||||||
|
"upstream": "origin/feature/done",
|
||||||
|
"ahead": 0,
|
||||||
|
"behind": 0,
|
||||||
|
"gone": true,
|
||||||
|
"local_sha": "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4",
|
||||||
|
"remotes": {},
|
||||||
|
"compose_projects": [
|
||||||
|
"demo-feature-done"
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
"demo:feature-done"
|
||||||
|
],
|
||||||
|
"state": "merged-clean",
|
||||||
|
"merged_by": "#12",
|
||||||
|
"merged_into": "main",
|
||||||
|
"size_bytes": 4096,
|
||||||
|
"skip": null,
|
||||||
|
"actions": [
|
||||||
|
"worktree",
|
||||||
|
"local-branch",
|
||||||
|
"remote-branch"
|
||||||
|
],
|
||||||
|
"reason": "merged into main by #12",
|
||||||
|
"result": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "feature/kept",
|
||||||
|
"worktree": null,
|
||||||
|
"head": null,
|
||||||
|
"detached": false,
|
||||||
|
"locked": null,
|
||||||
|
"worktree_state": "none",
|
||||||
|
"staged": null,
|
||||||
|
"unstaged": null,
|
||||||
|
"untracked": null,
|
||||||
|
"upstream": null,
|
||||||
|
"ahead": null,
|
||||||
|
"behind": null,
|
||||||
|
"gone": false,
|
||||||
|
"local_sha": "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d",
|
||||||
|
"remotes": {},
|
||||||
|
"compose_projects": [],
|
||||||
|
"images": [],
|
||||||
|
"state": "merged-clean",
|
||||||
|
"merged_by": "ancestor",
|
||||||
|
"merged_into": "main",
|
||||||
|
"size_bytes": null,
|
||||||
|
"skip": "ignored files beyond known caches: notes.txt",
|
||||||
|
"actions": [
|
||||||
|
"local-branch"
|
||||||
|
],
|
||||||
|
"reason": "merged into main",
|
||||||
|
"result": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"worktree": "@ROOT@/repos/github.com/me/demo",
|
"worktree": "@ROOT@/repos/github.com/me/demo",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import {spawn} from "node:child_process";
|
||||||
|
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||||
|
import {createServer} from "node:net";
|
||||||
|
import {tmpdir} from "node:os";
|
||||||
|
import {join} from "node:path";
|
||||||
|
|
||||||
|
// Args: <firefox binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot} like cdp.mjs.
|
||||||
|
// Prints {results, errors}: one probe value per step and every JavaScript error the page logged.
|
||||||
|
const [binary, planFile] = process.argv.slice(2);
|
||||||
|
const plan = JSON.parse(readFileSync(planFile, "utf8"));
|
||||||
|
const profile = mkdtempSync(join(tmpdir(), "bidi-"));
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const port = await new Promise((resolve) => {
|
||||||
|
const probe = createServer().listen(0, "127.0.0.1", () => {
|
||||||
|
const {port: free} = probe.address();
|
||||||
|
probe.close(() => resolve(free));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const browser = spawn(binary, ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], {stdio: ["ignore", "ignore", "pipe"]});
|
||||||
|
|
||||||
|
const address = await new Promise((resolve, reject) => {
|
||||||
|
let text = "";
|
||||||
|
const timer = setTimeout(() => reject(new Error(`no BiDi address: ${text.slice(-500)}`)), 60000);
|
||||||
|
browser.stderr.on("data", (chunk) => {
|
||||||
|
text += chunk;
|
||||||
|
const found = /WebDriver BiDi listening on (ws:\/\/\S+)/.exec(text);
|
||||||
|
if (found) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(found[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
browser.on("exit", (code) => reject(new Error(`browser exited with ${code}: ${text.slice(-500)}`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = new WebSocket(`${address}/session`);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.onopen = resolve;
|
||||||
|
socket.onerror = reject;
|
||||||
|
});
|
||||||
|
const waiting = new Map();
|
||||||
|
const errors = [];
|
||||||
|
let next = 0;
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.method === "log.entryAdded" && data.params.level === "error" && data.params.type === "javascript") {
|
||||||
|
errors.push(data.params.text);
|
||||||
|
}
|
||||||
|
if (data.id && waiting.has(data.id)) {
|
||||||
|
const {resolve, reject} = waiting.get(data.id);
|
||||||
|
waiting.delete(data.id);
|
||||||
|
if (data.type === "error") {
|
||||||
|
reject(new Error(`${data.error}: ${data.message}`));
|
||||||
|
} else {
|
||||||
|
resolve(data.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function send(method, params) {
|
||||||
|
next += 1;
|
||||||
|
const id = next;
|
||||||
|
socket.send(JSON.stringify({id, method, params}));
|
||||||
|
return new Promise((resolve, reject) => waiting.set(id, {resolve, reject}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
try {
|
||||||
|
await send("session.new", {capabilities: {}});
|
||||||
|
await send("session.subscribe", {events: ["log.entryAdded"]});
|
||||||
|
const {contexts} = await send("browsingContext.getTree", {});
|
||||||
|
const context = contexts[0].context;
|
||||||
|
for (const step of plan.steps) {
|
||||||
|
await send("browsingContext.setViewport", {context, viewport: {width: step.width, height: step.height}});
|
||||||
|
if (step.url) {
|
||||||
|
await send("browsingContext.navigate", {context, url: step.url, wait: "complete"});
|
||||||
|
}
|
||||||
|
await sleep(step.wait);
|
||||||
|
let value = null;
|
||||||
|
if (step.probe) {
|
||||||
|
const answer = await send("script.evaluate", {expression: `(async () => JSON.stringify(await ${step.probe}))()`, target: {context}, awaitPromise: true, resultOwnership: "none"});
|
||||||
|
value = answer.type === "success" ? JSON.parse(answer.result.value) : {error: answer.exceptionDetails.text};
|
||||||
|
}
|
||||||
|
if (step.shot) {
|
||||||
|
const {data} = await send("browsingContext.captureScreenshot", {context});
|
||||||
|
writeFileSync(step.shot, Buffer.from(data, "base64"));
|
||||||
|
}
|
||||||
|
results.push(value);
|
||||||
|
}
|
||||||
|
await send("browser.close", {}).catch(() => null);
|
||||||
|
} finally {
|
||||||
|
socket.close();
|
||||||
|
browser.kill();
|
||||||
|
await sleep(300);
|
||||||
|
rmSync(profile, {recursive: true, force: true});
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({results, errors}));
|
||||||
+10
-2
@@ -3,13 +3,15 @@ import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
|||||||
import {tmpdir} from "node:os";
|
import {tmpdir} from "node:os";
|
||||||
import {join} from "node:path";
|
import {join} from "node:path";
|
||||||
|
|
||||||
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot}.
|
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot, media} and an optional
|
||||||
|
// authenticator {credentialId, privateKey, rpId}: base64 id and PKCS#8 key of a passkey the page may sign with.
|
||||||
// Prints {results, errors}: one probe value per step and every uncaught page exception.
|
// Prints {results, errors}: one probe value per step and every uncaught page exception.
|
||||||
const [binary, planFile] = process.argv.slice(2);
|
const [binary, planFile] = process.argv.slice(2);
|
||||||
const plan = JSON.parse(readFileSync(planFile, "utf8"));
|
const plan = JSON.parse(readFileSync(planFile, "utf8"));
|
||||||
const profile = mkdtempSync(join(tmpdir(), "cdp-"));
|
const profile = mkdtempSync(join(tmpdir(), "cdp-"));
|
||||||
// --no-sandbox: the agent sandbox already forbids the user namespaces Chromium's own sandbox needs.
|
// --no-sandbox: the agent sandbox already forbids the user namespaces Chromium's own sandbox needs.
|
||||||
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
|
// --enable-unsafe-swiftshader: without a GPU, headless Chromium gives WebGL only through SwiftShader, which it no longer enables by itself.
|
||||||
|
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
|
||||||
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
|
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
@@ -65,8 +67,14 @@ try {
|
|||||||
const {sessionId} = await send("Target.attachToTarget", {targetId, flatten: true});
|
const {sessionId} = await send("Target.attachToTarget", {targetId, flatten: true});
|
||||||
const page = (method, params) => send(method, params, sessionId);
|
const page = (method, params) => send(method, params, sessionId);
|
||||||
await page("Runtime.enable", {});
|
await page("Runtime.enable", {});
|
||||||
|
if (plan.authenticator) {
|
||||||
|
await page("WebAuthn.enable", {enableUI: false});
|
||||||
|
const {authenticatorId} = await page("WebAuthn.addVirtualAuthenticator", {options: {protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true}});
|
||||||
|
await page("WebAuthn.addCredential", {authenticatorId, credential: {...plan.authenticator, isResidentCredential: false, signCount: 0}});
|
||||||
|
}
|
||||||
for (const step of plan.steps) {
|
for (const step of plan.steps) {
|
||||||
await page("Emulation.setDeviceMetricsOverride", {width: step.width, height: step.height, deviceScaleFactor: 1, mobile: false});
|
await page("Emulation.setDeviceMetricsOverride", {width: step.width, height: step.height, deviceScaleFactor: 1, mobile: false});
|
||||||
|
await page("Emulation.setEmulatedMedia", {features: step.media || []});
|
||||||
if (step.url) {
|
if (step.url) {
|
||||||
await page("Page.navigate", {url: step.url});
|
await page("Page.navigate", {url: step.url});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,87 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
sys.path.insert(0, str(HERE.parent))
|
sys.path.insert(0, str(HERE.parent))
|
||||||
|
|
||||||
from hub_case import HubCase
|
from hub_case import HubCase
|
||||||
from hub_support import ASK, iso
|
from hub_support import ASK, iso, verifier
|
||||||
|
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
||||||
|
|
||||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-e2e"))
|
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
||||||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||||
SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in ("orbit", "chord", "swimlane", "tree", "3d"))]
|
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||||
PROBE = """(() => {
|
SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS)]
|
||||||
const all = (selector) => [...document.querySelectorAll(selector)];
|
HELPERS = """
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const until = async (check, ms) => {
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const found = check();
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const all = (selector, root) => [...(root || document).querySelectorAll(selector)];
|
||||||
const text = (node) => (node ? node.textContent.trim() : null);
|
const text = (node) => (node ? node.textContent.trim() : null);
|
||||||
const label = (button) => text(button.firstChild);
|
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);
|
||||||
const badge = (button) => {
|
const badge = (button) => {
|
||||||
const found = button.querySelector(".badge");
|
const found = button.querySelector(".badge");
|
||||||
return found && !found.hidden ? text(found) : null;
|
return found && !found.hidden ? text(found) : null;
|
||||||
};
|
};
|
||||||
|
const icons = all("svg.bi").filter((svg) => !svg.closest(".sprite"));
|
||||||
const banner = document.getElementById("mayday");
|
const banner = document.getElementById("mayday");
|
||||||
return {
|
return {
|
||||||
hash: location.hash,
|
hash: location.hash,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
h1: text(document.querySelector("h1")),
|
h1: text(document.querySelector("h1")),
|
||||||
header: all("header.top button").map((button) => button.id),
|
header: all("header.top button").map((button) => button.id),
|
||||||
tabs: all("#tabs > .tab-group > button").map(label),
|
tabs: all("#tabs > .tab-group > button").map(name),
|
||||||
subs: all("#tabs .subtabs button").map(label),
|
subs: all("#tabs .subtabs button").map(name),
|
||||||
badges: Object.fromEntries(all("#tabs button").map((button) => [button.id, badge(button)])),
|
badges: Object.fromEntries(all("#tabs button").map((button) => [button.id, badge(button)])),
|
||||||
banner: banner.hidden ? null : text(banner),
|
banner: banner.hidden ? null : text(banner),
|
||||||
rail: Boolean(document.getElementById("rail")),
|
rail: Boolean(document.getElementById("rail")),
|
||||||
views: all('#main [aria-label="Ansicht"] button').map(text),
|
views: all('#main [aria-label="Ansicht"] button').map(name),
|
||||||
chips: all('#main [aria-label="Kategorien"] button').map(text),
|
layouts: all('#main [aria-label="Anordnung"] button').map(name),
|
||||||
menus: all("#main details.menu > summary").map(text),
|
chips: all('#main [aria-label="Kategorien"] button').map(name),
|
||||||
|
menus: all("#main details.menu > summary").map(name),
|
||||||
time: text(document.querySelector("#time > summary")),
|
time: text(document.querySelector("#time > summary")),
|
||||||
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
||||||
custom: Boolean(document.querySelector('#main details.menu input[type="datetime-local"]')),
|
custom: Boolean(document.querySelector('#main details.menu input[type="datetime-local"]')),
|
||||||
toolbar: all('#main [role="toolbar"] > *').map((node) => (node.tagName === "DETAILS" ? text(node.querySelector("summary")) : text(node))),
|
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node.querySelector("button") || node)),
|
||||||
toAll: all("#to-all input[type=radio]").map((input) => [input.value, input.checked]),
|
toAll: all("#to-all input[type=radio]").map((input) => [input.value, input.checked]),
|
||||||
inbox: all("#main .inbox-list > li").length,
|
inbox: all("#main .inbox-list > li").length,
|
||||||
|
icons: icons.length,
|
||||||
|
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]),
|
||||||
|
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")),
|
||||||
|
canvas: Boolean(document.querySelector("#main .graph3d canvas")),
|
||||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||||
};
|
};
|
||||||
})()"""
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
ACTIONS = """(async () => {
|
ACTIONS = """(async () => {@HELPERS@
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
const press = async (key) => {
|
const press = async (key) => {
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true}));
|
document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true}));
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
@@ -60,22 +90,79 @@ ACTIONS = """(async () => {
|
|||||||
const found = {keys: [await press("2"), await press("3"), await press("1")]};
|
const found = {keys: [await press("2"), await press("3"), await press("1")]};
|
||||||
document.getElementById("settings-open").click();
|
document.getElementById("settings-open").click();
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
found.ambience = [...document.querySelectorAll(".sheet-box form > section:first-child button")].map((button) => button.id);
|
found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id);
|
||||||
found.speech = [...document.querySelectorAll('.sheet-box [aria-label="Klassen"] input')].map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
found.speech = all('.sheet-box [aria-label="Klassen"] 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.querySelector(".sheet-head button").click();
|
||||||
document.getElementById("palette-open").click();
|
document.getElementById("palette-open").click();
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
found.palette = [...document.querySelectorAll(".palette-list li > span:first-child")].map((span) => span.textContent.trim());
|
found.palette = all(".palette-list li > span:first-child").map(text);
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
document.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||||
await sleep(200);
|
await sleep(200);
|
||||||
document.querySelector('#main [aria-label="Kategorien"] button:nth-child(2)').click();
|
document.querySelector('#main [aria-label="Kategorien"] button:nth-child(2)').click();
|
||||||
await sleep(900);
|
await sleep(900);
|
||||||
const someone = document.querySelector('#main button.linkish[title$="öffnen"]');
|
document.querySelector('#main button.linkish[title$="öffnen"]').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;
|
||||||
|
return found;
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
PICK_3D = """(async () => {@HELPERS@
|
||||||
|
const list = await until(() => document.querySelector("#main .space-list"), 8000);
|
||||||
|
if (!list) {
|
||||||
|
return {error: text(document.querySelector("#main .graph"))};
|
||||||
|
}
|
||||||
|
list.open = true;
|
||||||
|
const chip = all("#main .space-list [data-node]").find((node) => node.dataset.node.startsWith("@PREFIX@"));
|
||||||
|
if (!chip) {
|
||||||
|
return {error: all("#main .space-list [data-node]").map((node) => node.dataset.node)};
|
||||||
|
}
|
||||||
|
chip.click();
|
||||||
|
await sleep(600);
|
||||||
|
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);
|
||||||
someone.click();
|
someone.click();
|
||||||
await sleep(500);
|
await sleep(500);
|
||||||
found.drawer = [...document.querySelectorAll("#drawer input[type=radio]")].map((input) => [input.value, input.checked]);
|
document.querySelector('#drawer input[type=radio][value="call"]').click();
|
||||||
return found;
|
const field = document.querySelector("#drawer textarea");
|
||||||
})()"""
|
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);
|
||||||
|
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@
|
||||||
|
const card = await until(() => all("#main details.repo").find((details) => details.textContent.includes("github.com/me/demo")), 8000);
|
||||||
|
if (!card) {
|
||||||
|
return {error: text(document.getElementById("main"))};
|
||||||
|
}
|
||||||
|
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 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)};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
CONFIRM = """(async () => {@HELPERS@
|
||||||
|
all(".sheet-box button").find((button) => name(button) === "Löschen").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);
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def labelled(probe: dict) -> dict:
|
||||||
|
return {key: probe[key] for key in ("unlabelled", "broken")}
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
||||||
@@ -97,24 +184,55 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
{"ts": now - 60, "from": "Docs", "to": [], "text": "MAYDAY disk full", "class": "mayday", "v": 2},
|
{"ts": now - 60, "from": "Docs", "to": [], "text": "MAYDAY disk full", "class": "mayday", "v": 2},
|
||||||
]),
|
]),
|
||||||
})
|
})
|
||||||
|
self.subagents(now)
|
||||||
self.request("feat: orchestrator", "")
|
self.request("feat: orchestrator", "")
|
||||||
self.create_ask(ASK)
|
self.create_ask(ASK)
|
||||||
self.write_tree()
|
self.root = self.write_tree()
|
||||||
self.base = f"http://{self.host}/"
|
self.base = f"http://{self.host}/"
|
||||||
|
|
||||||
def drive(self, steps: list[dict]) -> dict:
|
def subagents(self, now: float) -> None:
|
||||||
|
def line(ts: float) -> dict:
|
||||||
|
return {"type": "user", "sessionId": "s1", "timestamp": iso(ts), "message": {"role": "user", "content": "x"}}
|
||||||
|
|
||||||
|
folder = self.projects / "-repo" / "s1" / "subagents"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(self.projects / "-repo" / "s1.jsonl").write_text(self.jsonl([line(now - 3600), line(now - 30)]), encoding="utf-8")
|
||||||
|
for ident, finished in (("abc", True), ("def", False)):
|
||||||
|
(folder / f"agent-{ident}.meta.json").write_text(json.dumps({"agentType": "Explore", "description": f"look {ident}", "spawnDepth": 1, "finished": finished}), encoding="utf-8")
|
||||||
|
(folder / f"agent-{ident}.jsonl").write_text(self.jsonl([line(now - 1800), line(now - 60)]), encoding="utf-8")
|
||||||
|
|
||||||
|
def drive(self, steps: list[dict], authenticator: dict | None, driver: str = "cdp.mjs", binary: str = os.environ.get("CHROMIUM", "chromium")) -> dict:
|
||||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||||
json.dump({"steps": steps}, handle)
|
json.dump({"steps": steps, "authenticator": authenticator}, handle)
|
||||||
self.addCleanup(os.unlink, handle.name)
|
self.addCleanup(os.unlink, handle.name)
|
||||||
done = subprocess.run(["node", str(HERE / "cdp.mjs"), os.environ.get("CHROMIUM", "chromium"), handle.name], capture_output=True, text=True, timeout=600, check=False)
|
done = subprocess.run(["node", str(HERE / driver), binary, handle.name], capture_output=True, text=True, timeout=900, check=False)
|
||||||
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
|
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
|
||||||
return json.loads(done.stdout)
|
return json.loads(done.stdout)
|
||||||
|
|
||||||
|
def beside(self, work: Callable[[], None]) -> None:
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def loop() -> None:
|
||||||
|
while not stop.wait(0.2):
|
||||||
|
work()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=loop, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
self.addCleanup(thread.join, 10)
|
||||||
|
self.addCleanup(stop.set)
|
||||||
|
|
||||||
|
def shot(self, name: str) -> str:
|
||||||
|
return str(OUT / f"chromium-{name}.png")
|
||||||
|
|
||||||
def test_chromium_walks_every_screen_at_three_widths(self):
|
def test_chromium_walks_every_screen_at_three_widths(self):
|
||||||
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": str(OUT / f"chromium-{width}-{screen.replace('/', '-')}.png")} for width, height in WIDTHS for screen in SCREENS]
|
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": self.shot(f"{width}-{screen.replace('/', '-')}")} for width, height in WIDTHS for screen in SCREENS]
|
||||||
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
|
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
|
||||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": ACTIONS, "shot": None})
|
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": ACTIONS, "shot": None})
|
||||||
data = self.drive(steps)
|
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"}]})
|
||||||
|
data = self.drive(steps, None)
|
||||||
|
(OUT / "chromium-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
self.assertEqual(data["errors"], [])
|
self.assertEqual(data["errors"], [])
|
||||||
probes = data["results"][: len(WIDTHS) * len(SCREENS)]
|
probes = data["results"][: len(WIDTHS) * len(SCREENS)]
|
||||||
for probe, step in zip(probes, steps, strict=False):
|
for probe, step in zip(probes, steps, strict=False):
|
||||||
@@ -124,46 +242,149 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertEqual(probe["header"], ["palette-open", "settings-open"])
|
self.assertEqual(probe["header"], ["palette-open", "settings-open"])
|
||||||
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], ["Inbox", "Regeln"]))
|
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], ["Inbox", "Regeln"]))
|
||||||
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-agents": "1", "tab-artifacts": None})
|
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-agents": "1", "tab-artifacts": None})
|
||||||
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk fullaufheben")
|
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full")
|
||||||
self.assertFalse(probe["rail"])
|
self.assertFalse(probe["rail"])
|
||||||
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
|
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
|
||||||
|
self.assertGreater(probe["icons"], 8)
|
||||||
|
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
||||||
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||||||
by_hash = {probe["hash"]: probe for probe in probes}
|
by_hash = {probe["hash"]: probe for probe in probes}
|
||||||
stream = by_hash["#stream"]
|
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"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
||||||
self.assertEqual((stream["menus"], stream["custom"]), (["Filter ▾"], True))
|
self.assertEqual((stream["menus"], stream["custom"]), (["Filter"], True))
|
||||||
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
||||||
for tab in ("agents", "artifacts"):
|
for tab in ("agents", "artifacts"):
|
||||||
with self.subTest(tab=tab):
|
with self.subTest(tab=tab):
|
||||||
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D"])
|
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D"])
|
||||||
|
self.assertEqual(by_hash[f"#{tab}/3d"]["layouts"], ["Kraft", "Zeit", "Spawn-Tiefe"])
|
||||||
|
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
|
||||||
|
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ Knoten · \d+ Kanten · Kraft$")
|
||||||
agents = by_hash["#agents/orbit"]
|
agents = by_hash["#agents/orbit"]
|
||||||
self.assertEqual(agents["toolbar"], ["Funk an alle ▾", "Mayday aufheben", "Priorität", "Tag/Nacht ▾"])
|
self.assertEqual(agents["toolbar"], ["Funk an alle", "Mayday aufheben", "Priorität", "Tag/Nacht"])
|
||||||
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
||||||
redirects = data["results"][len(probes) : len(probes) + 3]
|
self.assertEqual(by_hash["#agents/tree"]["spawn"][0], ["operator", None])
|
||||||
self.assertEqual([probe["hash"] for probe in redirects], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
|
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
||||||
actions = data["results"][-1]
|
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"):
|
||||||
|
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)
|
||||||
|
extra = data["results"][len(probes) :]
|
||||||
|
self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
|
||||||
|
actions = extra[3]
|
||||||
self.assertNotIn("error", actions)
|
self.assertNotIn("error", actions)
|
||||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#stream"])
|
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#stream"])
|
||||||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
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", "SECURITE"])
|
||||||
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
|
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 ("Funk an alle …", "Mayday aufheben", "Priorität …", "Abend", "Regeln …", "Einstellungen …", "Himmel ansehen", "Leitstand umschalten", "Benachrichtigungen aktivieren"):
|
||||||
with self.subTest(tool=tool):
|
with self.subTest(tool=tool):
|
||||||
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
|
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
|
||||||
self.assertEqual(actions["drawer"], [["post", True], ["call", False]])
|
self.assertEqual(actions["drawer"], [["post", True], ["call", False]])
|
||||||
|
artifact_pick, agent_pick, calm = 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\)$")
|
||||||
|
|
||||||
|
def test_chromium_sends_a_call_from_the_agent_panel_and_shows_its_receipt(self):
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
def answer() -> None:
|
||||||
|
for script, args in self.scripts.calls[len(handled) :]:
|
||||||
|
handled.append(script)
|
||||||
|
if script.endswith("radio.py") and args[:2] == ["send", "call"]:
|
||||||
|
to, text, now = args[args.index("--to") + 1], args[-1], time.time()
|
||||||
|
rows = [{"ts": now, "from": "Operator", "to": [to], "text": text, "class": "call", "v": 2}, {"ts": now + 0.5, "from": to, "to": ["Operator"], "text": text, "class": "receipt", "v": 2, "call": now}]
|
||||||
|
with (self.council / "news.jsonl").open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(self.jsonl(rows))
|
||||||
|
|
||||||
|
self.beside(answer)
|
||||||
|
data = self.drive([{"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": CALL, "shot": self.shot("1366-call-receipt")}], None)
|
||||||
|
self.assertEqual(data["errors"], [])
|
||||||
|
result = data["results"][0]
|
||||||
|
self.assertNotIn("error", result)
|
||||||
|
self.assertIsNotNone(result["receipt"], result)
|
||||||
|
self.assertIn("Gelesen", result["receipt"])
|
||||||
|
|
||||||
|
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
||||||
|
tree = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||||||
|
tree["delete_ready"], tree["delete_reason"] = True, ""
|
||||||
|
self.tree_file.write_text(json.dumps(tree), encoding="utf-8")
|
||||||
|
demo = f"{self.root}/repos/github.com/me/demo"
|
||||||
|
Path(demo).mkdir(parents=True)
|
||||||
|
fake = self.outside / "bin" / "pkgmgr"
|
||||||
|
fake.parent.mkdir()
|
||||||
|
fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
worktree = f"{self.root}/worktrees/github.com/me/demo/feature-done"
|
||||||
|
(fake.parent / "plan.json").write_text(json.dumps([{"branch": "feature/done", "worktree": worktree, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None}]), encoding="utf-8")
|
||||||
|
following = json.loads(json.dumps(tree))
|
||||||
|
following["repos"][0]["items"] = [item for item in following["repos"][0]["items"] if item["branch"] != "feature/done"]
|
||||||
|
(fake.parent / "next-tree.json").write_text(json.dumps(following), encoding="utf-8")
|
||||||
|
signer = self.pin()
|
||||||
|
trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "cleanup.json", origin=self.origin)
|
||||||
|
runner = host.Cleanup(trust, self.tree_file, fake)
|
||||||
|
jobs, results = self.cleanup / "jobs", self.cleanup / "results"
|
||||||
|
self.beside(lambda: any(jobs.iterdir()) and host.cycle(jobs, results, runner))
|
||||||
|
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
|
||||||
|
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
|
||||||
|
local = f"http://localhost:{self.port}/"
|
||||||
|
steps = [
|
||||||
|
{"width": 1366, "height": 768, "url": f"{local}#artifacts/tree", "wait": 1200, "probe": OPEN_DELETE, "shot": self.shot("1366-delete-dry-run")},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": CONFIRM, "shot": self.shot("1366-delete-result")},
|
||||||
|
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": AFTER, "shot": self.shot("1366-delete-tree-after")},
|
||||||
|
]
|
||||||
|
data = self.drive(steps, authenticator)
|
||||||
|
(OUT / "chromium-delete.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
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(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.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")]])
|
||||||
|
calls = [json.loads(line)["argv"] for line in (fake.parent / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
["branch", "cleanup", "--json", "--local", "--branch", "feature/done"],
|
||||||
|
["branch", "cleanup", "--apply", "--json", "--local", "--branch", "feature/done"],
|
||||||
|
["branch", "tree", "--format", "json", "--output", str(self.tree_file)],
|
||||||
|
])
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||||
def test_firefox_renders_every_screen_at_three_widths(self):
|
def test_firefox_walks_every_screen_at_three_widths(self):
|
||||||
profile = Path(tempfile.mkdtemp(prefix="firefox-e2e-"))
|
shots = [(width, height, screen, OUT / f"firefox-{width}-{screen.replace('/', '-')}.png") for width, height in WIDTHS for screen in SCREENS]
|
||||||
self.addCleanup(shutil.rmtree, profile, True)
|
for *_, shot in shots:
|
||||||
for width, height in WIDTHS:
|
|
||||||
for screen in SCREENS:
|
|
||||||
shot = OUT / f"firefox-{width}-{screen.replace('/', '-')}.png"
|
|
||||||
shot.unlink(missing_ok=True)
|
shot.unlink(missing_ok=True)
|
||||||
|
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": str(shot)} for width, height, screen, shot in shots]
|
||||||
|
data = self.drive(steps, None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||||
|
(OUT / "firefox-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
blocked = [error for error in data["errors"] if error.startswith("Content-Security-Policy")]
|
||||||
|
self.assertEqual([error for error in data["errors"] if error not in blocked], [])
|
||||||
|
# 3d-force-graph injects its own <style>, which the unchanged CSP blocks; the page carries those rules itself.
|
||||||
|
self.assertEqual([error for error in blocked if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||||
|
for probe, (width, _, screen, shot) in zip(data["results"], shots, strict=True):
|
||||||
with self.subTest(width=width, screen=screen):
|
with self.subTest(width=width, screen=screen):
|
||||||
command = [os.environ.get("FIREFOX", "firefox"), "--headless", "--no-remote", "--profile", str(profile), "--window-size", f"{width},{height}", "--screenshot", str(shot), f"{self.base}#{screen}"]
|
self.assertNotIn("error", probe)
|
||||||
subprocess.run(command, capture_output=True, timeout=120, check=False)
|
self.assertEqual(probe["hash"], f"#{screen}")
|
||||||
|
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], ["Inbox", "Regeln"]))
|
||||||
|
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
||||||
|
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||||||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||||||
|
if screen.endswith("/3d"):
|
||||||
|
self.assertTrue(probe["canvas"], probe["note"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+2
-1
@@ -42,6 +42,7 @@ class HubCase(unittest.TestCase):
|
|||||||
self.projects = base / "projects"
|
self.projects = base / "projects"
|
||||||
self.tree_file = base / "tree" / "branch-tree.json"
|
self.tree_file = base / "tree" / "branch-tree.json"
|
||||||
self.timers = base / "timers"
|
self.timers = base / "timers"
|
||||||
|
self.cleanup = base / "state" / "cleanup"
|
||||||
self.approvers = base / "config" / "approvers.json"
|
self.approvers = base / "config" / "approvers.json"
|
||||||
self.replay = base / "replay"
|
self.replay = base / "replay"
|
||||||
for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers):
|
for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers):
|
||||||
@@ -63,7 +64,7 @@ class HubCase(unittest.TestCase):
|
|||||||
transcripts=self.projects,
|
transcripts=self.projects,
|
||||||
scripts=self.scripts,
|
scripts=self.scripts,
|
||||||
templates=HUB / "templates",
|
templates=HUB / "templates",
|
||||||
places=server.Places(tree_file=self.tree_file, timers=self.timers),
|
places=server.Places(tree_file=self.tree_file, timers=self.timers, vendor=HUB / "vendor", cleanup=self.cleanup),
|
||||||
model=server.Model(url="", model="", key=""),
|
model=server.Model(url="", model="", key=""),
|
||||||
engines=self.engines,
|
engines=self.engines,
|
||||||
approvers=self.approvers,
|
approvers=self.approvers,
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hub_case import HubCase
|
||||||
|
from hub_support import HUB, PAGE
|
||||||
|
from kvb_orchestrator import artifacts, exchange, icons
|
||||||
|
from kvb_orchestrator.errors import ToolError
|
||||||
|
|
||||||
|
VENDOR = HUB / "vendor"
|
||||||
|
TEMPLATES = HUB / "templates"
|
||||||
|
VENDORED = {
|
||||||
|
"bootstrap-icons/bootstrap-icons.svg": ("fe7b61303ee8b160aa2d596dabbb70f4ffeaf8ea24891004e9b1d0d2877638b1", "Copyright (c) 2019-2024 The Bootstrap Authors"),
|
||||||
|
"3d-force-graph/3d-force-graph.min.js": ("13088340aad408156064fd120c3bc3a47c93a3cc45b8f132d6284b20e2223be9", "Copyright (c) 2017 Vasco Asturiano"),
|
||||||
|
"three/three.module.js": ("9052042d676cb0fdc1ddfefe193053f34b7ac0513a616fdac4535d49987812ea", "Copyright © 2010-2026 three.js authors"),
|
||||||
|
"three/three.core.js": ("9edde002b066a9a05676a6127f67735b62baf399bdea529f2f7e31657da769e6", "Copyright © 2010-2026 three.js authors"),
|
||||||
|
}
|
||||||
|
VERSIONS = {"3d-force-graph/3d-force-graph.min.js": "Version 1.80.1", "three/three.core.js": "REVISION = '186'"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubIcons(HubCase):
|
||||||
|
def test_the_page_inlines_exactly_the_icons_it_uses_from_the_vendored_sprite(self):
|
||||||
|
html = self.index()
|
||||||
|
sprite = re.search(r'<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true" focusable="false">(.*?)</svg>', html, re.DOTALL)
|
||||||
|
self.assertIsNotNone(sprite)
|
||||||
|
inlined = re.findall(r'<symbol id="bi-([a-z0-9-]+)" viewBox="0 0 16 16">', sprite.group(1))
|
||||||
|
used = icons.used(TEMPLATES)
|
||||||
|
self.assertGreater(len(used), 40)
|
||||||
|
self.assertEqual(inlined, used)
|
||||||
|
known = icons.symbols(VENDOR / icons.SPRITE)
|
||||||
|
self.assertLessEqual(set(used), set(known))
|
||||||
|
for name in ("command", "gear"):
|
||||||
|
with self.subTest(header=name):
|
||||||
|
self.assertIn(f'<use href="#bi-{name}"></use>', html)
|
||||||
|
self.assertIsNone(re.search(r'(?:src|href)="(?:https?:)?//', html))
|
||||||
|
self.assertNotIn("cdn.", html)
|
||||||
|
|
||||||
|
def test_an_icon_missing_from_the_sprite_stops_the_start(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
|
(Path(folder) / "page.js").write_text('const x = icon("bi-not-an-icon-ever");', encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ToolError, "not-an-icon-ever"):
|
||||||
|
icons.sheet(VENDOR, Path(folder))
|
||||||
|
|
||||||
|
def test_vendored_files_are_pinned_and_carry_their_mit_licence(self):
|
||||||
|
for name, (digest, holder) in VENDORED.items():
|
||||||
|
with self.subTest(file=name):
|
||||||
|
path = VENDOR / name
|
||||||
|
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest)
|
||||||
|
licence = (path.parent / "LICENSE").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("MIT License", licence)
|
||||||
|
self.assertIn(holder, licence)
|
||||||
|
for name, marker in VERSIONS.items():
|
||||||
|
with self.subTest(version=name):
|
||||||
|
self.assertIn(marker, (VENDOR / name).read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_the_vendor_route_serves_only_its_allowlist_under_the_plain_csp(self):
|
||||||
|
status, headers, body = self.get("/vendor/3d-force-graph.min.js")
|
||||||
|
self.assertEqual((status, headers["content-type"]), (200, "text/javascript; charset=utf-8"))
|
||||||
|
self.assertEqual(body, (VENDOR / "3d-force-graph" / "3d-force-graph.min.js").read_bytes())
|
||||||
|
self.assertEqual(headers["content-security-policy"], exchange.PLAIN_CSP)
|
||||||
|
for path in ("/vendor/bootstrap-icons.svg", "/vendor/LICENSE", "/vendor/..%2Fserver.py", "/vendor/three"):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertEqual(self.get(path)[0], 404)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubArtifacts(HubCase):
|
||||||
|
def test_the_tree_carries_artefact_leaves_overlaps_merges_and_lanes(self):
|
||||||
|
root = self.write_tree()
|
||||||
|
demo = f"{root}/worktrees/github.com/me/demo"
|
||||||
|
feature, done = f"{demo}/feature", f"{demo}/feature-done"
|
||||||
|
register = {
|
||||||
|
"work/done": {"title": "Done", "status": "running", "worker": "Skills session", "workdir": done, "artifacts": [{"ref": "image:demo:feature-done", "added": "2026-11-06T10:00:00+00:00"}, {"ref": "volume:demo_data", "added": "2026-11-06T10:05:00+00:00"}]},
|
||||||
|
"work/loose": {"title": "Loose", "status": "running", "artifacts": [{"ref": "log:/tmp/loose.log", "added": "2026-11-06T11:00:00+00:00"}]},
|
||||||
|
"work/plain": {"title": "Plain", "status": "running"},
|
||||||
|
}
|
||||||
|
history = [{"id": "q1", "kind": "test-nostack", "workdir": done, "start": 1000.0, "end": 1010.0, "exit": 0}, {"id": "q2", "kind": "deploy", "workdir": done, "start": 1100.0, "end": 1110.0, "exit": 0}]
|
||||||
|
self.write_council({"rasci.json": register, "queue-history.jsonl": self.jsonl(history)})
|
||||||
|
touched = {}
|
||||||
|
for path, branch, files in ((feature, "feature", ["a.py", "b.py"]), (done, "feature/done", ["a.py", "c.py"])):
|
||||||
|
result = self.tool("review_request", {**self.arguments(f"feat: {branch}", "", self.page(f"{branch.replace('/', '-')}.html", PAGE)), "worktree": path, "branch": branch})
|
||||||
|
touched[branch] = result["structuredContent"]["id"]
|
||||||
|
self.httpd.hub.reviews.stored(touched[branch], ".json").write_text(json.dumps({"files": [{"path": name} for name in files]}), encoding="utf-8")
|
||||||
|
self.httpd.hub.reviews.decide(touched["feature/done"], {"status": "approved", "proof": None})
|
||||||
|
status, _, body = self.get("/api/tree")
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
data = json.loads(body)
|
||||||
|
repo = next(entry for entry in data["repos"] if entry["name"] == "github.com/me/demo")
|
||||||
|
branches = {entry["name"]: entry for entry in repo["branches"]}
|
||||||
|
leaves = [(leaf["task"], leaf["kind"], leaf["name"]) for leaf in branches["feature/done"]["worktrees"][0]["artefacts"]]
|
||||||
|
self.assertEqual(leaves, [("work/done", "image", "demo:feature-done"), ("work/done", "volume", "demo_data")])
|
||||||
|
self.assertEqual(branches["feature"]["worktrees"][0]["artefacts"], [])
|
||||||
|
self.assertEqual([(task["task"], [leaf["ref"] for leaf in task["artefacts"]]) for task in data["tasks"]], [("work/loose", ["log:/tmp/loose.log"])])
|
||||||
|
self.assertEqual(repo["overlaps"], [{"a": "feature", "b": "feature/done", "count": 1, "files": ["a.py"]}])
|
||||||
|
self.assertEqual(repo["merges"], [{"from": "feature/done", "into": "main", "by": "#12"}, {"from": "feature/kept", "into": "main", "by": "ancestor"}])
|
||||||
|
self.assertEqual((branches["feature"]["files"], branches["feature/done"]["files"]), (2, 2))
|
||||||
|
kinds = [(event["kind"], event["ok"]) for event in branches["feature/done"]["events"]]
|
||||||
|
self.assertEqual(kinds, [("gate", True), ("review", None), ("gate", True), ("commit", True)])
|
||||||
|
self.assertEqual([event["kind"] for event in branches["feature"]["events"]], ["review", "gate"])
|
||||||
|
self.assertEqual(data["cleanups"], [])
|
||||||
|
|
||||||
|
def test_a_lane_counts_push_commands_and_failed_gates(self):
|
||||||
|
path = "/w/demo/feature"
|
||||||
|
history = [{"kind": "gate", "workdir": f"{path}/sub", "start": 10.0, "end": 20.0, "exit": 2}, {"kind": "test", "workdir": "/w/other", "end": 30.0, "exit": 0}]
|
||||||
|
jobs = [
|
||||||
|
{"command": "gipsn", "directory": path, "status": "done", "finished": 40.0},
|
||||||
|
{"command": "make test", "directory": path, "status": "done", "finished": 50.0},
|
||||||
|
{"command": "git push origin feature", "directory": path, "status": "queued", "created_ts": 60.0},
|
||||||
|
{"command": "pushd x", "directory": path, "status": "done", "finished": 70.0},
|
||||||
|
]
|
||||||
|
events = artifacts.lane([path], [], history, jobs)
|
||||||
|
self.assertEqual([(event["ts"], event["kind"], event["ok"]) for event in events], [(20.0, "gate", False), (40.0, "push", True), (60.0, "push", None)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hub_case import HubCase
|
||||||
|
from hub_support import HUB, ORIGIN, key, signing, verifier
|
||||||
|
|
||||||
|
CLEANUP = HUB / "client" / "cleanup.py"
|
||||||
|
CLEANUP_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_cleanup_runner", CLEANUP)
|
||||||
|
host = importlib.util.module_from_spec(CLEANUP_SPEC)
|
||||||
|
CLEANUP_SPEC.loader.exec_module(host)
|
||||||
|
DONE_TIP = "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4"
|
||||||
|
OTHER_TIP = "1111111111111111111111111111111111111111"
|
||||||
|
# Param: format with python=<interpreter>; plan.json beside it is the dry-run plan, next-tree.json the rewritten tree.
|
||||||
|
FAKE_PKGMGR = """#!{python}
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
here = Path(__file__).parent
|
||||||
|
with (here / "calls.jsonl").open("a", encoding="utf-8") as log:
|
||||||
|
log.write(json.dumps({{"argv": sys.argv[1:], "cwd": os.getcwd()}}) + "\\n")
|
||||||
|
if sys.argv[1:3] == ["branch", "tree"]:
|
||||||
|
following = here / "next-tree.json"
|
||||||
|
rewritten = following.read_text(encoding="utf-8") if following.exists() else json.dumps({{"rewritten": True}})
|
||||||
|
Path(sys.argv[sys.argv.index("--output") + 1]).write_text(rewritten, encoding="utf-8")
|
||||||
|
raise SystemExit(0)
|
||||||
|
print(json.dumps({{"items": json.loads((here / "plan.json").read_text(encoding="utf-8"))}}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubCleanup(HubCase):
|
||||||
|
def ready(self, ready: bool) -> dict:
|
||||||
|
root = self.write_tree()
|
||||||
|
data = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||||||
|
data["delete_ready"], data["delete_reason"] = ready, "" if ready else "Secret Service locked"
|
||||||
|
self.tree_file.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
return signing.cleanup_item(f"{root}/repos/github.com/me/demo", "feature/done", f"{root}/worktrees/github.com/me/demo/feature-done", DONE_TIP)
|
||||||
|
|
||||||
|
def branches(self) -> dict:
|
||||||
|
status, _, body = self.get("/api/tree")
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
demo = next(repo for repo in json.loads(body)["repos"] if repo["name"] == "github.com/me/demo")
|
||||||
|
return {entry["name"]: entry for entry in demo["branches"]}
|
||||||
|
|
||||||
|
def sign(self, items: list[dict]) -> tuple[int, bytes]:
|
||||||
|
status, _, body = self.post_json("/api/sign", {"items": items}, self.guard(self.host))
|
||||||
|
return status, body
|
||||||
|
|
||||||
|
def test_the_tree_offers_a_delete_only_for_merged_clean_branches_without_skip_reason(self):
|
||||||
|
item = self.ready(False)
|
||||||
|
branches = self.branches()
|
||||||
|
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",
|
||||||
|
])
|
||||||
|
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})
|
||||||
|
self.assertEqual(branches["feature/kept"]["skip"], "ignored files beyond known caches: notes.txt")
|
||||||
|
|
||||||
|
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"))
|
||||||
|
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 /"}])):
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertEqual(self.sign(items)[0], 400)
|
||||||
|
status, body = self.sign([item])
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(item)])
|
||||||
|
|
||||||
|
def test_a_signed_delete_becomes_a_job_without_any_command(self):
|
||||||
|
item = self.ready(True)
|
||||||
|
signer = self.pin()
|
||||||
|
proof = signer.proof(self.prepare([item])["payload"], self.origin)
|
||||||
|
self.assertEqual(self.post_json("/api/cleanup", {"items": [item], "proof": None}, self.guard(self.host))[0], 400)
|
||||||
|
self.assertEqual(self.post_json("/api/cleanup", {"items": [item], "proof": proof}, {"Host": self.host})[0], 403)
|
||||||
|
status, _, body = self.post_json("/api/cleanup", {"items": [item], "proof": proof}, self.guard(self.host))
|
||||||
|
self.assertEqual(status, 202, body)
|
||||||
|
job_id = json.loads(body)["id"]
|
||||||
|
written = self.cleanup / "jobs" / f"{job_id}.json"
|
||||||
|
job = json.loads(written.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(sorted(job), ["created", "id", "items", "proof", "v"])
|
||||||
|
self.assertEqual(job["items"], [{key_name: item[key_name] for key_name in ("repository", "branch", "worktree", "tip")}])
|
||||||
|
self.assertEqual(job["proof"], proof)
|
||||||
|
self.assertNotIn("command", written.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((written.stat().st_mode & 0o777, (self.cleanup / "jobs").stat().st_mode & 0o777), (0o600, 0o700))
|
||||||
|
self.assertEqual(json.loads(self.get(f"/api/cleanup/{job_id}")[2])["status"], "queued")
|
||||||
|
self.assertEqual(self.get("/api/cleanup/0000000000000000")[0], 404)
|
||||||
|
result = {"id": job_id, "finished": time.time(), "items": [{**job["items"][0], "status": "done", "reason": ""}]}
|
||||||
|
(self.cleanup / "results" / f"{job_id}.json").write_text(json.dumps(result), encoding="utf-8")
|
||||||
|
self.assertEqual(json.loads(self.get(f"/api/cleanup/{job_id}")[2])["status"], "done")
|
||||||
|
self.assertEqual(json.loads(self.get("/api/tree")[2])["cleanups"], [result])
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostCleanup(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(tmp.cleanup)
|
||||||
|
base = Path(tmp.name)
|
||||||
|
self.repo = base / "repos" / "demo"
|
||||||
|
self.repo.mkdir(parents=True)
|
||||||
|
self.jobs, self.results = base / "jobs", base / "results"
|
||||||
|
self.jobs.mkdir()
|
||||||
|
self.results.mkdir()
|
||||||
|
self.fake = base / "bin" / "pkgmgr"
|
||||||
|
self.fake.parent.mkdir()
|
||||||
|
self.fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8")
|
||||||
|
self.fake.chmod(0o755)
|
||||||
|
self.calls = self.fake.parent / "calls.jsonl"
|
||||||
|
self.tree = base / "tree" / "branch-tree.json"
|
||||||
|
self.tree.parent.mkdir()
|
||||||
|
self.tree.write_text(json.dumps({"repos": [{"path": str(self.repo), "known": True}, {"path": str(base / "stranger"), "known": False}]}), encoding="utf-8")
|
||||||
|
self.approvers = base / "config" / "approvers.json"
|
||||||
|
self.trust = verifier.Trust(approvers=self.approvers, replay=base / "replay" / "cleanup.json", origin=ORIGIN)
|
||||||
|
self.signer = key(-7, "main")
|
||||||
|
self.signer.pin(self.approvers, ORIGIN)
|
||||||
|
self.item = {"repository": str(self.repo), "branch": "feature/done", "worktree": f"{self.repo}/.worktrees/feature-done", "tip": DONE_TIP}
|
||||||
|
|
||||||
|
def proof(self, items: list[dict], signer) -> dict:
|
||||||
|
hashes = [signing.item_hash(signing.cleanup_item(**item)) for item in items]
|
||||||
|
return signer.proof(signing.approval_payload(hashes, secrets.token_hex(16), int(time.time()) + 600), ORIGIN)
|
||||||
|
|
||||||
|
def entry(self, **changes: object) -> dict:
|
||||||
|
return {"branch": self.item["branch"], "worktree": self.item["worktree"], "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None, **changes}
|
||||||
|
|
||||||
|
def run_job(self, items: list[dict], proof: object, plan: list[dict]) -> tuple[dict, list[dict]]:
|
||||||
|
(self.fake.parent / "plan.json").write_text(json.dumps(plan), encoding="utf-8")
|
||||||
|
job_id = secrets.token_hex(8)
|
||||||
|
(self.jobs / f"{job_id}.json").write_text(json.dumps({"v": 1, "id": job_id, "created": "x", "items": items, "proof": proof}), encoding="utf-8")
|
||||||
|
self.assertEqual(host.cycle(self.jobs, self.results, host.Cleanup(self.trust, self.tree, self.fake)), 1)
|
||||||
|
self.assertEqual(list(self.jobs.iterdir()), [])
|
||||||
|
calls = [json.loads(line) for line in self.calls.read_text(encoding="utf-8").splitlines()] if self.calls.exists() else []
|
||||||
|
self.calls.unlink(missing_ok=True)
|
||||||
|
return json.loads((self.results / f"{job_id}.json").read_text(encoding="utf-8")), calls
|
||||||
|
|
||||||
|
def test_a_signed_merged_clean_item_is_dry_run_then_deleted_by_exact_name(self):
|
||||||
|
result, calls = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()])
|
||||||
|
self.assertEqual([(entry["status"], entry["reason"]) for entry in result["items"]], [("done", "")])
|
||||||
|
selector = ["--json", "--local", "--branch", "feature/done"]
|
||||||
|
self.assertEqual(calls, [
|
||||||
|
{"argv": ["branch", "cleanup", *selector], "cwd": str(self.repo)},
|
||||||
|
{"argv": ["branch", "cleanup", "--apply", *selector], "cwd": str(self.repo)},
|
||||||
|
{"argv": ["branch", "tree", "--format", "json", "--output", str(self.tree)], "cwd": str(self.tree.parent)},
|
||||||
|
])
|
||||||
|
self.assertEqual(json.loads(self.tree.read_text(encoding="utf-8")), {"rewritten": True})
|
||||||
|
self.assertEqual(result["tree"], {"code": 0, "error": ""})
|
||||||
|
|
||||||
|
def test_every_doubtful_item_is_refused_and_changes_nothing(self):
|
||||||
|
pattern = {**self.item, "branch": "feature/*"}
|
||||||
|
option = {**self.item, "branch": "--all"}
|
||||||
|
stranger = {**self.item, "repository": str(self.repo.parent.parent / "stranger")}
|
||||||
|
rule = self.signer.proof(signing.rule_payload(secrets.token_hex(8), ["review"], "", "*", "*", int(time.time()) + 600), ORIGIN)
|
||||||
|
cases = (
|
||||||
|
("bad signature", [self.item], self.proof([self.item], key(-7, "intruder")), [self.entry()], "no valid operator signature", 0),
|
||||||
|
("missing signature", [self.item], None, [self.entry()], "no signature", 0),
|
||||||
|
("rule instead of approval", [self.item], rule, [self.entry()], "no valid operator signature", 0),
|
||||||
|
("other item signed", [self.item], self.proof([{**self.item, "tip": OTHER_TIP}], self.signer), [self.entry()], "does not list this item", 0),
|
||||||
|
("unknown repository", [stranger], self.proof([stranger], self.signer), [self.entry()], "unknown repository", 0),
|
||||||
|
("pattern characters", [pattern], self.proof([pattern], self.signer), [self.entry(branch="feature/*")], "pattern or option characters", 0),
|
||||||
|
("option name", [option], self.proof([option], self.signer), [self.entry(branch="--all")], "pattern or option characters", 0),
|
||||||
|
("changed tip", [self.item], self.proof([self.item], self.signer), [self.entry(local_sha=OTHER_TIP)], "tip changed", 1),
|
||||||
|
("not merged-clean", [self.item], self.proof([self.item], self.signer), [self.entry(state="dirty")], "state is dirty", 1),
|
||||||
|
("skip reason", [self.item], self.proof([self.item], self.signer), [self.entry(skip="ignored files")], "gc skip reason", 1),
|
||||||
|
("other worktree", [self.item], self.proof([self.item], self.signer), [self.entry(worktree="/elsewhere")], "worktree changed", 1),
|
||||||
|
("pattern match in pkgmgr", [self.item], self.proof([self.item], self.signer), [self.entry(), self.entry(branch="feature/done-too")], "other branches too", 1),
|
||||||
|
)
|
||||||
|
for name, items, proof, plan, reason, dry_runs in cases:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
result, calls = self.run_job(items, proof, plan)
|
||||||
|
self.assertEqual([entry["status"] for entry in result["items"]], ["refused"])
|
||||||
|
self.assertIn(reason, result["items"][0]["reason"])
|
||||||
|
self.assertEqual([call["argv"] for call in calls], [["branch", "cleanup", "--json", "--local", "--branch", items[0]["branch"]]] * dry_runs)
|
||||||
|
self.assertNotIn("tree", result)
|
||||||
|
|
||||||
|
def test_a_signature_deletes_once_and_a_malformed_job_nothing(self):
|
||||||
|
proof = self.proof([self.item], self.signer)
|
||||||
|
self.assertEqual(self.run_job([self.item], proof, [self.entry()])[0]["items"][0]["status"], "done")
|
||||||
|
again, calls = self.run_job([self.item], proof, [self.entry()])
|
||||||
|
self.assertEqual((again["items"][0]["status"], calls), ("refused", []))
|
||||||
|
self.assertIn("already used", again["items"][0]["reason"])
|
||||||
|
for name, items in (("command smuggled in", [{**self.item, "command": "rm -rf ~"}]), ("relative repository", [{**self.item, "repository": "demo"}]), ("no items", [])):
|
||||||
|
with self.subTest(name=name):
|
||||||
|
result, calls = self.run_job(items, proof, [self.entry()])
|
||||||
|
self.assertEqual((result["items"], calls), ([], []))
|
||||||
|
self.assertIn("malformed job", result["error"])
|
||||||
|
|
||||||
|
def test_the_unit_runs_the_script_once_over_the_job_directory(self):
|
||||||
|
(self.fake.parent / "plan.json").write_text(json.dumps([self.entry()]), encoding="utf-8")
|
||||||
|
job_id = secrets.token_hex(8)
|
||||||
|
(self.jobs / f"{job_id}.json").write_text(json.dumps({"v": 1, "id": job_id, "created": "x", "items": [self.item], "proof": self.proof([self.item], self.signer)}), encoding="utf-8")
|
||||||
|
(self.jobs / "stray.tmp").write_text("x", encoding="utf-8")
|
||||||
|
trust = ["--approvers", str(self.approvers), "--replay", str(self.trust.replay)]
|
||||||
|
command = [sys.executable, str(CLEANUP), "--port", ORIGIN.rsplit(":", 1)[1], "--jobs", str(self.jobs), "--results", str(self.results), *trust, "--tree", str(self.tree), "--pkgmgr", str(self.fake)]
|
||||||
|
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
|
||||||
|
self.assertEqual(done.returncode, 0, done.stderr)
|
||||||
|
self.assertEqual(list(self.jobs.iterdir()), [])
|
||||||
|
result = json.loads((self.results / f"{job_id}.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(result["items"][0]["status"], "done")
|
||||||
|
self.assertEqual((self.results / f"{job_id}.json").stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostCleanupGitConfig(unittest.TestCase):
|
||||||
|
def test_pkgmgr_runs_no_hook_the_repository_config_names(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
base = Path(tmp)
|
||||||
|
repo, hooks, marker = base / "repo", base / "hooks", base / "hook-ran"
|
||||||
|
hooks.mkdir()
|
||||||
|
(hooks / "reference-transaction").write_text(f"#!/bin/sh\necho path >> {marker}\n", encoding="utf-8")
|
||||||
|
(hooks / "reference-transaction").chmod(0o755)
|
||||||
|
git = ["git", "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"]
|
||||||
|
subprocess.run([*git, "init", "-q", str(repo)], check=True)
|
||||||
|
subprocess.run([*git, "commit", "-q", "--allow-empty", "-m", "x"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "branch", "control"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "branch", "doomed"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "config", "core.hooksPath", str(hooks)], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "config", "hook.spy.event", "reference-transaction"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "config", "hook.spy.command", f"echo config >> {marker}"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "branch", "-D", "control"], cwd=repo, check=True, capture_output=True)
|
||||||
|
self.assertEqual({line.split()[0] for line in marker.read_text(encoding="utf-8").splitlines()}, {"path", "config"})
|
||||||
|
marker.unlink()
|
||||||
|
fake = base / "pkgmgr"
|
||||||
|
fake.write_text(f"#!{sys.executable}\nimport subprocess\nsubprocess.run(['git', 'branch', '-D', '--', 'doomed'], check=True)\n", encoding="utf-8")
|
||||||
|
fake.chmod(0o755)
|
||||||
|
code, _, error = host.run(fake, ["branch", "cleanup", "--apply"], str(repo))
|
||||||
|
self.assertEqual(code, 0, error)
|
||||||
|
self.assertFalse(marker.exists())
|
||||||
|
branches = subprocess.run(["git", "branch", "--format=%(refname:short)"], cwd=repo, capture_output=True, text=True, check=True).stdout.split()
|
||||||
|
self.assertNotIn("doomed", branches)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -175,7 +175,8 @@ class TestHubSpeech(HubCase):
|
|||||||
args = {
|
args = {
|
||||||
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
|
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
|
||||||
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"),
|
"--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"),
|
||||||
"--tree-file": str(self.tree_file), "--timers": str(self.timers), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
|
"--tree-file": str(self.tree_file), "--timers": str(self.timers), "--vendor": str(HUB / "vendor"), "--cleanup": str(self.cleanup),
|
||||||
|
"--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "",
|
||||||
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
|
"--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper),
|
||||||
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
|
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,7 @@ class TestHubSpeech(HubCase):
|
|||||||
argv = [part for pair in given.items() for part in pair]
|
argv = [part for pair in given.items() for part in pair]
|
||||||
return subprocess.run([sys.executable, str(HUB / "server.py"), *argv], capture_output=True, text=True, timeout=60, check=False)
|
return subprocess.run([sys.executable, str(HUB / "server.py"), *argv], capture_output=True, text=True, timeout=60, check=False)
|
||||||
|
|
||||||
for name in ("--piper", "--voices", "--operator-voice", "--whisper", "--whisper-model", "--scratch", "--approvers"):
|
for name in ("--piper", "--voices", "--operator-voice", "--whisper", "--whisper-model", "--scratch", "--approvers", "--vendor", "--cleanup"):
|
||||||
with self.subTest(name=name):
|
with self.subTest(name=name):
|
||||||
done = run({key: value for key, value in args.items() if key != name})
|
done = run({key: value for key, value in args.items() if key != name})
|
||||||
self.assertEqual(done.returncode, 2, done.stderr)
|
self.assertEqual(done.returncode, 2, done.stderr)
|
||||||
|
|||||||
@@ -83,6 +83,26 @@ class TestPageMatchesScript(unittest.TestCase):
|
|||||||
found = re.findall(r'^\s*"?(\w+)"?:', block, re.MULTILINE)
|
found = re.findall(r'^\s*"?(\w+)"?:', block, re.MULTILINE)
|
||||||
self.assertEqual(sorted(found), sorted(module.CHOICES[choices]))
|
self.assertEqual(sorted(found), sorted(module.CHOICES[choices]))
|
||||||
|
|
||||||
|
def test_buttons_show_vendored_icons_with_their_former_text(self):
|
||||||
|
page = PAGE.read_text(encoding="utf-8")
|
||||||
|
sprite = (REPO_ROOT / "orchestrator" / "vendor" / "bootstrap-icons" / "bootstrap-icons.svg").read_text(encoding="utf-8")
|
||||||
|
vendored = dict(re.findall(r'<symbol class="bi bi-[a-z0-9-]+" viewBox="0 0 16 16" id="([a-z0-9-]+)">(.*?)</symbol>', sprite, re.DOTALL))
|
||||||
|
embedded = dict(re.findall(r'<symbol id="bi-([a-z0-9-]+)" viewBox="0 0 16 16">(.*?)</symbol>', page, re.DOTALL))
|
||||||
|
used = re.findall(r'<use href="#bi-([a-z0-9-]+)">', page)
|
||||||
|
self.assertTrue(used)
|
||||||
|
self.assertEqual(sorted(embedded), sorted(set(used)))
|
||||||
|
for name in used:
|
||||||
|
with self.subTest(icon=name):
|
||||||
|
self.assertEqual(embedded[name], vendored[name])
|
||||||
|
buttons = re.findall(r"<button\b([^>]*)>(.*?)</button>", page, re.DOTALL)
|
||||||
|
self.assertEqual([re.sub(r"<[^>]+>", "", inner).strip() for _, inner in buttons], [""])
|
||||||
|
for attributes, inner in buttons:
|
||||||
|
self.assertIn('<use href="#bi-play-fill">', inner)
|
||||||
|
self.assertIn('title="Start"', attributes)
|
||||||
|
self.assertIn('aria-label="Start"', attributes)
|
||||||
|
self.assertNotIn("☀", page)
|
||||||
|
self.assertIsNone(re.search(r'(?:src|href)="(?:https?:)?//', page))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user