hypgraph 0.7.5__tar.gz → 0.7.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hypgraph-0.7.5 → hypgraph-0.7.7}/CHANGELOG.md +27 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/PKG-INFO +1 -1
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/__init__.py +1 -1
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/cli.py +3 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/server.py +29 -5
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/hypgraph.css +41 -5
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/hypgraph.js +73 -11
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/index.html +1 -1
- hypgraph-0.7.7/tests/test_server_hosts.py +65 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/.gitignore +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/AGENTS.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/CITATION.cff +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/LICENSE +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/NOTICE +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/README.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/PLAN.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/decisions/ADR-002-governance-profiles.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/profile-workflows-plan.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/review-2026-10-24.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/site-plan.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/spec.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/ui-plan.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/workflows/README.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/workflows/knowledge.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/workflows/optimization.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/workflows/scientific.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/workflows/software.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/docs/workflows/trading.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/pyproject.toml +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/__main__.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/bundle.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/checks.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/coord.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/demos/__init__.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/demos/broad_street.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/derive.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/graph.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/language.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/mcp.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/plan.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/policy.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/profiles.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/refusals.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/report_check.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/schema.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/skill/SKILL.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/stats.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/store.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/__init__.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/atlas.css +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/atlas.js +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/hypgraph.svg +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/vendor/cose-base.js +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/ui/vendor/layout-base.js +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/src/hypgraph/workflow.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_accept_work_kind.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_bound_support.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_bundle_telemetry.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_cancel_releases_lease.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_cited_and_failed_support.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_coord.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_d10.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_engine.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_engine_fixes.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_incremental.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_link_translate.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_log_and_check.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_measure_questions.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_opposed_answers.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_option_lead.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_order_detail.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_profiles.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_profiles_041.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_questions_not_assertions.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_refusal_friction.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_refusal_results.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_refusals.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_relation_words.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_report_answers.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_rerunnable.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_restates_scale.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_server_auth.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_shape_refusals.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_stats_not_backtests.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_stats_values.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_surfaces.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_telemetry_stream.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_ui_app.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_ui_engine.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_uncorrected_effect.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_unnamed_inputs.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_versions_not_figures.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_vocabulary_friction.py +0 -0
- {hypgraph-0.7.5 → hypgraph-0.7.7}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.7.7 — 2026-10-29: `hypgraph serve` inside a container
|
|
4
|
+
|
|
5
|
+
- **`hypgraph serve` bound to `0.0.0.0` answered 421 to everyone.** Its Host check knew only the bind address,
|
|
6
|
+
which no browser sends: inside a container with a published port, every request arrives as
|
|
7
|
+
`Host: 127.0.0.1:<host port>` and was refused as a possible DNS rebinding. A wildcard bind now accepts the
|
|
8
|
+
loopback names (the port was never part of the check), and `--allow-host NAME` (repeatable; `allow_hosts=` on
|
|
9
|
+
`make_server` / `serve`) adds a LAN or service name. A wildcard bind still never means "any Host".
|
|
2
10
|
|
|
11
|
+
## 0.7.6 — 2026-10-29: the swarm as swimlanes, and the UI walked on a real record
|
|
12
|
+
|
|
13
|
+
- **UI polish from a walk of a real swarm record.** Identical alerts collapse into one line with a count (a seat
|
|
14
|
+
that looped four times showed four lines); the six KPI tiles fit one row where the sixth sat orphaned below;
|
|
15
|
+
the grids break on the panel's own width, not the window's (container queries: an embed is narrower than its
|
|
16
|
+
page, and the Live view squeezed its graph to 130 px between two full columns); the theme follows the OS unless
|
|
17
|
+
the visitor chose one; a refusal relayed twice ("claim refused: claim
|
|
18
|
+
refused:") reads once in the event stream; `mount(…, {embed: true})` marks an app on a page of its own: it
|
|
19
|
+
grows to its content and scrolls nothing inside, so a wheel over it moves the page (twenty ticks over the
|
|
20
|
+
homepage's embed used to move nothing), and its feed shows the latest 15 events. hypgraph.com: the hero's chips no
|
|
21
|
+
longer wrap mid-phrase.
|
|
22
|
+
|
|
23
|
+
- **The Live view shows the swarm as swimlanes.** Seats as rows in the order they first took work, time across,
|
|
24
|
+
every work order a bar coloured by how the record settled it (accepted, reworked, returned, cancelled, in
|
|
25
|
+
flight), every model call a tick above the bar and every tool call a tick below, refusals and failures in
|
|
26
|
+
red, the scheduler's reworks and escalations as marks on their own row, and the playhead sweeping across
|
|
27
|
+
during replay on a fixed axis. Hover anything for its figures; click an order for its trace. Drawn from the
|
|
28
|
+
telemetry the view already holds, no dependency.
|
|
29
|
+
|
|
3
30
|
## 0.7.5 — 2026-10-28: a cited source is a line of support, and a finding of failure is not a fall
|
|
4
31
|
|
|
5
32
|
- **A cited source is a line of support, and a succeeded run's "failed" outcome is a finding, not a fall.**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hypgraph
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.7
|
|
4
4
|
Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
|
|
5
5
|
Project-URL: Homepage, https://hypgraph.com
|
|
6
6
|
Project-URL: Documentation, https://hypgraph.com/engineers/
|
|
@@ -16,7 +16,7 @@ Standing is derived on every read, never stored; every write is an event in a SH
|
|
|
16
16
|
their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
-
__version__ = "0.7.
|
|
19
|
+
__version__ = "0.7.7"
|
|
20
20
|
|
|
21
21
|
from .bundle import verify_bundle # noqa: E402
|
|
22
22
|
from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
|
|
@@ -425,6 +425,7 @@ def cmd_serve(args) -> int:
|
|
|
425
425
|
if tokens and not args.writable:
|
|
426
426
|
print("note: --token only matters with --writable (reads are open)")
|
|
427
427
|
serve(args.db, host=args.host, port=args.port, writable=args.writable, open_browser=not args.no_browser,
|
|
428
|
+
allow_hosts=tuple(args.allow_host or ()),
|
|
428
429
|
tokens=tokens or None, telemetry=(args.telemetry or "auto") if not args.no_telemetry else None)
|
|
429
430
|
return 0
|
|
430
431
|
|
|
@@ -537,6 +538,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
537
538
|
s = cmd("serve", cmd_serve, "the HypGraph UI and JSON API on localhost")
|
|
538
539
|
s.add_argument("--host", default="127.0.0.1")
|
|
539
540
|
s.add_argument("--port", type=int, default=8765)
|
|
541
|
+
s.add_argument("--allow-host", action="append", metavar="NAME",
|
|
542
|
+
help="a Host header name to answer to besides loopback (a LAN or service name); repeatable")
|
|
540
543
|
s.add_argument("--writable", action="store_true", help="also accept writes (POST /api/graph/write)")
|
|
541
544
|
s.add_argument("--no-browser", action="store_true")
|
|
542
545
|
s.add_argument("--telemetry", default="", metavar="FILE",
|
|
@@ -236,6 +236,28 @@ def read_telemetry(path: Path | None, after: int = 0, inv: str = "", limit: int
|
|
|
236
236
|
return out, after + end + 1
|
|
237
237
|
|
|
238
238
|
|
|
239
|
+
LOOPBACK_NAMES = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
240
|
+
WILDCARD_BINDS = frozenset({"", "0.0.0.0", "::", "*"})
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def accepted_hosts(host: str, allow_hosts: tuple[str, ...] | list[str] = ()) -> set[str]:
|
|
244
|
+
"""The names a request's Host header may carry (port ignored); anything else is 421.
|
|
245
|
+
|
|
246
|
+
Bound to a loopback name, the server answers to every loopback name. Bound to a wildcard (``0.0.0.0``,
|
|
247
|
+
``::``) — as inside a container whose port is published — it answers to the loopback names too, since the
|
|
248
|
+
browser reaches the published port as ``127.0.0.1:<host port>``; the bind address itself is never a name a
|
|
249
|
+
browser sends. ``allow_hosts`` adds names: a LAN hostname, a service name. The check is DNS-rebinding
|
|
250
|
+
defence (a page on another origin can't make the browser talk to this server by name), so a wildcard bind
|
|
251
|
+
never means "any Host"."""
|
|
252
|
+
names = {h.strip().lower() for h in allow_hosts if h and h.strip()}
|
|
253
|
+
h = (host or "").strip().lower()
|
|
254
|
+
if h in LOOPBACK_NAMES or h in WILDCARD_BINDS:
|
|
255
|
+
names |= LOOPBACK_NAMES
|
|
256
|
+
if h and h not in WILDCARD_BINDS:
|
|
257
|
+
names.add(h)
|
|
258
|
+
return names
|
|
259
|
+
|
|
260
|
+
|
|
239
261
|
def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path,
|
|
240
262
|
tokens: dict[str, frozenset] | None = None, telemetry: Path | None = None):
|
|
241
263
|
class Handler(BaseHTTPRequestHandler):
|
|
@@ -389,7 +411,8 @@ def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path,
|
|
|
389
411
|
|
|
390
412
|
|
|
391
413
|
def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
|
|
392
|
-
tokens: dict[str, frozenset] | None = None, telemetry: str | Path | None = "auto"
|
|
414
|
+
tokens: dict[str, frozenset] | None = None, telemetry: str | Path | None = "auto",
|
|
415
|
+
allow_hosts: tuple[str, ...] | list[str] = ()):
|
|
393
416
|
"""``telemetry``: the swarm's sidecar to serve and stream — "auto" is ``<db>.telemetry.jsonl`` beside the record
|
|
394
417
|
(it may appear later: a run started after the server is picked up), a path, or None for none."""
|
|
395
418
|
g = graph if isinstance(graph, Graph) else Graph(graph)
|
|
@@ -399,19 +422,20 @@ def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *
|
|
|
399
422
|
tel = Path(g.path + ".telemetry.jsonl")
|
|
400
423
|
elif telemetry:
|
|
401
424
|
tel = Path(telemetry)
|
|
402
|
-
hosts =
|
|
403
|
-
{host.lower()}
|
|
425
|
+
hosts = accepted_hosts(host, allow_hosts)
|
|
404
426
|
root = static_dir().resolve()
|
|
405
427
|
return ThreadingHTTPServer((host, port), make_handler(g, writable=writable, hosts=hosts, root=root, tokens=tokens,
|
|
406
428
|
telemetry=tel))
|
|
407
429
|
|
|
408
430
|
|
|
409
431
|
def serve(db: str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
|
|
410
|
-
open_browser: bool = True, tokens: dict[str, frozenset] | None = None, telemetry: str | None = "auto"
|
|
432
|
+
open_browser: bool = True, tokens: dict[str, frozenset] | None = None, telemetry: str | None = "auto",
|
|
433
|
+
allow_hosts: tuple[str, ...] | list[str] = ()) -> None:
|
|
411
434
|
if not Path(db).exists():
|
|
412
435
|
print(f"{db} doesn't exist yet — `hypgraph demo` builds one to explore, `hypgraph init` starts your own.")
|
|
413
436
|
return
|
|
414
|
-
srv = make_server(db, host, port, writable=writable, tokens=tokens, telemetry=telemetry
|
|
437
|
+
srv = make_server(db, host, port, writable=writable, tokens=tokens, telemetry=telemetry,
|
|
438
|
+
allow_hosts=allow_hosts)
|
|
415
439
|
url = f"http://{host}:{srv.server_address[1]}/"
|
|
416
440
|
auth = f", {len(tokens)} write token(s)" if tokens else (", writes unauthenticated" if writable else "")
|
|
417
441
|
tel = telemetry if telemetry not in ("auto", None) else (db + ".telemetry.jsonl" if telemetry == "auto" else "")
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
.hg .inv { display: grid; grid-template-columns: 8px 1fr; gap: 9px; align-items: start; }
|
|
65
65
|
.hg .inv .dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; }
|
|
66
66
|
.hg .inv .t { font-weight: 550; color: var(--ink); line-height: 1.3; } .hg .inv .s { font-size: 11.5px; color: var(--ink3); }
|
|
67
|
-
.hg .hg-main { overflow: auto; padding: 22px 26px 40px; min-height: 0; }
|
|
67
|
+
.hg .hg-main { overflow: auto; padding: 22px 26px 40px; min-height: 0; container: hg-main / inline-size; }
|
|
68
68
|
.hg .head { display: flex; align-items: flex-end; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
|
|
69
69
|
.hg .head h1 { margin: 0; font-size: 20px; letter-spacing: -.015em; }
|
|
70
70
|
.hg .head p { margin: 4px 0 0; color: var(--ink2); max-width: 820px; }
|
|
@@ -84,13 +84,21 @@
|
|
|
84
84
|
.hg .card > h3 .r { margin-left: auto; display: flex; gap: 6px; }
|
|
85
85
|
.hg .card .body { padding: 12px 14px; }
|
|
86
86
|
.hg .grid { display: grid; gap: 14px; }
|
|
87
|
-
.hg .kpis { grid-template-columns: repeat(
|
|
87
|
+
.hg .kpis { grid-template-columns: repeat(6, minmax(0, 1fr)); } /* 6, 3 or 2 to a row: never an orphan */
|
|
88
|
+
@container hg-main (max-width: 900px) { .hg .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
|
89
|
+
@container hg-main (max-width: 460px) { .hg .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
90
|
+
/* embedded on a page of its own: the window grows to its content and scrolls nothing inside, so a wheel over it
|
|
91
|
+
moves the page; the feed shows the latest events instead of scrolling */
|
|
92
|
+
.hg.hg-embed .hg-app { height: auto; grid-template-rows: 52px auto; }
|
|
93
|
+
.hg.hg-embed .hg-main, .hg.hg-embed .hg-nav { overflow: visible; }
|
|
94
|
+
.hg.hg-embed .feed { max-height: none; overflow: visible; }
|
|
95
|
+
.hg.hg-embed { overflow: visible; }
|
|
88
96
|
.hg .kpi { padding: 12px 14px; } .hg .kpi .k { color: var(--ink2); font-size: 12px; }
|
|
89
97
|
.hg .kpi .v { font-size: 22px; font-weight: 650; letter-spacing: -.02em; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
|
90
98
|
.hg .kpi .s { color: var(--ink3); font-size: 11.5px; margin-top: 2px; }
|
|
91
99
|
.hg .kpi.warn .v { color: var(--warn); } .hg .kpi.bad .v { color: var(--bad); } .hg .kpi.ok .v { color: var(--ok); }
|
|
92
100
|
.hg .two { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }
|
|
93
|
-
@
|
|
101
|
+
@container hg-main (max-width: 860px) { .hg .two { grid-template-columns: 1fr; } }
|
|
94
102
|
.hg table { width: 100%; border-collapse: collapse; }
|
|
95
103
|
.hg th, .hg td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
96
104
|
.hg th { font-size: 11.5px; font-weight: 600; color: var(--ink3); text-transform: uppercase; letter-spacing: .04em;
|
|
@@ -146,7 +154,7 @@
|
|
|
146
154
|
@keyframes hg-pulse { 0% { box-shadow: 0 0 0 0 rgba(18, 183, 106, .55); } 70% { box-shadow: 0 0 0 9px rgba(18, 183, 106, 0); } 100% { box-shadow: 0 0 0 0 rgba(18, 183, 106, 0); } }
|
|
147
155
|
.hg .scrub { flex: 1; min-width: 160px; accent-color: var(--accent); }
|
|
148
156
|
.hg .live { grid-template-columns: minmax(240px, 300px) minmax(0, 1fr) minmax(280px, 380px); }
|
|
149
|
-
@
|
|
157
|
+
@container hg-main (max-width: 1000px) { .hg .live { grid-template-columns: 1fr; } } /* the panel's width, not the window's: an embed is narrower than its page */
|
|
150
158
|
.hg .seat { display: grid; grid-template-columns: 30px 1fr auto; gap: 10px; align-items: center; padding: 9px 14px; border-bottom: 1px solid var(--line); }
|
|
151
159
|
.hg .seat .av { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; font-weight: 700; font-size: 12px;
|
|
152
160
|
background: var(--sunken); color: var(--ink2); }
|
|
@@ -160,6 +168,34 @@
|
|
|
160
168
|
.hg .tel-bad { color: var(--bad); font-weight: 600; } .hg .tel-warn { color: var(--warn); font-weight: 600; }
|
|
161
169
|
.hg .fly { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-right: 6px;
|
|
162
170
|
vertical-align: 0; animation: hg-pulse 1.2s infinite; }
|
|
171
|
+
/* the timeline: seats as rows, orders as bars, calls as ticks above (model) and below (tool) the bar */
|
|
172
|
+
.hg .lanes { display: grid; gap: 3px; }
|
|
173
|
+
.hg .lane-row { display: grid; grid-template-columns: 132px minmax(0, 1fr); gap: 10px; align-items: center; }
|
|
174
|
+
.hg .lane-lbl { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; font-size: 12px; }
|
|
175
|
+
.hg .lane-lbl b { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .hg .lane-lbl .dim { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
176
|
+
.hg .lane { position: relative; height: 28px; background: var(--sunken); border-radius: 4px; }
|
|
177
|
+
.hg .lane .ord { position: absolute; top: 8px; height: 12px; border: 0; border-radius: 3px; padding: 0 4px; margin: 0; font: 600 10px/12px var(--mono);
|
|
178
|
+
color: #fff; text-align: left; overflow: hidden; white-space: nowrap; cursor: pointer; opacity: .92; box-sizing: border-box; }
|
|
179
|
+
.hg .lane .ord:hover, .hg .lane .ord:focus-visible { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; z-index: 3; }
|
|
180
|
+
.hg .ord.ok { background: var(--ok); } .hg .ord.warn { background: var(--warn); } .hg .ord.info { background: var(--info); }
|
|
181
|
+
.hg .ord.off { background: var(--ink3); } .hg .ord.done { background: var(--accent-ink); } .hg .ord.open { background: var(--accent); animation: hg-blink 1.2s ease-in-out infinite; }
|
|
182
|
+
.hg .lane .tick { position: absolute; top: 2px; height: 5px; min-width: 2px; border-radius: 1px; background: var(--ink); opacity: .6; z-index: 1; }
|
|
183
|
+
.hg .lane .tick.tool { top: 21px; background: var(--ink3); }
|
|
184
|
+
.hg .lane .tick.refused, .hg .lane .tick.bad { top: 21px; height: 6px; background: var(--bad); opacity: 1; min-width: 3px; z-index: 2; }
|
|
185
|
+
.hg .lane .tick.model.bad { top: 1px; }
|
|
186
|
+
.hg .lane .tick.open { opacity: 1; background: var(--accent); animation: hg-blink 1.2s ease-in-out infinite; }
|
|
187
|
+
.hg .lane .mark { position: absolute; top: 9px; width: 10px; height: 10px; margin-left: -5px; background: var(--warn); transform: rotate(45deg); border-radius: 2px; z-index: 1; }
|
|
188
|
+
.hg .lane .playhead { position: absolute; top: -2px; bottom: -2px; width: 2px; margin-left: -1px; background: var(--bad); z-index: 4; pointer-events: none; }
|
|
189
|
+
.hg .lane-row.axis .lane { height: 16px; background: none; }
|
|
190
|
+
.hg .lane-row.axis .lane span { position: absolute; top: 1px; font: 10.5px var(--mono); color: var(--ink3); transform: translateX(-50%); }
|
|
191
|
+
.hg .lane-row.axis .lane span:first-child { transform: none; } .hg .lane-row.axis .lane span:last-child { transform: translateX(-100%); }
|
|
192
|
+
.hg .legend { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; font-size: 11.5px; margin-top: 10px; }
|
|
193
|
+
.hg .legend .ord, .hg .legend .tick, .hg .legend .mark { position: static; display: inline-block; width: 16px; height: 10px; margin: 0 2px 0 0; vertical-align: middle; animation: none; }
|
|
194
|
+
.hg .legend .tick { height: 5px; } .hg .legend .mark { width: 8px; height: 8px; transform: rotate(45deg); } .hg .legend .gap { width: 10px; }
|
|
195
|
+
@container hg-main (max-width: 560px) { .hg .lane-row { grid-template-columns: 84px minmax(0, 1fr); } .hg .lane-lbl .dim { display: none; }
|
|
196
|
+
.hg .lane-row.axis .lane span:nth-child(even) { display: none; } }
|
|
197
|
+
@keyframes hg-blink { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
|
|
198
|
+
@media (prefers-reduced-motion: reduce) { .hg .ord.open, .hg .lane .tick.open { animation: none; } }
|
|
163
199
|
.hg .trace { display: grid; gap: 4px; }
|
|
164
200
|
.hg .trace .tr { display: grid; grid-template-columns: minmax(160px, 300px) 1fr 64px; gap: 10px; align-items: center; font-size: 12px; }
|
|
165
201
|
.hg .trace .tl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink2); }
|
|
@@ -171,7 +207,7 @@
|
|
|
171
207
|
.hg .alerts li { display: flex; gap: 8px; align-items: baseline; padding: 6px 0; }
|
|
172
208
|
.hg .alerts { list-style: none; margin: 0; padding: 0; }
|
|
173
209
|
.hg .kanban { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
|
174
|
-
@
|
|
210
|
+
@container hg-main (max-width: 780px) { .hg .kanban { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
175
211
|
.hg .col { background: var(--surface2); border: 1px solid var(--line); border-radius: 9px; padding: 8px; min-height: 120px; }
|
|
176
212
|
.hg .col h3 { margin: 2px 4px 8px; font-size: 11.5px; color: var(--ink3); text-transform: uppercase; letter-spacing: .05em; display: flex; }
|
|
177
213
|
.hg .col h3 b { margin-left: auto; color: var(--ink2); }
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* // or, with no server at all: { source: HypGraph.BundleSource(bundleJson) }
|
|
11
11
|
* </script>
|
|
12
12
|
*
|
|
13
|
-
* Views (docs/ui-plan.md): Overview · Live · Pipeline · Graph · Evidence · Work orders · Audit log · Report, each in
|
|
13
|
+
* Views (docs/ui-plan.md): Overview · Live (with the swarm as swimlanes) · Pipeline · Graph · Evidence · Work orders · Audit log · Report, each in
|
|
14
14
|
* the investigation's profile's own words (Requirement, Thesis, Solution…). Nothing here writes. Standing, stages and
|
|
15
15
|
* gates come from the engine; a BundleSource shows what the bundle was exported with and re-verifies its hash chain
|
|
16
16
|
* in the browser. An HttpSource follows the record live (GET /stream), falling back to polling on older hosts, and
|
|
17
17
|
* hides what a host's API doesn't serve.
|
|
18
18
|
*
|
|
19
|
-
* Options: source (required) · theme ('light' | 'dark'
|
|
19
|
+
* Options: source (required) · theme ('light' | 'dark'; the OS's choice by default) · embed (on a page of its own:
|
|
20
|
+
* it grows to its content and scrolls nothing inside, the feed shows the latest 15 events) · hash (keep the view in
|
|
21
|
+
* location.hash) · title · live ·
|
|
20
22
|
* play (open on the Live view and replay the run from its first event; also `#view=live&play=1`, and skipped when
|
|
21
23
|
* the visitor asks for reduced motion) · onTheme(t) · operatorUrl · fileUrl(path). Keys: 1–8 views · ⌘K / Ctrl+K or / search · Esc close · t theme.
|
|
22
24
|
* `HypAtlas` is kept as an alias of `HypGraph` for one release.
|
|
@@ -321,7 +323,9 @@
|
|
|
321
323
|
opts = opts || {};
|
|
322
324
|
const src = opts.source || HttpSource();
|
|
323
325
|
root.classList.add('hg');
|
|
324
|
-
|
|
326
|
+
const prefersDark = () => !!(global.matchMedia && global.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
327
|
+
if (!root.dataset.theme) root.dataset.theme = opts.theme || (prefersDark() ? 'dark' : 'light');
|
|
328
|
+
if (opts.embed) root.classList.add('hg-embed'); // on a page of its own, not the workbench: short feeds, no scroll trap
|
|
325
329
|
const brand = opts.title && !/^atlas$/i.test(opts.title) ? esc(opts.title) : 'Hyp<b>Graph</b>';
|
|
326
330
|
root.innerHTML = `<div class="hg-app">
|
|
327
331
|
<header class="hg-top"><div class="hg-brand">${LOGO}<span>${brand}</span></div>
|
|
@@ -454,9 +458,14 @@
|
|
|
454
458
|
if (m.tel.length) out.push(...telAlerts(m.tel));
|
|
455
459
|
return out;
|
|
456
460
|
}
|
|
457
|
-
const alertList = (items) =>
|
|
458
|
-
|
|
459
|
-
|
|
461
|
+
const alertList = (items) => {
|
|
462
|
+
const seen = new Map(); // the same alert raised four times is one line, ×4
|
|
463
|
+
items.forEach(([lvl, txt, code]) => { const k = lvl + '|' + txt, h = seen.get(k); if (h) h.n++; else seen.set(k, { lvl, txt, code, n: 1 }); });
|
|
464
|
+
const rows = [...seen.values()];
|
|
465
|
+
return rows.length ? `<ul class="alerts">${rows.map(({ lvl, txt, code, n }) =>
|
|
466
|
+
`<li><span class="pill ${lvl}">${lvl === 'bad' ? 'action' : lvl === 'warn' ? 'check' : 'note'}</span><span>${esc(txt)}${n > 1 ? ` <span class="pill neutral" title="raised ${n} times">×${n}</span>` : ''}${code ? ` <button class="btn" type="button" data-n="${esc(code)}" style="padding:1px 8px">open</button>` : ''}</span></li>`).join('')}</ul>`
|
|
467
|
+
: '<div class="dim">Nothing needs attention.</div>';
|
|
468
|
+
};
|
|
460
469
|
|
|
461
470
|
// ── telemetry (what the swarm did that the record can't see) ──
|
|
462
471
|
function telUpTo() { // the telemetry the current replay position has reached
|
|
@@ -515,7 +524,7 @@
|
|
|
515
524
|
switch (t.kind) {
|
|
516
525
|
case 'model_end': what = t.ok === false ? `<span class="tel-bad">model call failed</span> ${d(String(t.error || '').slice(0, 120))}`
|
|
517
526
|
: `model ${d(t.model || '')} · ${fmtN(t.in || 0)}→${fmtN(t.out || 0)} tokens${t.usd ? ' · ' + fmtUsd(t.usd) : ''} · ${t.s}s${(t.tool_calls || []).length ? ' → ' + t.tool_calls.map(esc).join(', ') : ''}`; break;
|
|
518
|
-
case 'tool_end': what = refusedCall(t) ? `<span class="tel-warn">${esc(t.tool)} refused by the record</span> ${d(String(t.error || '').replace(/^refused:?\s*/, '').slice(0, 140))}`
|
|
527
|
+
case 'tool_end': what = refusedCall(t) ? `<span class="tel-warn">${esc(t.tool)} refused by the record</span> ${d(String(t.error || '').replace(/^refused:?\s*/, '').replace(/\b(\w+ refused):\s*\1:\s*/g, '$1: ').slice(0, 140))}`
|
|
519
528
|
: t.ok === false ? `<span class="tel-bad">tool ${esc(t.tool)} failed</span> ${d(String(t.error || '').slice(0, 120))}` : `tool ${c(t.tool)} · ${t.s}s`; break;
|
|
520
529
|
case 'order_start': what = `took ${c(t.order)} ${d('· ' + (t.model || ''))}`; break;
|
|
521
530
|
case 'order_end': what = `finished ${c(t.order)} · ${t.calls || 0} call(s)${t.stopped ? ' ' + d('· ' + t.stopped) : ''}`; break;
|
|
@@ -552,6 +561,57 @@
|
|
|
552
561
|
<div class="trace">${rows || '<div class="dim">No calls yet.</div>'}</div>`;
|
|
553
562
|
}
|
|
554
563
|
|
|
564
|
+
function lanes(tel, running) { // the swarm as swimlanes: seats as rows, orders as bars, calls as ticks
|
|
565
|
+
const m = S.m, all = m.tel, following = S.cursor == null;
|
|
566
|
+
if (!all.length) return '';
|
|
567
|
+
const run = all.find((t) => t.kind === 'run_start'), end = all.find((t) => t.kind === 'run_end');
|
|
568
|
+
const seen = tel.length ? tel[tel.length - 1].ts : (run ? run.ts : all[0].ts);
|
|
569
|
+
const now = following && running ? Date.now() / 1000 : seen; // the replay position, or the wall clock
|
|
570
|
+
const t0 = run ? run.ts : all[0].ts;
|
|
571
|
+
const t1 = Math.max(end ? end.ts : Math.max(all[all.length - 1].ts, now), t0 + 1); // a fixed axis while replaying
|
|
572
|
+
const span = t1 - t0, x = (ts) => Math.min(100, Math.max(0, ((ts - t0) / span) * 100));
|
|
573
|
+
const fmtT = (s) => s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}` : `${Math.floor(s / 60)}:${String(Math.floor(Math.max(s, 0) % 60)).padStart(2, '0')}`;
|
|
574
|
+
const starts = tel.filter((t) => t.kind === 'order_start');
|
|
575
|
+
if (!starts.length) return '<div class="empty">Nobody has started yet.</div>';
|
|
576
|
+
const status = new Map((m.orders || []).map((o) => [o.code, o.status]));
|
|
577
|
+
const ends = new Map(tel.filter((t) => t.kind === 'order_end').map((t) => [t.span, t]));
|
|
578
|
+
const startOf = new Map(tel.filter((t) => t.kind === 'model_start' || t.kind === 'tool_start').map((t) => [t.span, t]));
|
|
579
|
+
const calls = tel.filter((t) => t.kind === 'model_end' || t.kind === 'tool_end');
|
|
580
|
+
const open = openSpans(tel).filter((t) => t.kind === 'model_start' || t.kind === 'tool_start');
|
|
581
|
+
const seats = [...new Set(starts.map((o) => o.seat).concat(calls.map((t) => t.seat)).filter(Boolean))]; // in the order they first took work; a seat that only made calls (the lead's report) still gets its row
|
|
582
|
+
const head = (following && running) || S.playing || !following ? `<u class="playhead" style="left:${x(now).toFixed(2)}%" title="${esc(fmtT(now - t0))}"></u>` : '';
|
|
583
|
+
const rows = seats.map((seat) => {
|
|
584
|
+
const bars = starts.filter((o) => o.seat === seat).map((o) => {
|
|
585
|
+
const e = ends.get(o.span), st = e ? (status.get(o.order) || 'ended') : 'open'; // until it ends, the viewer doesn't know how it ends
|
|
586
|
+
const a = x(o.ts), b = x(e ? e.ts : now);
|
|
587
|
+
const cls = st === 'accepted' ? 'ok' : st === 'reworked' ? 'warn' : st === 'cancelled' ? 'off' : st === 'returned' ? 'info' : e ? 'done' : 'open';
|
|
588
|
+
const title = `${o.order} · ${seat}${o.model ? ' · ' + o.model : ''}\n${e ? `${Number(e.s || e.ts - o.ts).toFixed(1)}s · ${e.calls || 0} call(s)${e.stopped ? ' · ' + e.stopped : ''}` : 'in flight'}\n${st}`;
|
|
589
|
+
return `<button type="button" class="ord ${cls}" data-act="lv-trace" data-order="${esc(o.order)}" style="left:${a.toFixed(2)}%;width:${Math.max(b - a, 0.4).toFixed(2)}%" title="${esc(title)}">${esc(o.order)}</button>`;
|
|
590
|
+
}).join('');
|
|
591
|
+
const ticks = calls.filter((t) => t.seat === seat).map((t) => {
|
|
592
|
+
const s = startOf.get(t.span), ts = s ? s.ts : t.ts - (Number(t.s) || 0);
|
|
593
|
+
const a = x(ts), w = Math.max(((Number(t.s) || 0) / span) * 100, 0.15);
|
|
594
|
+
const kind = t.kind === 'model_end' ? (t.ok === false ? 'bad' : 'model') : refusedCall(t) ? 'refused' : failed(t) ? 'bad' : 'tool';
|
|
595
|
+
const title = t.kind === 'model_end' ? `model ${t.model || ''} · ${fmtN(t.in || 0)}→${fmtN(t.out || 0)} tokens${t.usd ? ' · ' + fmtUsd(t.usd) : ''} · ${t.s}s${t.ok === false ? '\n' + String(t.error || '').slice(0, 120) : ''}`
|
|
596
|
+
: `${t.tool} · ${t.s}s${t.ok === false ? '\n' + String(t.error || '').replace(/^refused:?\s*/, '').slice(0, 120) : ''}`;
|
|
597
|
+
return `<u class="tick ${kind}" style="left:${a.toFixed(2)}%;width:${Math.min(w, 100 - a).toFixed(2)}%" title="${esc(title)}"></u>`;
|
|
598
|
+
}).join('');
|
|
599
|
+
const flying = open.filter((t) => t.seat === seat).map((t) => `<u class="tick open ${t.kind === 'model_start' ? 'model' : 'tool'}" style="left:${x(t.ts).toFixed(2)}%;width:${Math.max(x(now) - x(t.ts), 0.15).toFixed(2)}%" title="${esc(t.kind === 'model_start' ? `calling ${t.model || 'the model'}` : `running ${t.tool}`)}"></u>`).join('');
|
|
600
|
+
const mine = calls.filter((t) => t.seat === seat), n = mine.filter((t) => t.kind === 'model_end').length;
|
|
601
|
+
const usd = mine.reduce((a, t) => a + (Number(t.usd) || 0), 0), refs = mine.filter(refusedCall).length;
|
|
602
|
+
return `<div class="lane-row"><div class="lane-lbl"><b>${esc(seat)}</b><span class="dim">${n} call${n === 1 ? '' : 's'}${usd ? ' · ' + fmtUsd(usd) : ''}${refs ? ` · <span class="tel-warn">${refs} refused</span>` : ''}</span></div><div class="lane">${bars}${ticks}${flying}${head}</div></div>`;
|
|
603
|
+
}).join('');
|
|
604
|
+
const marks = tel.filter((t) => t.kind === 'decision' && /rework|escalat|cancel|moot/.test(String(t.event || ''))).map((t) =>
|
|
605
|
+
`<u class="mark" style="left:${x(t.ts).toFixed(2)}%" title="${esc(`scheduler: ${t.event}${t.order ? ' ' + t.order : ''}${t.why || t.missing ? ' — ' + String(t.why || t.missing).slice(0, 100) : ''}`)}"></u>`).join('');
|
|
606
|
+
const sched = marks ? `<div class="lane-row"><div class="lane-lbl"><b>scheduler</b><span class="dim">reworks · escalations</span></div><div class="lane">${marks}${head}</div></div>` : '';
|
|
607
|
+
const step = [5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600].find((s) => span / s <= 8) || 3600;
|
|
608
|
+
let ax = '';
|
|
609
|
+
for (let s = 0; s <= span; s += step) ax += `<span style="left:${((s / span) * 100).toFixed(2)}%">${fmtT(s)}</span>`;
|
|
610
|
+
return `<div class="lanes">${rows}${sched}<div class="lane-row axis"><div class="lane-lbl"></div><div class="lane">${ax}</div></div></div>
|
|
611
|
+
<div class="legend dim"><i class="ord ok"></i> accepted <i class="ord warn"></i> reworked <i class="ord info"></i> returned <i class="ord off"></i> cancelled <i class="ord open"></i> in flight
|
|
612
|
+
<span class="gap"></span><u class="tick model"></u> model call <u class="tick tool"></u> tool call <u class="tick refused"></u> refused by the record <u class="tick bad"></u> failed <u class="mark"></u> rework · escalation</div>`;
|
|
613
|
+
}
|
|
614
|
+
|
|
555
615
|
// ── views ──
|
|
556
616
|
function vOverview() {
|
|
557
617
|
const m = S.m, qs = m.board || [], claims = m.nodes.filter((n) => n.type === 'claim');
|
|
@@ -714,6 +774,7 @@
|
|
|
714
774
|
<button class="btn" type="button" data-act="lv-live" ${following ? 'disabled' : ''}>${live ? 'Follow live' : 'Jump to end'}</button></div></div>
|
|
715
775
|
${tk.length ? `<div class="grid kpis" style="margin-bottom:10px">${tk.map(([k, v, s, c]) => `<div class="card kpi ${c || ''}"><div class="k">${esc(k)}</div><div class="v">${v}</div>${s ? `<div class="s">${esc(s)}</div>` : ''}</div>`).join('')}</div>` : ''}
|
|
716
776
|
<div class="grid kpis" style="margin-bottom:14px">${kp.map(([k, v]) => `<div class="card kpi"><div class="k">${esc(k)}</div><div class="v">${v}</div></div>`).join('')}</div>
|
|
777
|
+
${m.tel.length ? `<div class="card" style="margin-bottom:14px"><h3>Timeline <span class="sub">who held what, when — every call a tick; click an order for its trace</span></h3><div class="body">${lanes(tel, running)}</div></div>` : ''}
|
|
717
778
|
${m.tel.length ? (() => { const a = telAlerts(tel); return a.length ? `<div class="card" style="margin-bottom:14px"><h3>Alerts</h3><div class="body">${alertList(a)}</div></div>` : ''; })() : ''}
|
|
718
779
|
<div class="grid live">
|
|
719
780
|
<div class="card"><h3>Seats <span class="sub">who is working on what</span></h3>${seats(seen, tel)}</div>
|
|
@@ -725,10 +786,10 @@
|
|
|
725
786
|
${m.tel.length ? `<div class="card" style="margin-top:14px"><h3>Trace <span class="sub">where a work order's time went: each model and tool call</span></h3><div class="body">${trace(tel)}</div></div>` : ''}`;
|
|
726
787
|
}
|
|
727
788
|
function feed(seen, tel, running) {
|
|
728
|
-
const mode = S.feed || 'all';
|
|
729
|
-
const rec = mode === 'telemetry' ? [] : seen.slice(-
|
|
730
|
-
const tl = mode === 'record' ? [] : tel.filter((t) => !t.kind.endsWith('_start') || t.kind === 'order_start' || t.kind === 'run_start').slice(-
|
|
731
|
-
const all = rec.concat(tl).sort((a, b) => b.ts - a.ts || ((b.e ? b.e.seq : 0) - (a.e ? a.e.seq : 0))).slice(0,
|
|
789
|
+
const mode = S.feed || 'all', cap = opts.embed ? 15 : 400;
|
|
790
|
+
const rec = mode === 'telemetry' ? [] : seen.slice(-cap).map((e) => ({ ts: Date.parse(e.at) / 1000, e }));
|
|
791
|
+
const tl = mode === 'record' ? [] : tel.filter((t) => !t.kind.endsWith('_start') || t.kind === 'order_start' || t.kind === 'run_start').slice(-cap).map((t) => ({ ts: t.ts, t }));
|
|
792
|
+
const all = rec.concat(tl).sort((a, b) => b.ts - a.ts || ((b.e ? b.e.seq : 0) - (a.e ? a.e.seq : 0))).slice(0, cap);
|
|
732
793
|
return all.map((x, i) => x.e ? evRow(x.e, i === 0 && (S.playing || running)) : telRow(x.t)).join('') || '<div class="empty">Nothing yet.</div>';
|
|
733
794
|
}
|
|
734
795
|
function seats(seen, tel) {
|
|
@@ -959,6 +1020,7 @@
|
|
|
959
1020
|
if (a === 'lv-play') return S.playing ? (stopPlay(), render()) : play();
|
|
960
1021
|
if (a === 'lv-start') { stopPlay(); S.cursor = 0; return render(); }
|
|
961
1022
|
if (a === 'lv-live') { stopPlay(); S.cursor = null; return render(); }
|
|
1023
|
+
if (a === 'lv-trace') { S.traceOrder = t.dataset.order || ''; render(); const el = $('#hg-trace-order'); if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); return; }
|
|
962
1024
|
if (a === 'print') return global.print();
|
|
963
1025
|
if (a === 'md') return download(`${slug}-report.md`, (m.report && m.report.markdown) || '', 'text/markdown');
|
|
964
1026
|
if (a === 'bundle') return download(`${slug}.bundle.json`, JSON.stringify(src.bundle), 'application/json');
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<script>
|
|
22
22
|
const stored = (() => { try { return localStorage.getItem('hypgraph-theme'); } catch { return null; } })();
|
|
23
23
|
HypGraph.mount(document.getElementById('app'), {
|
|
24
|
-
source: HypGraph.HttpSource('api/graph'), hash: true, theme: stored || 'light',
|
|
24
|
+
source: HypGraph.HttpSource('api/graph'), hash: true, theme: stored || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'),
|
|
25
25
|
onTheme: (t) => { try { localStorage.setItem('hypgraph-theme', t); } catch { /* private mode */ } },
|
|
26
26
|
});
|
|
27
27
|
</script>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Which Host headers `serve` answers to. Bound to loopback: loopback names. Bound to a wildcard (a container
|
|
2
|
+
with a published port): loopback names too — the browser reaches the published port as 127.0.0.1:<host port>, and
|
|
3
|
+
the bind address is never a name a browser sends. Anything else is 421, unless --allow-host names it."""
|
|
4
|
+
import threading
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hypgraph import Graph
|
|
11
|
+
from hypgraph.server import accepted_hosts, make_server
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get(url, host_header):
|
|
15
|
+
req = urllib.request.Request(f"{url}/api/graph/investigations", headers={"Host": host_header})
|
|
16
|
+
try:
|
|
17
|
+
with urllib.request.urlopen(req) as r:
|
|
18
|
+
return r.status
|
|
19
|
+
except urllib.error.HTTPError as e:
|
|
20
|
+
return e.code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def serve_on(tmp_path):
|
|
25
|
+
servers = []
|
|
26
|
+
|
|
27
|
+
def start(host, **kw):
|
|
28
|
+
g = Graph(str(tmp_path / f"{len(servers)}.hypgraph"))
|
|
29
|
+
srv = make_server(g, host, port=0, **kw)
|
|
30
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
31
|
+
servers.append(srv)
|
|
32
|
+
return f"http://127.0.0.1:{srv.server_address[1]}"
|
|
33
|
+
|
|
34
|
+
yield start
|
|
35
|
+
for srv in servers:
|
|
36
|
+
srv.shutdown()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_accepted_hosts_rules():
|
|
40
|
+
assert accepted_hosts("127.0.0.1") == {"127.0.0.1", "localhost", "::1"}
|
|
41
|
+
assert accepted_hosts("0.0.0.0") == {"127.0.0.1", "localhost", "::1"} # the bind address is not a name
|
|
42
|
+
assert accepted_hosts("::") == {"127.0.0.1", "localhost", "::1"}
|
|
43
|
+
assert accepted_hosts("lab.local") == {"lab.local"} # a real name: that name only
|
|
44
|
+
assert accepted_hosts("0.0.0.0", ["Swarm.Internal", " "]) == {"127.0.0.1", "localhost", "::1", "swarm.internal"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_loopback_bind_answers_loopback_names_and_refuses_others(serve_on):
|
|
48
|
+
url = serve_on("127.0.0.1")
|
|
49
|
+
assert _get(url, "127.0.0.1:9") == 200 # the port is not part of the check
|
|
50
|
+
assert _get(url, "localhost") == 200
|
|
51
|
+
assert _get(url, "evil.example") == 421
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_wildcard_bind_answers_loopback_through_a_published_port(serve_on):
|
|
55
|
+
url = serve_on("0.0.0.0")
|
|
56
|
+
assert _get(url, "127.0.0.1:8100") == 200 # the host side of a container's published port
|
|
57
|
+
assert _get(url, "localhost:8100") == 200
|
|
58
|
+
assert _get(url, "0.0.0.0:8000") == 421 # no browser sends the bind address; a rebinding page might
|
|
59
|
+
assert _get(url, "evil.example") == 421
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_allow_host_adds_a_name(serve_on):
|
|
63
|
+
url = serve_on("0.0.0.0", allow_hosts=["swarm.internal"])
|
|
64
|
+
assert _get(url, "swarm.internal:8000") == 200
|
|
65
|
+
assert _get(url, "other.internal") == 421
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|