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
+82829
-451
No files matched your search
@@ -38,5 +38,6 @@ COPY server.py /app/server.py
|
||||
COPY kvb_orchestrator /app/kvb_orchestrator
|
||||
COPY client/signing.py /app/client/signing.py
|
||||
COPY templates /app/templates
|
||||
COPY vendor /app/vendor
|
||||
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ services:
|
||||
- /tree/branch-tree.json
|
||||
- --timers
|
||||
- /timers
|
||||
- --vendor
|
||||
- /app/vendor
|
||||
- --cleanup
|
||||
- /state/cleanup
|
||||
- --llm-url
|
||||
- "${KVB_ORCHESTRATOR_LLM_URL}"
|
||||
- --llm-model
|
||||
|
||||
@@ -4,6 +4,7 @@ import secrets
|
||||
import time
|
||||
|
||||
from .asking import ask_state
|
||||
from .cleanup import cleanup_request
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import decode, expect, field
|
||||
from .records import digest, shown
|
||||
@@ -142,7 +143,9 @@ class Approving:
|
||||
def sign(self, body: bytes) -> dict:
|
||||
payload = decode(body)
|
||||
expect(payload, ("items",))
|
||||
hashes = [signing.item_hash(item["signed"]) for item in self.checked(payload["items"]) if item["signed"] is not None]
|
||||
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()]
|
||||
if not hashes:
|
||||
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})")
|
||||
SIGN_ROUTE = "/api/sign"
|
||||
RULE_SIGN_ROUTE = "/api/rules/sign"
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE)
|
||||
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):
|
||||
@@ -52,10 +57,16 @@ class Handler(Exchange):
|
||||
item = ITEM.fullmatch(path)
|
||||
entry = ENTRY.fullmatch(path)
|
||||
proof = PROOF_ROUTE.fullmatch(path)
|
||||
vendored = VENDOR_ROUTE.fullmatch(path)
|
||||
cleanup = CLEANUP_STATE.fullmatch(path)
|
||||
if self.host() is None:
|
||||
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
|
||||
elif path == "/":
|
||||
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:
|
||||
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
|
||||
elif path == "/api/state":
|
||||
@@ -112,6 +123,8 @@ class Handler(Exchange):
|
||||
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
|
||||
if path == RULE_SIGN_ROUTE:
|
||||
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:
|
||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||
if undo:
|
||||
@@ -188,6 +201,10 @@ class Handler(Exchange):
|
||||
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
|
||||
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:
|
||||
suffix, content_type, csp = STORED[name]
|
||||
reviews = self.server.hub.reviews
|
||||
|
||||
@@ -5,12 +5,16 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
from markupsafe import Markup
|
||||
|
||||
from .approving import Approving
|
||||
from .artifacts import Touched
|
||||
from .asking import Asking
|
||||
from .batches import Pending
|
||||
from .cleanup import Cleaning, Spool
|
||||
from .engines import Engines, Speech
|
||||
from .events import Log, Watch
|
||||
from .icons import sheet
|
||||
from .inbox import Inbox
|
||||
from .jobs import Jobs
|
||||
from .language import Language, Model
|
||||
@@ -34,6 +38,8 @@ from .view import View
|
||||
class Places:
|
||||
tree_file: Path
|
||||
timers: Path
|
||||
vendor: Path
|
||||
cleanup: Path
|
||||
|
||||
|
||||
def environment(templates: Path) -> jinja2.Environment:
|
||||
@@ -49,7 +55,7 @@ def environment(templates: Path) -> jinja2.Environment:
|
||||
|
||||
|
||||
@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
|
||||
pages: Path
|
||||
public_port: int
|
||||
@@ -75,6 +81,10 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
|
||||
settings: Settings
|
||||
speech: Speech
|
||||
approvers: Path
|
||||
vendor: Path
|
||||
icons: Markup
|
||||
spool: Spool
|
||||
touched: Touched
|
||||
|
||||
def hosts(self) -> tuple[str, str]:
|
||||
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:
|
||||
uploads = state / "uploads"
|
||||
uploads.mkdir(exist_ok=True)
|
||||
reviews = Reviews(state)
|
||||
return Hub(
|
||||
vendor=places.vendor,
|
||||
icons=sheet(places.vendor, templates),
|
||||
spool=Spool(places.cleanup).ready(),
|
||||
touched=Touched(reviews.stored),
|
||||
approvers=approvers,
|
||||
speech=Speech(engines, state / "tts"),
|
||||
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,
|
||||
pages=pages,
|
||||
public_port=public_port,
|
||||
reviews=Reviews(state),
|
||||
reviews=reviews,
|
||||
asks=Ledger(state / "asks.json"),
|
||||
decisions=Ledger(state / "decisions.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
|
||||
|
||||
from .artifacts import enrich
|
||||
from .council import HUMAN, agent, read_claims
|
||||
from .crons import cron_state, queue_rows, timer_rows
|
||||
from .errors import ToolError
|
||||
from .fields import single
|
||||
from .lifetimes import edge_times, lifetime, session_gone, subagent_rows
|
||||
from .names import PSEUDO, SESSION_ID, Directory, title_sightings
|
||||
from .records import epoch, iso_epoch
|
||||
from .records import epoch, iso_epoch, read_jsonl
|
||||
from .register import REGISTER, read_register
|
||||
from .tree import assign
|
||||
|
||||
@@ -45,7 +46,10 @@ class Sources:
|
||||
|
||||
def repositories(self) -> dict:
|
||||
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]:
|
||||
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 .records import iso_epoch
|
||||
from .register import ENDED, status_of
|
||||
from .signed import signing
|
||||
|
||||
LABEL_PARTS = 3
|
||||
# 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()), "")
|
||||
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 {
|
||||
"name": item["branch"],
|
||||
"head": item["local_sha"] or item["head"] or remote,
|
||||
"head": head,
|
||||
"upstream": item["upstream"] or "",
|
||||
"ahead": item["ahead"] or 0,
|
||||
"behind": item["behind"] or 0,
|
||||
"gone": item["gone"],
|
||||
"state": item["state"],
|
||||
"merged_by": item["merged_by"],
|
||||
"merged_into": item["merged_into"],
|
||||
"skip": item["skip"],
|
||||
"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 [],
|
||||
}
|
||||
|
||||
@@ -53,11 +82,15 @@ def repo(entry: dict) -> dict:
|
||||
return {
|
||||
**{key: value for key, value in entry.items() if key != "items"},
|
||||
"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"]],
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
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('/')}/")
|
||||
|
||||
|
||||
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:
|
||||
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
|
||||
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"]:
|
||||
workers = set()
|
||||
for task, row in current.items():
|
||||
workdir, name = row.get("workdir"), row.get("branch")
|
||||
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
|
||||
by_branch = not isinstance(workdir, str) and name == item["name"]
|
||||
if by_dir or by_branch:
|
||||
if owns(row, tree["path"], item["name"]):
|
||||
workers |= holders[task]
|
||||
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
|
||||
branches.append({**item, "worktrees": trees})
|
||||
|
||||
@@ -132,5 +132,5 @@ class View:
|
||||
def render(self, nonce: str) -> tuple[str, str]:
|
||||
now = time.time()
|
||||
state = self.state(now - RADIO_WINDOW, now, now, 0)
|
||||
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state)
|
||||
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"]]))
|
||||
@@ -22,6 +22,11 @@
|
||||
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
|
||||
read-only; read again whenever it changes.
|
||||
--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
|
||||
natural-language command line 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)
|
||||
for name in ("--listen-port", "--public-port"):
|
||||
parser.add_argument(name, type=int, required=True)
|
||||
paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--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"):
|
||||
parser.add_argument(name, type=Path, required=True)
|
||||
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
||||
@@ -77,7 +82,7 @@ def main() -> int:
|
||||
transcripts=args.transcripts,
|
||||
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
||||
templates=args.templates,
|
||||
places=Places(tree_file=args.tree_file, timers=args.timers),
|
||||
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)),
|
||||
engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch),
|
||||
approvers=args.approvers,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Agent Orchestrator 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 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; }
|
||||
.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); }
|
||||
.tabs button:hover { background: var(--hover); color: var(--text); }
|
||||
.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 .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 .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); }
|
||||
.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; }
|
||||
@@ -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[hidden] { display: none; }
|
||||
.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>
|
||||
</head>
|
||||
<body>
|
||||
{{ icons }}
|
||||
<canvas class="sky" id="sky" aria-hidden="true"></canvas>
|
||||
<div class="head" id="head">
|
||||
<header class="top">
|
||||
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
|
||||
<div class="tools">
|
||||
<button type="button" class="btn small ghost" id="palette-open" title="Befehle (Strg+K)" aria-label="Befehle">⌘K</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="palette-open" title="Befehle (Strg+K)" aria-label="Befehle"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
|
||||
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Einstellungen und Ambiente" aria-label="Einstellungen"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="navbar">
|
||||
@@ -438,10 +483,16 @@ body.sky-look .sky:active { cursor: grabbing; }
|
||||
|
||||
{% 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-cleanup.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
{% include "page-pulses.js" %}
|
||||
|
||||
@@ -3,6 +3,10 @@ const Nav = (() => {
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const 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 known = (view) => VIEWS.some(([id]) => id === view);
|
||||
|
||||
@@ -30,7 +34,7 @@ const Nav = (() => {
|
||||
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) {
|
||||
module.exports = Nav;
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
ui.agent = key;
|
||||
const text = h("textarea", {"aria-label": `Nachricht an ${name}`, placeholder: `Nachricht an ${name} …`});
|
||||
const file = h("input", {type: "file", class: "field", "aria-label": "Datei anhängen"});
|
||||
const button = h("button", {type: "submit", class: "btn primary"}, "Senden");
|
||||
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Senden")}, icon("bi-send"));
|
||||
const classes = choice("to-one", TO_ONE, "post");
|
||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post kommt am Ende des Zuges an, Call sofort mit Lesebestätigung; prüfbar mit operator_message."),
|
||||
@@ -118,7 +118,7 @@
|
||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||
drawer.replaceChildren(
|
||||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · früher ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeAgent}}, "×")),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", closeAgent)),
|
||||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagenten"), subs, h("h3", {}, "Verlauf"), log),
|
||||
form);
|
||||
drawer.hidden = false;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
}
|
||||
const permission = "Notification" in window ? Notification.permission : "unsupported";
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
};
|
||||
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."),
|
||||
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() {
|
||||
@@ -59,12 +59,12 @@
|
||||
}
|
||||
|
||||
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 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("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() {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
window.addEventListener("hashchange", apply);
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("resize", redrawSky);
|
||||
window.addEventListener("resize", requestSpace);
|
||||
window.addEventListener("resize", refresh3d);
|
||||
skyPanel();
|
||||
radio.element = h("audio", {preload: "none"});
|
||||
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 NONCE = document.currentScript ? document.currentScript.nonce : "";
|
||||
const ACTIVE_BEAT = 900;
|
||||
const DAY = 86400;
|
||||
const CHAIR = "Maintainer";
|
||||
const HUMAN = "Operator";
|
||||
@@ -7,6 +9,7 @@
|
||||
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 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 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)?"]];
|
||||
@@ -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 WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
const COMPASS = [["N", "Norden"], ["NO", "Nordosten"], ["O", "Osten"], ["SO", "Südosten"], ["S", "Süden"], ["SW", "Südwesten"], ["W", "Westen"], ["NW", "Nordwesten"]];
|
||||
const AXES = [["time", "Zeit", "Tiefe = Zeit im gewählten Zeitraum: jeder Agent eine Weltlinie"], ["spawn", "Spawn-Tiefe", "Tiefe = Spawn-Tiefe: Subagenten hängen als Baum unter ihrer Session"]];
|
||||
const SUBS_TTL = 60000;
|
||||
const TURN = 10;
|
||||
const NUDGE = 40;
|
||||
const ZOOM = 1.2;
|
||||
const GLIDE = 320;
|
||||
const PLANE = 450;
|
||||
const SAVE_DELAY = 700;
|
||||
const TRACK_RETRY = 5000;
|
||||
const CALM = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
|
||||
const TENSE = [1, 16 / 15, 6 / 5, 45 / 32, 3 / 2, 8 / 5];
|
||||
const BASE = 110;
|
||||
@@ -62,7 +61,7 @@
|
||||
direct: [[740, 0, 0.2]],
|
||||
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 relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
|
||||
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 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 targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
|
||||
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));
|
||||
}
|
||||
|
||||
function relabel(button, name, label, hint) {
|
||||
button.replaceChildren(icon(name));
|
||||
button.setAttribute("aria-label", label);
|
||||
button.title = hint || label;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
button.textContent = "Kopiert";
|
||||
relabel(button, "bi-clipboard-check", "Kopiert");
|
||||
} catch {
|
||||
button.textContent = "Nicht möglich";
|
||||
relabel(button, "bi-x-circle", "Nicht möglich");
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
button.textContent = "Kopieren";
|
||||
}, 1600);
|
||||
window.setTimeout(() => relabel(button, "bi-clipboard", "Kopieren"), 1600);
|
||||
});
|
||||
return button;
|
||||
}
|
||||
@@ -118,7 +126,7 @@
|
||||
function openSheet(title, body, wide) {
|
||||
const sheet = document.getElementById("sheet");
|
||||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), 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)));
|
||||
sheet.hidden = false;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,9 @@
|
||||
return;
|
||||
}
|
||||
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: {
|
||||
click: () => useSaved(view),
|
||||
keydown: (event) => {
|
||||
@@ -111,11 +113,11 @@
|
||||
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();
|
||||
dropView(view.name);
|
||||
}}}, "×")));
|
||||
stream.dom.saved.replaceChildren(...mine, btn("+ Ansicht speichern", "btn small ghost", saveView));
|
||||
}}}, icon("bi-x-lg"))));
|
||||
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: () => {
|
||||
customize();
|
||||
if (filter.kinds.has(id) && filter.kinds.size > 1) {
|
||||
@@ -141,11 +143,11 @@
|
||||
}}}, label)) : [h("span", {class: "muted"}, "Erst mit der Art Transkripte.")];
|
||||
stream.dom.parts.replaceChildren(...parts);
|
||||
const focus = filter.sub || filter.session || filter.agent ? h("span", {class: "chip", "aria-pressed": "true"}, filter.sub ? `Subagent ${filter.sub.slice(0, 12)}` : filter.session ? `Session ${filter.session.slice(0, 8)}` : `Agent ${filter.agent}`,
|
||||
h("button", {type: "button", class: "x", "aria-label": "Filter entfernen", on: {click: () => {
|
||||
h("button", {type: "button", class: "x", ...named("Filter entfernen"), on: {click: () => {
|
||||
filter.sub = "";
|
||||
filter.session = "";
|
||||
filter.agent = "";
|
||||
refilter();
|
||||
}}}, "×")) : null;
|
||||
}}}, icon("bi-x-lg"))) : null;
|
||||
stream.dom.focus.replaceChildren(...(focus ? [focus] : []));
|
||||
}
|
||||
@@ -157,28 +157,20 @@
|
||||
}
|
||||
|
||||
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)),
|
||||
axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"),
|
||||
h("span", {class: "muted"}, words[axis]));
|
||||
h("span", {class: "muted"}, "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit"));
|
||||
}
|
||||
|
||||
function side() {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
||||
if (!node) {
|
||||
const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten";
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)];
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Klicke auf einen Knoten: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.")];
|
||||
}
|
||||
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
|
||||
return [
|
||||
h("div", {class: "panel-head"},
|
||||
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
|
||||
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")),
|
||||
iconBtn("bi-x-lg", "Schließen", "btn small ghost", () => select(node.id))),
|
||||
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
|
||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||
@@ -188,22 +180,17 @@
|
||||
}
|
||||
|
||||
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() {
|
||||
const view = ui.views.agents;
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const toolbar = agentsToolbar();
|
||||
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
|
||||
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
|
||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : 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]}`));
|
||||
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
||||
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
||||
}
|
||||
@@ -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) {
|
||||
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));
|
||||
let timer = 0;
|
||||
const count = () => {
|
||||
@@ -117,11 +117,11 @@
|
||||
}
|
||||
const buttons = [];
|
||||
if (item.kind === "review" || item.kind === "ask") {
|
||||
buttons.push(btn("Freigeben", "btn small primary", () => approveOne(item)), btn("Ablehnen", "btn small danger", () => rejectOne(item)));
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
@@ -153,10 +153,10 @@
|
||||
actionsFor(item),
|
||||
item.kind === "operator" ? copyButton(item.command) : null,
|
||||
h("span", {class: "spacer"}),
|
||||
btn("1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned)),
|
||||
btn("morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned)),
|
||||
item.until ? btn("wecken", "btn small ghost", () => mark(item, 0, item.pinned)) : null,
|
||||
btn(item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
iconBtn("bi-hourglass-split", "1 h", "btn small ghost", () => mark(item, nowSeconds() + 3600, item.pinned), "1 h: eine Stunde zurückstellen"),
|
||||
iconBtn("bi-sunrise", "morgen", "btn small ghost", () => mark(item, tomorrow(), item.pinned), "morgen: bis morgen 8 Uhr zurückstellen"),
|
||||
item.until ? iconBtn("bi-alarm", "wecken", "btn small ghost", () => mark(item, 0, item.pinned), "wecken: wieder zeigen") : null,
|
||||
iconBtn(item.pinned ? "bi-pin-angle-fill" : "bi-pin-angle", item.pinned ? "lösen" : "anheften", "btn small ghost", () => mark(item, item.until, !item.pinned))));
|
||||
}
|
||||
|
||||
function inboxScreen() {
|
||||
@@ -188,8 +188,8 @@
|
||||
ui.snoozed = event.target.checked;
|
||||
drawInbox();
|
||||
}}});
|
||||
const choose = h("button", {type: "button", class: "btn small soft", disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, `Auswahl freigeben (${chosen.length})`);
|
||||
const all = h("button", {type: "button", class: "btn small primary", disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, `Alle grünen freigeben (${green.length})`);
|
||||
const choose = h("button", {type: "button", class: "btn small soft", ...named(`Auswahl freigeben (${chosen.length})`), disabled: !chosen.length || ui.busy, on: {click: () => bulk(chosen, "Auswahl freigeben")}}, icon("bi-check2-square"), String(chosen.length));
|
||||
const all = h("button", {type: "button", class: "btn small primary", ...named(`Alle grünen freigeben (${green.length})`), disabled: !green.length || ui.busy, on: {click: () => bulk(green, "Alle grünen freigeben")}}, icon("bi-check2-all"), String(green.length));
|
||||
inboxDom.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Inbox", counter(waitingCount())), h("span", {class: "spacer"}),
|
||||
hidden ? h("label", {class: "check"}, snoozed, `${hidden} zurückgestellt`) : null),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
async function load() {
|
||||
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) {
|
||||
throw new Error(String(response.status));
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
return;
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
meta([["Repo", review.worktree], ["Branch", review.branch], ["Tree", (review.tree || "").slice(0, 12), review.tree], ["Score", review.score]]),
|
||||
review.notes ? h("pre", {class: "notes"}, review.notes) : null,
|
||||
h("div", {class: "actions"},
|
||||
h("a", {class: "btn ghost", href: `/reviews/${encodeURIComponent(review.id)}/page`, target: "_blank", rel: "noopener noreferrer"}, "Vollständige Seite öffnen ↗"),
|
||||
h("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"}),
|
||||
item ? actionsFor(item).map((node) => {
|
||||
node.addEventListener("click", closeSheet);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
function tabButton(id, label, hint) {
|
||||
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});
|
||||
return button;
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
function signature() {
|
||||
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 : []);
|
||||
}
|
||||
@@ -70,9 +70,13 @@
|
||||
main.replaceChildren(screenOf());
|
||||
drawn.screen = ui.screen;
|
||||
drawn.sig = sig;
|
||||
if (g3.host && !g3.host.isConnected) {
|
||||
dispose3d();
|
||||
}
|
||||
}
|
||||
stage();
|
||||
drawSide();
|
||||
drawSpawn();
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawStatus();
|
||||
@@ -89,7 +93,7 @@
|
||||
const banner = document.getElementById("mayday");
|
||||
const silence = state.silence;
|
||||
banner.hidden = !silence;
|
||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), 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`;
|
||||
window.setTimeout(measure, 0);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
if (space.edits === space.saved) {
|
||||
space.views = {graph: {...next.space.graph}, sky: {...next.space.sky}};
|
||||
space.glides = {graph: null, sky: null};
|
||||
requestSpace();
|
||||
refresh3d();
|
||||
}
|
||||
sky.mode = "";
|
||||
drawSky();
|
||||
@@ -56,15 +56,15 @@
|
||||
}
|
||||
|
||||
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"},
|
||||
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();
|
||||
skyLook(true);
|
||||
}),
|
||||
tool("music-toggle", "♪ Musik", "Generative Musik an oder aus", toggleMusic),
|
||||
tool("leitstand", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
||||
tool("notify", "Benachrichtigungen", "Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
||||
tool("music-toggle", "bi-music-note-beamed", "Musik", "Musik: generative Musik an oder aus", toggleMusic),
|
||||
tool("leitstand", "bi-display", "Leitstand", "Leitstand: Vollbild mit großer Schrift", toggleLeitstand),
|
||||
tool("notify", "bi-bell", "Benachrichtigungen", "Benachrichtigungen: Desktop-Benachrichtigungen des Browsers erlauben", askNotify)));
|
||||
window.setTimeout(() => {
|
||||
musicButton();
|
||||
notifyLabel();
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
root.style.setProperty("--card", `${glass ? Ambience.CARD * 100 : 100}%`);
|
||||
root.style.colorScheme = mode;
|
||||
root.dataset.mode = mode;
|
||||
requestSpace();
|
||||
refresh3d();
|
||||
}
|
||||
|
||||
function drawMoon(paint, at, radius, moon) {
|
||||
@@ -191,16 +191,16 @@
|
||||
|
||||
function skyPanel() {
|
||||
const panel = document.getElementById("sky-panel");
|
||||
const step = (text, label, run) => h("button", {type: "button", class: "btn small", title: label, "aria-label": label, on: {click: run}}, text);
|
||||
const step = (name, label, run) => iconBtn(name, label, "btn small", run);
|
||||
space.compass = h("span", {class: "compass", "aria-live": "polite"});
|
||||
panel.append(space.compass,
|
||||
step("◀", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("▶", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
||||
step("▲", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
||||
step("▼", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
||||
step("−", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("+", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("⟲ Zurücksetzen", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||
step("Fertig", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||
step("bi-caret-left-fill", "Blick nach links drehen (Pfeil links)", () => skyHooks.turnStep(-1, 0)),
|
||||
step("bi-caret-right-fill", "Blick nach rechts drehen (Pfeil rechts)", () => skyHooks.turnStep(1, 0)),
|
||||
step("bi-caret-up-fill", "Blick heben (Pfeil hoch)", () => skyHooks.turnStep(0, -1)),
|
||||
step("bi-caret-down-fill", "Blick senken (Pfeil runter)", () => skyHooks.turnStep(0, 1)),
|
||||
step("bi-zoom-out", "Sichtfeld weiten (Minus)", () => skyHooks.zoomStep(1 / ZOOM)),
|
||||
step("bi-zoom-in", "Hineinzoomen (Plus)", () => skyHooks.zoomStep(ZOOM)),
|
||||
step("bi-arrow-counterclockwise", "Blick zurücksetzen: Süden, Horizont, Sichtfeld 180° (Taste 0)", () => resetView("sky")),
|
||||
step("bi-check-lg", "Himmel verlassen (Esc)", () => skyLook(false)));
|
||||
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() {
|
||||
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
||||
return;
|
||||
}
|
||||
graphDom.pulses = null;
|
||||
if (graph3d()) {
|
||||
stageSpace();
|
||||
if (ui.views.agents === "3d") {
|
||||
stage3d("agents", graphDom.stage);
|
||||
return;
|
||||
}
|
||||
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
||||
@@ -59,9 +39,7 @@
|
||||
space.edits += 1;
|
||||
window.clearTimeout(space.timer);
|
||||
space.timer = window.setTimeout(persistViews, SAVE_DELAY);
|
||||
if (kind === "graph") {
|
||||
requestSpace();
|
||||
} else {
|
||||
if (kind === "sky") {
|
||||
redrawSky();
|
||||
}
|
||||
}
|
||||
@@ -168,16 +146,6 @@
|
||||
element.addEventListener("keydown", (event) => steerKey(event, kind, hooks));
|
||||
}
|
||||
|
||||
const graphHooks = {
|
||||
rotate: (dx, dy) => nudge("graph", (view) => ({...view, yaw: view.yaw - dx * 0.4, pitch: view.pitch + dy * 0.4}), false),
|
||||
pan: (dx, dy) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + dx / (space.cam ? space.cam.scale : 1), pan_y: view.pan_y + dy / (space.cam ? space.cam.scale : 1)}), false),
|
||||
zoom: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), false),
|
||||
turnStep: (x, y) => nudge("graph", (view) => ({...view, yaw: view.yaw - x * TURN, pitch: view.pitch + y * TURN}), true),
|
||||
panStep: (x, y) => nudge("graph", (view) => ({...view, pan_x: view.pan_x + x * NUDGE, pan_y: view.pan_y + y * NUDGE}), true),
|
||||
zoomStep: (factor) => nudge("graph", (view) => ({...view, zoom: view.zoom * factor}), true),
|
||||
pick: (x, y) => pickSpace(x, y),
|
||||
};
|
||||
|
||||
const skyLens = () => Space.lens(seenView("sky"), window.innerWidth, window.innerHeight);
|
||||
|
||||
const skyHooks = {
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
? "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}` : "";
|
||||
if (reason) {
|
||||
button.disabled = true;
|
||||
button.title = reason;
|
||||
button.title = `Spracheingabe: ${reason}`;
|
||||
return button;
|
||||
}
|
||||
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 filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||
const tree = {data: null, busy: false, q: "", open: new Set(), dom: null};
|
||||
const 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 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};
|
||||
@@ -43,9 +43,7 @@
|
||||
const speech = {pending: [], busy: false, warned: 0};
|
||||
const mic = {recorder: null, held: false, busy: false, timer: 0};
|
||||
const space = {
|
||||
views: {graph: {...state.settings.space.graph}, sky: {...state.settings.space.sky}}, glides: {graph: null, sky: null}, edits: 0, saved: 0, timer: 0,
|
||||
scene: null, sceneCursor: null, cam: null, frame: 0, look: false, compass: null, subs: new Map(), loading: false, version: 0,
|
||||
cursor: {from: 0, to: null, start: 0}, style: null, styleKey: "", trackAsked: 0,
|
||||
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,
|
||||
};
|
||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||
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];
|
||||
}
|
||||
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") {
|
||||
const ref = entry.ref;
|
||||
@@ -174,7 +174,7 @@
|
||||
const saved = h("div", {class: "chips", role: "group", "aria-label": "Gespeicherte Ansichten"});
|
||||
const focus = h("div", {class: "chips"});
|
||||
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()));
|
||||
stream.dom = {search, count, categories, kinds, parts, saved, focus, list};
|
||||
window.setTimeout(() => {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
const presets = h("div", {class: "segmented", role: "group", "aria-label": "Zeitraum"});
|
||||
const summary = h("summary", {title: "Zeitraum und Wiedergabe"});
|
||||
const slider = h("input", {type: "range", min: 0, max: 1000, step: 1, value: 1000, "aria-label": "Stand der Wiedergabe"});
|
||||
const play = 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 label = h("span", {class: "window-label"});
|
||||
for (const [seconds, name] of PRESETS.filter(([seconds]) => seconds !== -1)) {
|
||||
@@ -102,7 +102,7 @@
|
||||
const resume = ui.asOf !== null && ui.asOf < state.now - 60;
|
||||
let progress = resume ? (ui.asOf - began) / span : 0;
|
||||
ui.playing = true;
|
||||
timeDom.play.textContent = "❚❚ Pause";
|
||||
relabel(timeDom.play, "bi-pause-fill", "Pause");
|
||||
const advance = async () => {
|
||||
if (!ui.playing) {
|
||||
return;
|
||||
@@ -127,6 +127,6 @@
|
||||
ui.playing = false;
|
||||
window.clearTimeout(replay);
|
||||
if (timeDom) {
|
||||
timeDom.play.textContent = "▶ Wiedergabe";
|
||||
relabel(timeDom.play, "bi-play-fill", "Wiedergabe");
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,84 @@
|
||||
function treeScreen() {
|
||||
const search = h("input", {type: "search", placeholder: "Repo, Branch, Pfad oder Agent filtern …", value: tree.q, "aria-label": "Tree filtern", on: {input: (event) => {
|
||||
tree.q = event.target.value;
|
||||
drawTree();
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const list = h("div", {class: "repos"});
|
||||
tree.dom = {list, count};
|
||||
window.setTimeout(() => {
|
||||
drawTree();
|
||||
fetchTree();
|
||||
}, 0);
|
||||
return h("div", {}, h("div", {class: "filters"}, search, count, btn("Neu laden", "btn small soft", fetchTree)), list);
|
||||
const chip = (name, label, tone, count) => h("span", {class: "state", "data-tone": tone, role: "img", ...named(label)}, icon(name), count === undefined ? null : String(count));
|
||||
|
||||
function branchStates(entry) {
|
||||
return [
|
||||
entry.state === "merged-clean" || entry.merged_by ? chip("bi-sign-merge-left", `gemergt${entry.merged_by ? ` durch ${entry.merged_by}` : ""}${entry.merged_into ? ` in ${entry.merged_into}` : ""}`, "ok") : null,
|
||||
entry.state === "unmerged" ? chip("bi-signpost-split", "nicht gemergt", "neutral") : null,
|
||||
entry.state === "protected" ? chip("bi-shield-lock", "geschützt", "violet") : null,
|
||||
entry.state === "stale" ? chip("bi-hourglass-bottom", "veraltet", "warn") : null,
|
||||
entry.ahead && entry.upstream ? chip("bi-cloud-upload", `${entry.ahead} Commits ungepusht`, "info", entry.ahead) : null,
|
||||
entry.behind ? chip("bi-arrow-down-circle", `${entry.behind} Commits zurück`, "warn", entry.behind) : null,
|
||||
entry.gone ? chip("bi-cloud-slash", "Upstream weg", "bad") : null,
|
||||
!entry.upstream && !entry.gone ? chip("bi-cloud-slash", "ohne Upstream", "neutral") : null,
|
||||
entry.skip ? chip("bi-slash-circle", `gc überspringt: ${entry.skip}`, "bad") : null,
|
||||
];
|
||||
}
|
||||
|
||||
async function fetchTree() {
|
||||
if (tree.busy) {
|
||||
return;
|
||||
function worktreeStates(wt) {
|
||||
const counts = [wt.staged, wt.unstaged, wt.untracked];
|
||||
const changed = counts.every((value) => typeof value === "number") ? counts.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
return [
|
||||
wt.state === "clean" ? chip("bi-check-circle", "sauber", "ok") : null,
|
||||
wt.state === "changed" ? chip("bi-pencil-square", changed === undefined ? "nicht committete Änderungen" : `nicht committet: ${wt.staged} gestaged, ${wt.unstaged} geändert, ${wt.untracked} neu`, "warn", changed) : null,
|
||||
wt.state === "missing" ? chip("bi-question-circle", "Worktree fehlt", "neutral") : null,
|
||||
wt.locked ? chip("bi-lock-fill", `gesperrt: ${wt.locked}`, "warn") : null,
|
||||
];
|
||||
}
|
||||
|
||||
function deleteButton(entry) {
|
||||
if (!entry.cleanup) {
|
||||
return null;
|
||||
}
|
||||
tree.busy = true;
|
||||
try {
|
||||
const response = await fetch("/api/tree", {cache: "no-store"});
|
||||
if (response.ok) {
|
||||
tree.data = await response.json();
|
||||
}
|
||||
} catch {
|
||||
ui.offline = true;
|
||||
}
|
||||
tree.busy = false;
|
||||
drawTree();
|
||||
const ready = tree.data.delete_ready === true;
|
||||
const hint = ready ? `Löschen: ${entry.name} lokal entfernen, nach Trockenlauf und Signatur` : `Löschen gesperrt: ${tree.data.delete_reason || "delete_ready ist false"}`;
|
||||
const button = iconBtn("bi-trash3", "Löschen", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
|
||||
button.disabled = !ready;
|
||||
return h("span", {class: "hint", title: hint}, button);
|
||||
}
|
||||
|
||||
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) {
|
||||
const review = wt.review;
|
||||
return h("div", {class: "wt"},
|
||||
h("code", {}, wt.path),
|
||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||
wt.dirty === null ? pill("fehlt", "neutral") : wt.dirty ? pill("geändert", "warn") : pill("sauber", "ok"),
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
copyButton(wt.path));
|
||||
h("div", {class: "wt-head"},
|
||||
h("span", {class: "states"}, worktreeStates(wt)),
|
||||
h("code", {}, wt.path),
|
||||
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
|
||||
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
|
||||
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
|
||||
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() {
|
||||
@@ -47,9 +87,11 @@
|
||||
}
|
||||
if (!tree.data || tree.data.error) {
|
||||
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;
|
||||
}
|
||||
tree.dom.removeAll.replaceChildren(deleteAll());
|
||||
const q = tree.q.trim().toLowerCase();
|
||||
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
|
||||
const repos = [];
|
||||
@@ -60,21 +102,22 @@
|
||||
repos.push({repo, branches, detached});
|
||||
}
|
||||
}
|
||||
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
if (!repos.length) {
|
||||
const tasks = (tree.data.tasks || []).filter((task) => hit(task.task, task.title, ...task.artefacts.map((found) => found.ref)));
|
||||
const listed = new Set(tree.data.repos.flatMap((repo) => repo.branches.map((entry) => `${repo.path}\u0000${entry.name}`)));
|
||||
const gone = (tree.data.cleanups || []).flatMap((result) => result.items).filter((item) => !listed.has(`${item.repository}\u0000${item.branch}`) && hit(item.repository, item.branch));
|
||||
tree.dom.count.textContent = `${repos.length} Repositories · Stand ${clock.format(stampOf(tree.data.scanned))}${tree.data.delete_ready ? "" : ` · Löschen gesperrt: ${tree.data.delete_reason}`}`;
|
||||
if (!repos.length && !tasks.length && !gone.length) {
|
||||
tree.dom.list.replaceChildren(emptyState("Nichts gefunden", "Kein Repository passt zum Filter."));
|
||||
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 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)},
|
||||
h("summary", {}, h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), dirty ? pill(`${dirty} geändert`, "warn") : null),
|
||||
branches.map((entry) => h("div", {class: "branch-row"},
|
||||
h("div", {class: "branch-name"}, h("strong", {}, entry.name), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : h("span", {class: "muted"}, "ohne Upstream"),
|
||||
entry.ahead ? pill(`${entry.ahead} voraus`, "info") : null, entry.behind ? pill(`${entry.behind} zurück`, "warn") : null, entry.gone ? pill("Upstream weg", "bad") : null,
|
||||
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10))),
|
||||
entry.worktrees.map(worktreeRow))),
|
||||
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
|
||||
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null),
|
||||
branches.map((entry) => branchRow(repo, entry)),
|
||||
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
@@ -84,5 +127,11 @@
|
||||
}
|
||||
});
|
||||
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
Reference in new issue
Block a user