dev-guru 0.3.0__tar.gz → 0.4.0__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.
Files changed (44) hide show
  1. {dev_guru-0.3.0 → dev_guru-0.4.0}/CHANGELOG.md +35 -0
  2. {dev_guru-0.3.0 → dev_guru-0.4.0}/PKG-INFO +16 -6
  3. {dev_guru-0.3.0 → dev_guru-0.4.0}/README.md +13 -3
  4. {dev_guru-0.3.0 → dev_guru-0.4.0}/pyproject.toml +2 -2
  5. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/inprocess.py +3 -0
  6. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/negotiation.py +3 -1
  7. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/port.py +2 -0
  8. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/gateway/server.py +32 -8
  9. dev_guru-0.4.0/src/dev_guru/gateway/static/app.css +73 -0
  10. dev_guru-0.4.0/src/dev_guru/gateway/static/app.js +405 -0
  11. dev_guru-0.4.0/src/dev_guru/gateway/static/favicon.svg +1 -0
  12. dev_guru-0.4.0/src/dev_guru/gateway/static/index.html +37 -0
  13. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/render.py +3 -0
  14. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/version.py +1 -1
  15. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_gateway.py +224 -0
  16. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_negotiation.py +2 -2
  17. dev_guru-0.3.0/src/dev_guru/gateway/static/index.html +0 -12
  18. {dev_guru-0.3.0 → dev_guru-0.4.0}/.gitignore +0 -0
  19. {dev_guru-0.3.0 → dev_guru-0.4.0}/LICENSE +0 -0
  20. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/__init__.py +0 -0
  21. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/__main__.py +0 -0
  22. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/cli.py +0 -0
  23. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/__init__.py +0 -0
  24. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/diagram/__init__.py +0 -0
  25. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/errors.py +0 -0
  26. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/gateway/__init__.py +0 -0
  27. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/present/__init__.py +0 -0
  28. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/__init__.py +0 -0
  29. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/assets/explorer.css +0 -0
  30. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/assets/explorer.html +0 -0
  31. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/assets/explorer.js +0 -0
  32. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/model.py +0 -0
  33. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/pages.py +0 -0
  34. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/profiles.py +0 -0
  35. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/sdk.py +0 -0
  36. {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/service.py +0 -0
  37. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/conftest.py +0 -0
  38. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_cli.py +0 -0
  39. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_client.py +0 -0
  40. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_diagram.py +0 -0
  41. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_facade_features.py +0 -0
  42. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_packaging.py +0 -0
  43. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_present.py +0 -0
  44. {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_reports.py +0 -0
@@ -10,6 +10,41 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — [SemVer](ht
10
10
 
11
11
  ---
12
12
 
13
+ ## [0.4.0] — 2026-11-03
14
+
15
+ ### Added
16
+
17
+ - **A web interface in `dev serve`.** Overview, runs, a code view and capabilities from your sources, as plain files in
18
+ the package: no Node and no build step. Runs are shown live, survive a tab reload and a lost connection, name any
19
+ events no longer kept, and stop only when you press Cancel. It works from the keyboard and on a phone. Pages that
20
+ don't exist yet say so and name the commands that work today. Guide: [The local gateway](docs/guides/gateway.md).
21
+ - `GET /api/v1/runs`, the runs in a workspace.
22
+ - Real-Chrome journeys for the interface, on a temporary workspace they build themselves (`make web-journeys`, and part
23
+ of the acceptance suite).
24
+ - [ROADMAP.md](ROADMAP.md): what's released and what's planned.
25
+ - Dependabot for `uv` and GitHub Actions, with a seven-day cooldown.
26
+
27
+ ### Changed
28
+
29
+ - Needs Ananke 0.3.19 or newer, for its fixes to path traversal and vulnerable dependencies.
30
+ - CI grants `security-events: write` only to the jobs that upload findings.
31
+ - Releases are created with `gh release` rather than a third-party action.
32
+ - CI installs Trivy with retries and a pinned SHA-256 rather than through a third-party action, so one failed DNS lookup can't block a release.
33
+
34
+ ### Fixed
35
+
36
+ - The run console could stop without showing a run's final event, if it read between the run's final state and that event.
37
+
38
+ - The gateway answered a login token or CSRF token that wasn't ASCII with a `500`. It's now the usual refusal. No request
39
+ got through either way.
40
+
41
+ ### Security
42
+
43
+ - Reviewed two Semgrep findings as false positives and marked each in the code with its reason. Reviewed findings no
44
+ longer reappear as new alerts when a line moves.
45
+
46
+ ---
47
+
13
48
  ## [0.3.0] — 2026-11-03
14
49
 
15
50
  ### Added
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dev-guru
3
- Version: 0.3.0
4
- Summary: Dev Guru: understand, improve, verify and learn from your software. A local-first developer product on the Ananke Plexus harness: Python SDK and CLI first, offline HTML reports, later a local web UI.
3
+ Version: 0.4.0
4
+ Summary: Dev Guru: understand, improve, verify and learn from your software. A local-first developer product on the Ananke Plexus harness: Python SDK and CLI, offline HTML reports and a local web interface.
5
5
  Project-URL: Homepage, https://github.com/Neo-Pragnya/Dev-Guru
6
6
  Project-URL: Repository, https://github.com/Neo-Pragnya/Dev-Guru
7
7
  Project-URL: Issues, https://github.com/Neo-Pragnya/Dev-Guru/issues
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Topic :: Software Development :: Quality Assurance
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
- Requires-Dist: ananke-plexus<0.4,>=0.3.17
25
+ Requires-Dist: ananke-plexus<0.4,>=0.3.19
26
26
  Requires-Dist: markdown-it-py<5,>=3
27
27
  Requires-Dist: pydantic<3,>=2.11
28
28
  Requires-Dist: rich>=13
@@ -90,7 +90,7 @@ dev present talk.md # Markdown slides → one offline, accessib
90
90
  dev diagram docs/arch.md # an animated SVG of what a document states, every edge cited
91
91
  dev explore . --profile public # a report safe to publish: no source, docstrings or Git revision
92
92
  dev sources # capabilities from your folders, private Git and Dex Trove
93
- dev serve # a local, authenticated gateway to Ananke for browsers
93
+ dev serve # a local web interface: live runs, the code graph, your capabilities
94
94
  dev learning review NAME # review what Ananke learned before it becomes reusable
95
95
  ```
96
96
 
@@ -104,11 +104,20 @@ with GuruClient(workspace="path/to/project") as guru:
104
104
  guru.export(result, output="explorer.html")
105
105
  ```
106
106
 
107
+ ## In your browser
108
+
109
+ `dev serve` opens a web interface to what Ananke computes. It shows what's connected, starts and follows runs (which
110
+ survive a reload or a lost connection, and stop only when you press Cancel), browses the code graph, and searches your
111
+ capabilities. It listens only on your computer, only the link it prints gets in, and it needs no Node: everything comes
112
+ from the [Dev Guru package on PyPI](https://pypi.org/project/dev-guru/). See [The local gateway](docs/guides/gateway.md).
113
+
114
+ What's coming next, including code maps, flows, ERDs and CVE views, is in the [roadmap](ROADMAP.md).
115
+
107
116
  ## How it fits together
108
117
 
109
118
  | Repository | Role |
110
119
  | --- | --- |
111
- | **Dev Guru** (this repo) | The product: CLI and SDK, report view models and renderers, later a local web UI |
120
+ | **Dev Guru** (this repo) | The product: CLI and SDK, report view models and renderers, a local web interface |
112
121
  | [Ananke Plexus](https://github.com/Neo-Pragnya/Ananke-Plexus) | The harness: code graph, analysis, workflows, policy, runs and evidence |
113
122
  | [Dex Trove](https://github.com/Neo-Pragnya/Dex-Trove) | Optional capability memory: curated skills, agents, instructions and workflows |
114
123
 
@@ -119,7 +128,8 @@ required. See [Architecture](docs/concepts/architecture.md) and [How Dev Guru ta
119
128
 
120
129
  ```bash
121
130
  make verify # ruff, ruff format, strict mypy, pytest
122
- make acceptance-wheel # capability scenarios on the built wheel, including real-Chrome report journeys
131
+ make acceptance-wheel # capability scenarios on the built wheel, including real-Chrome journeys
132
+ make web-journeys # just the web interface, in real Chrome, on a temporary workspace
123
133
  make scan # gitleaks, pip-audit, GuardDog and Bandit on dist/
124
134
  ```
125
135
 
@@ -47,7 +47,7 @@ dev present talk.md # Markdown slides → one offline, accessib
47
47
  dev diagram docs/arch.md # an animated SVG of what a document states, every edge cited
48
48
  dev explore . --profile public # a report safe to publish: no source, docstrings or Git revision
49
49
  dev sources # capabilities from your folders, private Git and Dex Trove
50
- dev serve # a local, authenticated gateway to Ananke for browsers
50
+ dev serve # a local web interface: live runs, the code graph, your capabilities
51
51
  dev learning review NAME # review what Ananke learned before it becomes reusable
52
52
  ```
53
53
 
@@ -61,11 +61,20 @@ with GuruClient(workspace="path/to/project") as guru:
61
61
  guru.export(result, output="explorer.html")
62
62
  ```
63
63
 
64
+ ## In your browser
65
+
66
+ `dev serve` opens a web interface to what Ananke computes. It shows what's connected, starts and follows runs (which
67
+ survive a reload or a lost connection, and stop only when you press Cancel), browses the code graph, and searches your
68
+ capabilities. It listens only on your computer, only the link it prints gets in, and it needs no Node: everything comes
69
+ from the [Dev Guru package on PyPI](https://pypi.org/project/dev-guru/). See [The local gateway](docs/guides/gateway.md).
70
+
71
+ What's coming next, including code maps, flows, ERDs and CVE views, is in the [roadmap](ROADMAP.md).
72
+
64
73
  ## How it fits together
65
74
 
66
75
  | Repository | Role |
67
76
  | --- | --- |
68
- | **Dev Guru** (this repo) | The product: CLI and SDK, report view models and renderers, later a local web UI |
77
+ | **Dev Guru** (this repo) | The product: CLI and SDK, report view models and renderers, a local web interface |
69
78
  | [Ananke Plexus](https://github.com/Neo-Pragnya/Ananke-Plexus) | The harness: code graph, analysis, workflows, policy, runs and evidence |
70
79
  | [Dex Trove](https://github.com/Neo-Pragnya/Dex-Trove) | Optional capability memory: curated skills, agents, instructions and workflows |
71
80
 
@@ -76,7 +85,8 @@ required. See [Architecture](docs/concepts/architecture.md) and [How Dev Guru ta
76
85
 
77
86
  ```bash
78
87
  make verify # ruff, ruff format, strict mypy, pytest
79
- make acceptance-wheel # capability scenarios on the built wheel, including real-Chrome report journeys
88
+ make acceptance-wheel # capability scenarios on the built wheel, including real-Chrome journeys
89
+ make web-journeys # just the web interface, in real Chrome, on a temporary workspace
80
90
  make scan # gitleaks, pip-audit, GuardDog and Bandit on dist/
81
91
  ```
82
92
 
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "dev-guru"
7
7
  dynamic = ["version"]
8
- description = "Dev Guru: understand, improve, verify and learn from your software. A local-first developer product on the Ananke Plexus harness: Python SDK and CLI first, offline HTML reports, later a local web UI."
8
+ description = "Dev Guru: understand, improve, verify and learn from your software. A local-first developer product on the Ananke Plexus harness: Python SDK and CLI, offline HTML reports and a local web interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = "Apache-2.0"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  # Dev Guru is a client of Ananke: the harness owns analysis, execution and evidence (docs/architecture.md).
29
29
  # Keep this range in step with SUPPORTED_ANANKE in src/dev_guru/client/negotiation.py (tests/test_packaging.py).
30
30
  dependencies = [
31
- "ananke-plexus>=0.3.17,<0.4",
31
+ "ananke-plexus>=0.3.19,<0.4",
32
32
  "markdown-it-py>=3,<5",
33
33
  "pydantic>=2.11,<3",
34
34
  "rich>=13",
@@ -125,6 +125,9 @@ class InProcessAnanke:
125
125
  def get_run(self, workspace: Path, run_id: str) -> dict[str, Any]:
126
126
  return dict(self._call(workspace, lambda f: f.get_run(run_id)))
127
127
 
128
+ def run_history(self, workspace: Path) -> dict[str, Any]:
129
+ return dict(self._call(workspace, lambda f: f.evidence_view()))
130
+
128
131
  def run_events(self, workspace: Path, run_id: str, after: int = 0) -> dict[str, Any]:
129
132
  return dict(self._call(workspace, lambda f: f.stream_events(run_id, after)))
130
133
 
@@ -11,7 +11,7 @@ import importlib
11
11
  import re
12
12
  from importlib import metadata
13
13
 
14
- SUPPORTED_ANANKE = ">=0.3.17,<0.4" # keep in step with pyproject.toml (tests/test_packaging.py)
14
+ SUPPORTED_ANANKE = ">=0.3.19,<0.4" # keep in step with pyproject.toml (tests/test_packaging.py)
15
15
  APP_PROTOCOL = "ananke-app/1" # the app facade protocol this Dev Guru speaks
16
16
 
17
17
  # Capability id -> (module, attribute path). Required ones are what this release of Dev Guru calls.
@@ -57,6 +57,8 @@ def probe(capabilities: dict[str, tuple[str, str]] = CAPABILITIES) -> dict[str,
57
57
  found: dict[str, bool] = {}
58
58
  for cap, (module, attr_path) in capabilities.items():
59
59
  try:
60
+ # False positive: module names come from the fixed CAPABILITIES table above, never from a request or a file.
61
+ # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import
60
62
  obj: object = importlib.import_module(module)
61
63
  for part in attr_path.split("."):
62
64
  obj = getattr(obj, part)
@@ -141,6 +141,8 @@ class AnankeClient(Protocol):
141
141
 
142
142
  def get_run(self, workspace: Path, run_id: str) -> dict[str, Any]: ...
143
143
 
144
+ def run_history(self, workspace: Path) -> dict[str, Any]: ...
145
+
144
146
  def run_events(self, workspace: Path, run_id: str, after: int = 0) -> dict[str, Any]: ...
145
147
 
146
148
  def cancel_run(self, workspace: Path, run_id: str) -> dict[str, Any]: ...
@@ -19,6 +19,7 @@ import hmac
19
19
  import json
20
20
  import re
21
21
  import secrets
22
+ import sys
22
23
  import threading
23
24
  from concurrent.futures import ThreadPoolExecutor
24
25
  from http.cookies import CookieError, SimpleCookie
@@ -35,6 +36,13 @@ HOST = "127.0.0.1"
35
36
  MAX_BODY_BYTES = 2_000_000
36
37
  RUN_WORKERS = 2
37
38
  CSRF_HEADER = "X-Dev-Guru-CSRF"
39
+ # A fixed list, so no request path ever becomes a file path.
40
+ _STATIC = {
41
+ "/": ("index.html", "text/html; charset=utf-8"),
42
+ "/app.js": ("app.js", "text/javascript; charset=utf-8"),
43
+ "/app.css": ("app.css", "text/css; charset=utf-8"),
44
+ "/favicon.svg": ("favicon.svg", "image/svg+xml"),
45
+ }
38
46
  _RUN_ID = r"run-[0-9a-f]+"
39
47
  _ARTIFACT = r"[A-Za-z0-9_][A-Za-z0-9_.-]*"
40
48
  # The status for what Ananke refuses; anything retryable is 503, and anything else here is 400.
@@ -48,6 +56,20 @@ _REFUSAL_STATUS = {
48
56
  }
49
57
 
50
58
 
59
+ def _same(sent: str, secret: str) -> bool:
60
+ """Compare in constant time. As bytes, because ``compare_digest`` raises on text that isn't ASCII."""
61
+ return hmac.compare_digest(sent.encode("utf-8", "replace"), secret.encode())
62
+
63
+
64
+ class _Server(ThreadingHTTPServer):
65
+ daemon_threads = True
66
+
67
+ def handle_error(self, request: Any, client_address: Any) -> None:
68
+ """A browser dropping a connection is normal, and shouldn't print a stack trace in the user's terminal."""
69
+ if not isinstance(sys.exc_info()[1], ConnectionError):
70
+ super().handle_error(request, client_address)
71
+
72
+
51
73
  class _Reject(Exception):
52
74
  def __init__(self, status: int, code: str, message: str) -> None:
53
75
  super().__init__(message)
@@ -69,8 +91,7 @@ class Gateway:
69
91
  self._login_token: str | None = secrets.token_urlsafe(32)
70
92
  self._login_lock = threading.Lock()
71
93
  self.csrf = hmac.new(self._session.encode(), b"csrf", hashlib.sha256).hexdigest()
72
- self._server = ThreadingHTTPServer((HOST, port), _handler_for(self))
73
- self._server.daemon_threads = True
94
+ self._server = _Server((HOST, port), _handler_for(self))
74
95
  self.port = int(self._server.server_address[1])
75
96
  self.cookie_name = f"dev_guru_session_{self.port}" # cookies aren't separated by port
76
97
  self.origins = {f"http://{HOST}:{self.port}", f"http://localhost:{self.port}"}
@@ -84,13 +105,13 @@ class Gateway:
84
105
  def redeem(self, token: str) -> bool:
85
106
  """The login token works once. A link left in a history or a terminal log opens nothing later."""
86
107
  with self._login_lock:
87
- if self._login_token and hmac.compare_digest(token, self._login_token):
108
+ if self._login_token and _same(token, self._login_token):
88
109
  self._login_token = None
89
110
  return True
90
111
  return False
91
112
 
92
113
  def is_session(self, value: str) -> bool:
93
- return hmac.compare_digest(value, self._session)
114
+ return _same(value, self._session)
94
115
 
95
116
  def session_cookie(self) -> str:
96
117
  return f"{self.cookie_name}={self._session}; HttpOnly; SameSite=Strict; Path=/"
@@ -195,7 +216,7 @@ class _Handler(BaseHTTPRequestHandler):
195
216
 
196
217
  def _check_csrf(self) -> None:
197
218
  sent = self.headers.get(CSRF_HEADER, "")
198
- if not hmac.compare_digest(sent, self.gateway.csrf):
219
+ if not _same(sent, self.gateway.csrf):
199
220
  raise _Reject(403, "CSRF_REJECTED", f"a change needs the {CSRF_HEADER} header")
200
221
 
201
222
  def _read_json(self) -> dict[str, Any]:
@@ -251,9 +272,10 @@ class _Handler(BaseHTTPRequestHandler):
251
272
  return
252
273
  self._check_origin(required=False)
253
274
  self._check_session()
254
- if url.path == "/":
255
- page = (Path(__file__).parent / "static" / "index.html").read_bytes()
256
- self._send(200, page, "text/html; charset=utf-8")
275
+ served = _STATIC.get(url.path)
276
+ if served:
277
+ page = (Path(__file__).parent / "static" / served[0]).read_bytes()
278
+ self._send(200, page, served[1])
257
279
  return
258
280
  self._api_get(url.path, query)
259
281
 
@@ -309,6 +331,8 @@ class _Handler(BaseHTTPRequestHandler):
309
331
  depth=self._int(query, "depth", 1),
310
332
  )
311
333
  )
334
+ elif path == "/api/v1/runs":
335
+ self._json(client.run_history(root))
312
336
  elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})", path):
313
337
  self._json(client.get_run(root, match[1]))
314
338
  elif match := re.fullmatch(rf"/api/v1/runs/({_RUN_ID})/events", path):
@@ -0,0 +1,73 @@
1
+ /* Dev Guru "Lantern": warm parchment by day, plum-black lit by a flame at night. */
2
+ :root {
3
+ color-scheme: light dark;
4
+ --bg: #fbf4e6; --surface: #fffaf1; --surface-2: #f5e9d2; --ink: #2a1d14; --muted: #6d5a49; --line: #e3cfab;
5
+ --link: #a33f0c; --ok: #0b6157; --bad: #a62a1a; --warn: #7a4900; --ok-soft: #d6f3ee; --bad-soft: #fde3dc; --warn-soft: #fff0d4;
6
+ --flame: linear-gradient(135deg, #ffd27a, #ffb347 35%, #ff6b3d 75%, #e94f7a);
7
+ --display: Georgia, "Times New Roman", serif;
8
+ --body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
9
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
10
+ }
11
+ :root[data-theme="dark"] {
12
+ --bg: #0f0b13; --surface: #1a121f; --surface-2: #241a2b; --ink: #f6ecdf; --muted: #bfae9f; --line: #3a2a41;
13
+ --link: #ffbe73; --ok: #7fe6d8; --bad: #ffb197; --warn: #ffd58e; --ok-soft: #0e2f2b; --bad-soft: #3a1b12; --warn-soft: #3a2a10;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ html, body { margin: 0; }
17
+ body { min-height: 100vh; background: var(--bg); color: var(--ink); font: 16px/1.55 var(--body); }
18
+ a { color: var(--link); }
19
+ :focus-visible { outline: 3px solid #ff6b3d; outline-offset: 2px; border-radius: 4px; }
20
+ main:focus { outline: none; }
21
+ .skip { position: absolute; left: -999px; top: 8px; background: var(--surface); padding: 8px 12px; z-index: 5; }
22
+ .skip:focus { left: 8px; }
23
+ .top { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; padding: 12px 20px; background: var(--surface); border-bottom: 1px solid var(--line); }
24
+ .brand { display: inline-flex; align-items: center; gap: 10px; font: 600 20px var(--display); color: var(--ink); text-decoration: none; margin-right: auto; }
25
+ .flame { width: 14px; height: 20px; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%; background: var(--flame); box-shadow: 0 0 14px #ff8a3d99; animation: flicker 2.6s ease-in-out infinite; }
26
+ @keyframes flicker { 0%, 100% { transform: scale(1); opacity: 1; } 40% { transform: scale(1.08, .95); opacity: .86; } 70% { transform: scale(.95, 1.06); opacity: .95; } }
27
+ .field { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); min-width: 0; }
28
+ select, input { font: inherit; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; max-width: 100%; min-width: 0; }
29
+ button { font: inherit; font-weight: 600; cursor: pointer; border: 0; border-radius: 10px; padding: 8px 14px; background: var(--flame); color: #1d0f05; }
30
+ button.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); font-weight: 500; }
31
+ button:disabled { opacity: .55; cursor: default; }
32
+ .pill { font-size: 13px; padding: 3px 10px; border-radius: 99px; background: var(--surface-2); overflow-wrap: anywhere; }
33
+ .pill.ok, .succeeded { background: var(--ok-soft); color: var(--ok); }
34
+ .pill.bad, .failed { background: var(--bad-soft); color: var(--bad); }
35
+ .pill.warn, .cancelled { background: var(--warn-soft); color: var(--warn); }
36
+ .running, .queued { position: relative; background: var(--warn-soft); color: var(--warn); animation: glow 1.4s ease-in-out infinite; }
37
+ @keyframes glow { 50% { box-shadow: 0 0 12px #ff8a3d88; } }
38
+ .banner { padding: 10px 20px; background: var(--warn-soft); color: var(--warn); border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
39
+ .banner.bad { background: var(--bad-soft); color: var(--bad); }
40
+ .layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); }
41
+ nav { padding: 18px 14px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 2px; }
42
+ nav a { padding: 7px 10px; border-radius: 8px; color: var(--ink); text-decoration: none; }
43
+ nav a:hover { background: var(--surface-2); }
44
+ nav a[aria-current="page"] { background: var(--surface-2); font-weight: 600; box-shadow: inset 3px 0 0 #ff6b3d; }
45
+ .nav-title { margin: 14px 10px 4px; font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
46
+ .nav-title:first-child { margin-top: 0; }
47
+ main { padding: 24px clamp(16px, 4vw, 40px) 60px; min-width: 0; }
48
+ h1 { font: 600 30px/1.2 var(--display); margin: 0 0 6px; }
49
+ h2 { font: 600 20px var(--display); margin: 26px 0 8px; }
50
+ .lede { color: var(--muted); margin: 0 0 18px; max-width: 70ch; }
51
+ .card { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; margin: 12px 0; overflow-wrap: anywhere; }
52
+ .cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
53
+ .cards .card { margin: 0; }
54
+ .stat { font: 600 26px var(--display); display: block; }
55
+ .row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 12px 0; }
56
+ .wrap { overflow-x: auto; }
57
+ table { border-collapse: collapse; width: 100%; font-size: 14px; }
58
+ th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
59
+ th { color: var(--muted); font-weight: 600; }
60
+ code, .mono { font: 13px var(--mono); overflow-wrap: anywhere; }
61
+ .events { list-style: none; margin: 0; padding: 0 0 0 18px; border-left: 2px solid var(--line); }
62
+ .events li { position: relative; padding: 5px 0 5px 8px; animation: rise .3s ease-out; overflow-wrap: anywhere; }
63
+ .events li::before { content: ""; position: absolute; left: -24px; top: 12px; width: 10px; height: 10px; border-radius: 50%; background: var(--flame); }
64
+ @keyframes rise { from { opacity: 0; transform: translateY(4px); } }
65
+ .muted { color: var(--muted); }
66
+ .warning { border-left: 4px solid #ff6b3d; }
67
+ @media (max-width: 760px) {
68
+ .layout { grid-template-columns: minmax(0, 1fr); }
69
+ nav { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); padding: 10px 12px; }
70
+ .nav-title { width: 100%; margin: 6px 4px 0; }
71
+ .brand { width: 100%; }
72
+ }
73
+ @media (prefers-reduced-motion: reduce) { *, *::before { animation: none !important; transition: none !important; } }
@@ -0,0 +1,405 @@
1
+ // The Dev Guru web interface. No build step and no dependencies. Text from the server only ever reaches the page through
2
+ // textContent, so a hostile file name or message stays text. All domain decisions are Ananke's; this only shows them.
3
+ "use strict";
4
+
5
+ const ACTIVE = new Set(["queued", "running"]);
6
+ const POLL_MS = 1000;
7
+ const MAX_BACKOFF_MS = 8000;
8
+ const RUN_ID = /^run-[0-9a-f]+$/; // the only form the gateway accepts
9
+ const LINK = /^(#\/|\/api\/v1\/)/; // pages here and gateway routes, so no value can become a javascript: link
10
+ const state = { csrf: null, csrfHeader: null, workspaces: [], workspace: null, stop: null, graphKey: null, shown: 0 };
11
+
12
+ function el(tag, props, ...children) {
13
+ const node = document.createElement(tag);
14
+ for (const [key, value] of Object.entries(props || {})) {
15
+ if (key === "text") node.textContent = value;
16
+ else if (key === "href" && !LINK.test(value)) throw new TypeError("not a link this interface makes");
17
+ else if (key === "on") for (const [name, fn] of Object.entries(value)) node.addEventListener(name, fn);
18
+ else if (value !== undefined && value !== null && value !== false) node.setAttribute(key, value === true ? "" : value);
19
+ }
20
+ for (const child of children) if (child !== null && child !== undefined) node.append(child);
21
+ return node;
22
+ }
23
+
24
+ class ApiError extends Error {
25
+ constructor(status, code, message, retryable) {
26
+ super(message);
27
+ Object.assign(this, { status, code, retryable });
28
+ }
29
+ }
30
+
31
+ async function api(path, body) {
32
+ const options = { credentials: "same-origin" };
33
+ if (body !== undefined) {
34
+ options.method = "POST";
35
+ options.headers = { "Content-Type": "application/json", [state.csrfHeader]: state.csrf };
36
+ options.body = JSON.stringify({ workspace: state.workspace, ...body });
37
+ }
38
+ let response;
39
+ try {
40
+ response = await fetch(path, options);
41
+ } catch {
42
+ throw new ApiError(0, "UNREACHABLE", "Can't reach the gateway.", true);
43
+ }
44
+ let data = null;
45
+ try {
46
+ data = await response.json();
47
+ } catch {
48
+ throw new ApiError(response.status, "BAD_RESPONSE", "The gateway sent an unreadable response.", false);
49
+ }
50
+ if (response.ok) return data;
51
+ const error = data.error || {};
52
+ if (response.status === 401) banner("Your session has ended. Run dev serve again and open the new link.", true);
53
+ throw new ApiError(response.status, error.code || "ERROR", error.message || "The request failed.", !!error.retryable);
54
+ }
55
+
56
+ const scoped = (path, query = {}) => {
57
+ const params = new URLSearchParams({ workspace: state.workspace, ...query });
58
+ return `${path}?${params}`;
59
+ };
60
+
61
+ function banner(text, bad = false) {
62
+ const node = document.querySelector("#banner");
63
+ node.hidden = !text;
64
+ node.textContent = text || "";
65
+ node.classList.toggle("bad", bad);
66
+ }
67
+
68
+ function showError(main, error, ...actions) {
69
+ main.append(el("div", { class: "card warning", role: "alert" },
70
+ el("strong", { text: error.code }), el("p", { text: error.message }), ...actions));
71
+ }
72
+
73
+ function page(title, lede) {
74
+ // A new section each time: whatever an earlier page is still waiting for lands in one that's gone.
75
+ const section = el("section", {}, el("h1", { text: title }));
76
+ if (lede) section.append(el("p", { class: "lede", text: lede }));
77
+ document.querySelector("#main").replaceChildren(section);
78
+ document.title = `${title} · Dev Guru`;
79
+ return section;
80
+ }
81
+
82
+ const statePill = (value) => el("span", { class: `pill ${value}`, text: value });
83
+
84
+ function when(iso) {
85
+ const date = new Date(iso);
86
+ return Number.isNaN(date.getTime()) ? String(iso || "") : date.toLocaleString();
87
+ }
88
+
89
+ function warnings(run) {
90
+ return (run.warnings || []).map((w) => el("div", { class: "card warning" },
91
+ el("strong", { text: w.code }), el("p", { text: w.message })));
92
+ }
93
+
94
+ // ------------------------------------------------------------------ overview
95
+ async function overview() {
96
+ const main = page("Overview", "What's connected. Ananke does the work; Dev Guru shows it to you.");
97
+ try {
98
+ const [health, caps, runs] = await Promise.all([
99
+ api("/api/v1/health"), api(scoped("/api/v1/capabilities")), api(scoped("/api/v1/runs")),
100
+ ]);
101
+ main.append(el("div", { class: "cards" },
102
+ el("div", { class: "card" }, el("span", { class: "stat", text: health.harness.ananke_version || "none" }), "Ananke Plexus"),
103
+ el("div", { class: "card" }, el("span", { class: "stat", text: health.gateway }), "Dev Guru"),
104
+ el("div", { class: "card" }, el("span", { class: "stat", text: String(runs.total) }), "Runs in this workspace")));
105
+ main.append(el("h2", { text: "What the harness can run" }));
106
+ main.append(el("div", { class: "row" }, ...caps.run_kinds.map((k) => el("code", { class: "pill", text: k }))));
107
+ main.append(el("div", { class: "row" }, el("a", { href: "#/runs", text: "Go to runs" }), el("a", { href: "#/code", text: "Explore the code" })));
108
+ } catch (error) {
109
+ showError(main, error);
110
+ }
111
+ }
112
+
113
+ // ------------------------------------------------------------------ runs
114
+ async function startGraphRun(after) {
115
+ // Trying again after a lost connection repeats the same request, in case the first one did arrive.
116
+ state.graphKey = state.graphKey || crypto.randomUUID();
117
+ let run;
118
+ try {
119
+ run = await api("/api/v1/runs", { kind: "graph.build", parameters: {}, idempotency_key: state.graphKey });
120
+ } catch (error) {
121
+ if (error.status !== 0) state.graphKey = null;
122
+ throw error;
123
+ }
124
+ state.graphKey = null;
125
+ after(run);
126
+ }
127
+
128
+ async function runs() {
129
+ const main = page("Runs", "Runs live in Ananke, so they survive a reload or a closed tab.");
130
+ const start = el("button", { type: "button", id: "start-graph", text: "Build the code graph" });
131
+ const slot = el("div");
132
+ start.addEventListener("click", async () => {
133
+ start.disabled = true;
134
+ slot.replaceChildren();
135
+ try {
136
+ await startGraphRun((run) => { location.hash = `#/runs/${run.run_id}`; });
137
+ } catch (error) {
138
+ start.disabled = false;
139
+ const retry = error.code === "STALE_PLAN" ? el("button", { type: "button", class: "ghost", text: "Rescan and try again", on: { click: () => start.click() } }) : null;
140
+ showError(slot, error, retry);
141
+ }
142
+ });
143
+ main.append(el("div", { class: "row" }, start), slot);
144
+ try {
145
+ const history = await api(scoped("/api/v1/runs"));
146
+ if (!history.runs.length) {
147
+ main.append(el("p", { class: "muted", text: "No runs in this workspace yet." }));
148
+ return;
149
+ }
150
+ if (history.total > history.runs.length) main.append(el("p", { class: "muted", text: `The latest ${history.runs.length} of ${history.total} runs.` }));
151
+ const body = el("tbody");
152
+ for (const run of history.runs) {
153
+ body.append(el("tr", {},
154
+ el("td", {}, el("a", { href: `#/runs/${run.run_id}`, class: "mono", text: run.run_id })),
155
+ el("td", { text: run.kind }), el("td", {}, statePill(run.state)), el("td", { text: when(run.created_at) })));
156
+ }
157
+ main.append(el("div", { class: "wrap" }, el("table", {},
158
+ el("thead", {}, el("tr", {}, ...["Run", "Kind", "State", "Started"].map((h) => el("th", { scope: "col", text: h })))), body)));
159
+ } catch (error) {
160
+ showError(main, error);
161
+ }
162
+ }
163
+
164
+ async function runDetail(runId, loaded) {
165
+ const main = page("Run", "");
166
+ main.append(el("p", { class: "mono", id: "run-id", text: runId }));
167
+ const head = el("div", { class: "row", id: "run-state" });
168
+ const notes = el("div", { id: "run-warnings" });
169
+ const timeline = el("ol", { class: "events", id: "events", "aria-label": "Run events" });
170
+ const files = el("div", { id: "artifacts" });
171
+ const cancel = el("button", { type: "button", class: "ghost", id: "cancel", text: "Cancel run", hidden: true });
172
+ main.append(head, el("div", { class: "row" }, cancel), notes, el("h2", { text: "Events" }), timeline, el("h2", { text: "Artifacts" }), files,
173
+ el("p", {}, el("a", { href: "#/runs", text: "All runs" })));
174
+
175
+ let stopped = false;
176
+ state.stop = () => { stopped = true; };
177
+ let after = 0;
178
+ let wait = POLL_MS;
179
+ let lastEvent = null;
180
+ // Ananke records a run's final state just before its final event, so a read can land between the two.
181
+ let finalChecks = 3;
182
+
183
+ cancel.addEventListener("click", async () => {
184
+ cancel.disabled = true;
185
+ try {
186
+ await api(`/api/v1/runs/${runId}/cancel`, {});
187
+ } catch (error) {
188
+ cancel.disabled = false;
189
+ banner(`${error.code}: ${error.message}`, true);
190
+ }
191
+ });
192
+
193
+ const addEvent = (event) => {
194
+ const text = event.payload && event.payload.step ? `${event.type}: ${event.payload.step}` : event.type;
195
+ timeline.append(el("li", {}, el("span", { text }), " ", el("span", { class: "muted", text: when(event.at) })));
196
+ };
197
+
198
+ async function refresh() {
199
+ const run = await api(scoped(`/api/v1/runs/${runId}`));
200
+ head.replaceChildren(statePill(run.state), el("span", { text: run.kind }),
201
+ run.complete ? el("span", { class: "muted", text: "Evidence complete" }) : null);
202
+ cancel.hidden = !ACTIVE.has(run.state);
203
+ notes.replaceChildren(...warnings(run));
204
+ const seen = await api(scoped(`/api/v1/runs/${runId}/events`, { after }));
205
+ if (seen.gap) timeline.append(el("li", { class: "muted", text: `Events ${seen.gap.from} to ${seen.gap.to} are no longer kept.` }));
206
+ for (const event of seen.events) { addEvent(event); after = event.seq; lastEvent = event.type; }
207
+ const listed = await api(scoped(`/api/v1/runs/${runId}/artifacts`));
208
+ files.replaceChildren(listed.artifacts.length
209
+ ? el("ul", {}, ...listed.artifacts.map((a) => el("li", {},
210
+ el("a", { href: scoped(`/api/v1/runs/${runId}/artifacts/${encodeURIComponent(a.name)}`), download: a.name, text: a.name }),
211
+ el("span", { class: "muted", text: ` ${a.size} bytes · ${a.producer}` }))))
212
+ : el("p", { class: "muted", text: ACTIVE.has(run.state) ? "None yet." : "This run produced none." }));
213
+ return run;
214
+ }
215
+
216
+ while (!stopped) {
217
+ try {
218
+ const run = await refresh();
219
+ if (stopped) return;
220
+ banner("");
221
+ wait = POLL_MS;
222
+ if (!ACTIVE.has(run.state) && (lastEvent === `run.${run.state}` || --finalChecks <= 0)) return;
223
+ } catch (error) {
224
+ if (stopped) return;
225
+ if (!error.retryable) {
226
+ showError(main, error);
227
+ return;
228
+ }
229
+ banner(`Connection lost. The run carries on in Ananke. Trying again in ${Math.round(wait / 1000)}s.`);
230
+ wait = Math.min(wait * 2, MAX_BACKOFF_MS);
231
+ } finally {
232
+ loaded();
233
+ }
234
+ await new Promise((resolve) => setTimeout(resolve, wait));
235
+ }
236
+ }
237
+
238
+ // ------------------------------------------------------------------ code
239
+ async function code(query) {
240
+ const main = page("Code", "A view of Ananke's code graph, kept within a node budget. Nothing here changes your files.");
241
+ const collapse = query.get("collapse") || "directory";
242
+ const path = query.get("path") || "";
243
+ const go = (changes) => {
244
+ const next = new URLSearchParams({ collapse, ...(path ? { path } : {}), ...changes });
245
+ if (!next.get("path")) next.delete("path");
246
+ location.hash = `#/code?${next}`;
247
+ };
248
+ const select = el("select", { id: "collapse", on: { change: (e) => go({ collapse: e.target.value }) } },
249
+ ...[["directory", "Folders"], ["file", "Files"], ["none", "Every symbol"]].map(([value, text]) =>
250
+ el("option", { value, text, selected: value === collapse })));
251
+ main.append(el("div", { class: "row" }, el("label", { class: "field" }, "Group by", select),
252
+ path ? el("button", { type: "button", class: "ghost", text: `Showing ${path}: show everything`, on: { click: () => go({ path: "" }) } }) : null));
253
+ try {
254
+ const view = await api(scoped("/api/v1/code", { collapse, budget: 200, ...(path ? { path } : {}) }));
255
+ const a = view.edge_accounting;
256
+ main.append(el("p", { class: "muted", id: "accounting",
257
+ text: `${view.nodes.length} of ${view.total_nodes} shown. Links: ${a.shown} shown, ${a.inside_a_group} inside a group, ${a.to_nodes_not_shown} to items not shown, ${a.external_imports} third-party imports.` }));
258
+ if (view.truncated) main.append(el("p", { class: "muted", text: "Over budget: the most connected items are shown. Narrow it down by opening a folder." }));
259
+ const body = el("tbody");
260
+ for (const node of view.nodes) {
261
+ const openable = node.kind === "directory" || node.kind === "file";
262
+ const name = openable && collapse !== "none"
263
+ ? el("button", { type: "button", class: "ghost", text: node.path || node.name, on: { click: () => go(node.kind === "directory" ? { collapse: "file", path: node.path === "." ? "" : `${node.path}/` } : { collapse: "none", path: node.path }) } })
264
+ : el("span", { text: node.name });
265
+ body.append(el("tr", {}, el("td", {}, name), el("td", { text: node.kind }),
266
+ el("td", { class: "mono", text: node.members !== undefined ? `${node.members} items` : (node.source_location || "") })));
267
+ }
268
+ main.append(el("div", { class: "wrap" }, el("table", { id: "code-table" },
269
+ el("thead", {}, el("tr", {}, ...["Name", "Kind", "Where"].map((h) => el("th", { scope: "col", text: h })))), body)));
270
+ main.append(el("h2", { text: "Links" }));
271
+ const links = el("tbody");
272
+ for (const edge of view.edges.slice(0, 200)) {
273
+ const kinds = edge.kinds ? Object.entries(edge.kinds).map(([k, n]) => `${k} ${n}`).join(", ") : edge.kind;
274
+ const inferred = edge.origins ? edge.origins.inferred > 0 : edge.origin === "inferred";
275
+ links.append(el("tr", {}, el("td", { class: "mono", text: edge.source }), el("td", { class: "mono", text: edge.target }),
276
+ el("td", { text: inferred ? `${kinds} (includes inferred)` : kinds })));
277
+ }
278
+ main.append(view.edges.length ? el("div", { class: "wrap" }, el("table", { id: "link-table" },
279
+ el("thead", {}, el("tr", {}, ...["From", "To", "How"].map((h) => el("th", { scope: "col", text: h })))), links))
280
+ : el("p", { class: "muted", text: "No links between these items." }));
281
+ if (view.edges.length > 200) main.append(el("p", { class: "muted", text: `The first 200 of ${view.edges.length} links.` }));
282
+ main.append(el("p", { class: "muted mono", text: `Source ${view.source_snapshot}` }));
283
+ } catch (error) {
284
+ const build = error.code === "GRAPH_MISSING"
285
+ ? el("button", { type: "button", id: "build-graph", text: "Build the code graph", on: { click: () => startGraphRun((run) => { location.hash = `#/runs/${run.run_id}`; }).catch((e) => banner(`${e.code}: ${e.message}`, true)) } })
286
+ : null;
287
+ showError(main, error, build);
288
+ }
289
+ }
290
+
291
+ // ------------------------------------------------------------------ capabilities
292
+ async function sources(query) {
293
+ const main = page("Capabilities", "What your configured sources offer. Being listed doesn't mean a capability is trusted or will run.");
294
+ const words = query.get("q") || "";
295
+ const input = el("input", { type: "search", id: "search", value: words, "aria-label": "Search capabilities", placeholder: "Search" });
296
+ const form = el("form", { class: "row", on: { submit: (e) => { e.preventDefault(); location.hash = `#/sources?${new URLSearchParams({ q: input.value })}`; } } },
297
+ input, el("button", { type: "submit", class: "ghost", text: "Search" }));
298
+ main.append(form);
299
+ try {
300
+ const found = await api(scoped("/api/v1/sources", { q: words }));
301
+ if (!found.capabilities.length) {
302
+ main.append(el("p", { class: "muted", text: words ? "Nothing matches." : "No capabilities from this workspace's sources." }));
303
+ return;
304
+ }
305
+ const body = el("tbody");
306
+ for (const c of found.capabilities) {
307
+ body.append(el("tr", {}, el("td", { class: "mono", text: c.identity }), el("td", { text: c.kind }),
308
+ el("td", { text: c.version }), el("td", { text: c.licence }), el("td", { text: c.summary })));
309
+ }
310
+ main.append(el("div", { class: "wrap" }, el("table", {},
311
+ el("thead", {}, el("tr", {}, ...["Identity", "Kind", "Version", "Licence", "About"].map((h) => el("th", { scope: "col", text: h })))), body)));
312
+ } catch (error) {
313
+ showError(main, error);
314
+ }
315
+ }
316
+
317
+ // ------------------------------------------------------------------ not here yet: say so, and say what works today
318
+ const UNSUPPORTED = {
319
+ workflows: ["Workflows", "Assembling workflows in the browser isn't in this version.", ["ananke app workflow-preview DRAFT", "ananke app workflow-run DRAFT --backend B --plan-hash H --key K"]],
320
+ quality: ["Quality", "Quality views aren't in this version.", ["dev explore ."]],
321
+ docs: ["Documentation", "The documentation studio isn't in this version.", ["dev present SLIDES.md", "dev diagram DOCUMENT.md"]],
322
+ learning: ["Learning review", "Reviewing learning candidates in the browser isn't in this version.", ["dev learning review NAME"]],
323
+ };
324
+
325
+ function unsupported([title, text, commands]) {
326
+ const main = page(title, text);
327
+ main.append(el("h2", { text: "What works today" }), el("ul", {}, ...commands.map((c) => el("li", {}, el("code", { text: c })))));
328
+ }
329
+
330
+ // ------------------------------------------------------------------ shell
331
+ async function route(moveFocus = true) {
332
+ if (state.stop) state.stop();
333
+ state.stop = null;
334
+ banner("");
335
+ const [path, search] = location.hash.replace(/^#\/?/, "").split("?");
336
+ const query = new URLSearchParams(search || "");
337
+ const [section, id] = path.split("/");
338
+ for (const link of document.querySelectorAll("nav a")) {
339
+ if (link.dataset.route === section) link.setAttribute("aria-current", "page");
340
+ else link.removeAttribute("aria-current");
341
+ }
342
+ const main = document.querySelector("#main");
343
+ const shown = ++state.shown;
344
+ // busy until this page has its data; a later page takes over the mark
345
+ const loaded = () => { if (shown === state.shown) main.removeAttribute("aria-busy"); };
346
+ main.setAttribute("aria-busy", "true");
347
+ if (section === "runs" && id) {
348
+ if (!RUN_ID.test(id)) { page("Not found", "That isn't a run's address."); loaded(); if (moveFocus) main.focus(); return; }
349
+ const detail = runDetail(id, loaded);
350
+ if (moveFocus) main.focus();
351
+ await detail;
352
+ return;
353
+ }
354
+ if (section === "") await overview();
355
+ else if (section === "runs") await runs();
356
+ else if (section === "code") await code(query);
357
+ else if (section === "sources") await sources(query);
358
+ else if (Object.hasOwn(UNSUPPORTED, section)) unsupported(UNSUPPORTED[section]);
359
+ else page("Not found", "There's no such page.");
360
+ loaded();
361
+ if (moveFocus && shown === state.shown) main.focus(); // not on first load: the keyboard starts at the top, with the skip link
362
+ }
363
+
364
+ function setTheme(theme) {
365
+ document.documentElement.dataset.theme = theme;
366
+ try { localStorage.setItem("dev-guru-theme", theme); } catch { /* the choice just won't be remembered */ }
367
+ }
368
+
369
+ async function init() {
370
+ let saved = null;
371
+ try { saved = localStorage.getItem("dev-guru-theme"); } catch { /* not available here */ }
372
+ setTheme(saved || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
373
+ document.querySelector("#theme").addEventListener("click", () => setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
374
+ // The address holds the page, so the skip link moves focus instead of changing it.
375
+ document.querySelector(".skip").addEventListener("click", (event) => { event.preventDefault(); document.querySelector("#main").focus(); });
376
+ try {
377
+ const session = await api("/api/v1/session");
378
+ state.csrf = session.csrf_token;
379
+ state.csrfHeader = session.csrf_header;
380
+ state.workspaces = (await api("/api/v1/workspaces")).workspaces;
381
+ const health = await api("/api/v1/health");
382
+ const pill = document.querySelector("#harness");
383
+ pill.textContent = health.ok ? `Ananke ${health.harness.ananke_version}` : "Ananke isn't compatible";
384
+ pill.classList.add(health.ok ? "ok" : "bad");
385
+ } catch (error) {
386
+ showError(page("Dev Guru", ""), error);
387
+ return;
388
+ }
389
+ const picker = document.querySelector("#workspace");
390
+ for (const w of state.workspaces) picker.append(el("option", { value: w.id, title: w.path, text: w.name }));
391
+ let remembered = null;
392
+ try { remembered = sessionStorage.getItem("dev-guru-workspace"); } catch { /* not available here */ }
393
+ state.workspace = state.workspaces.some((w) => w.id === remembered) ? remembered : state.workspaces[0].id;
394
+ picker.value = state.workspace;
395
+ picker.addEventListener("change", () => {
396
+ state.workspace = picker.value;
397
+ try { sessionStorage.setItem("dev-guru-workspace", state.workspace); } catch { /* not available here */ }
398
+ if (location.hash.startsWith("#/runs/")) location.hash = "#/runs";
399
+ else route();
400
+ });
401
+ addEventListener("hashchange", () => route());
402
+ route(false);
403
+ }
404
+
405
+ init();
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="f" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#ffd27a"/><stop offset="1" stop-color="#ff6b3d"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="#120d16"/><path d="M16 5c1 5 7 8 7 14a7 7 0 0 1-14 0c0-3 2-5 3-7 1 2 2 3 3 3 1-3 0-7 1-10z" fill="url(#f)"/></svg>
@@ -0,0 +1,37 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Dev Guru</title>
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
8
+ <link rel="stylesheet" href="/app.css">
9
+ <script src="/app.js" defer></script>
10
+ </head>
11
+ <body>
12
+ <a class="skip" href="#main">Skip to content</a>
13
+ <header class="top">
14
+ <a class="brand" href="#/"><span class="flame" aria-hidden="true"></span>Dev Guru</a>
15
+ <label class="field">Workspace <select id="workspace"></select></label>
16
+ <span id="harness" class="pill" role="status"></span>
17
+ <button id="theme" class="ghost" type="button">Theme</button>
18
+ </header>
19
+ <div id="banner" class="banner" role="status" aria-live="polite" hidden></div>
20
+ <div class="layout">
21
+ <nav aria-label="Main">
22
+ <p class="nav-title">Available</p>
23
+ <a href="#/" data-route="">Overview</a>
24
+ <a href="#/runs" data-route="runs">Runs</a>
25
+ <a href="#/code" data-route="code">Code</a>
26
+ <a href="#/sources" data-route="sources">Capabilities</a>
27
+ <p class="nav-title">Not in this version</p>
28
+ <a href="#/workflows" data-route="workflows">Workflows</a>
29
+ <a href="#/quality" data-route="quality">Quality</a>
30
+ <a href="#/docs" data-route="docs">Documentation</a>
31
+ <a href="#/learning" data-route="learning">Learning review</a>
32
+ </nav>
33
+ <main id="main" tabindex="-1"></main>
34
+ </div>
35
+ <noscript><p>This interface needs JavaScript. The <code>dev</code> commands work without it.</p></noscript>
36
+ </body>
37
+ </html>
@@ -21,6 +21,9 @@ import os
21
21
  import re
22
22
  import subprocess
23
23
  from datetime import UTC, datetime
24
+
25
+ # False positive: this package needs Python 3.11 or newer (requires-python), where importlib.resources always exists.
26
+ # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2
24
27
  from importlib import resources
25
28
  from pathlib import Path
26
29
  from typing import Any
@@ -1,3 +1,3 @@
1
1
  """The package version, in a module that imports nothing (so any module can use it without an import cycle)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import http.client
6
6
  import json
7
+ import re
8
+ import socket
9
+ import struct
7
10
  import subprocess
8
11
  import threading
9
12
  import time
@@ -421,3 +424,224 @@ def test_an_unexpected_error_tells_the_browser_nothing_internal(
421
424
  monkeypatch.setattr(browser.g.client, "get_run", boom)
422
425
  status, _, data = browser.raw("GET", f"/api/v1/runs/run-1?workspace={browser.workspace}")
423
426
  assert status == 500 and b"secret" not in data and b"INTERNAL_ERROR" in data
427
+
428
+
429
+ def test_a_browser_dropping_a_connection_prints_nothing(
430
+ gateway: Gateway, capfd: pytest.CaptureFixture[str]
431
+ ) -> None:
432
+ conn = socket.create_connection((HOST, gateway.port))
433
+ conn.setsockopt(
434
+ socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)
435
+ ) # close with a reset
436
+ conn.send(b"GET ")
437
+ conn.close()
438
+ time.sleep(0.3)
439
+ assert "Traceback" not in capfd.readouterr().err
440
+
441
+
442
+ @pytest.mark.parametrize(
443
+ ("path", "media"),
444
+ [
445
+ ("/", "text/html"),
446
+ ("/app.js", "text/javascript"),
447
+ ("/app.css", "text/css"),
448
+ ("/favicon.svg", "image/svg+xml"),
449
+ ],
450
+ )
451
+ def test_the_interface_files_need_a_session_and_have_the_right_type(
452
+ browser: Browser, gateway: Gateway, path: str, media: str
453
+ ) -> None:
454
+ assert Browser(gateway).raw("GET", path)[0] == 401
455
+ status, headers, body = browser.raw("GET", path)
456
+ assert status == 200 and headers["content-type"].startswith(media) and body
457
+
458
+
459
+ @pytest.mark.parametrize(
460
+ "path", ["/index.html", "/static/app.js", "/../server.py", "/app.js/../../server.py"]
461
+ )
462
+ def test_only_listed_files_are_served(browser: Browser, path: str) -> None:
463
+ assert browser.raw("GET", path)[0] == 404
464
+
465
+
466
+ def test_the_page_has_no_inline_code_for_the_content_policy_to_block(browser: Browser) -> None:
467
+ _, _, page = browser.raw("GET", "/")
468
+ text = page.decode()
469
+ assert "<style" not in text and " onclick" not in text
470
+ assert all('src="' in tag for tag in text.split("<script")[1:])
471
+
472
+
473
+ # ------------------------------------------------------------------ every route, not just the first ones written
474
+ GET_ROUTES = [
475
+ "/",
476
+ "/app.js",
477
+ "/app.css",
478
+ "/favicon.svg",
479
+ "/api/v1/session",
480
+ "/api/v1/health",
481
+ "/api/v1/workspaces",
482
+ "/api/v1/capabilities?workspace=W",
483
+ "/api/v1/sources?workspace=W",
484
+ "/api/v1/code?workspace=W",
485
+ "/api/v1/runs?workspace=W",
486
+ "/api/v1/runs/run-0123?workspace=W",
487
+ "/api/v1/runs/run-0123/events?workspace=W",
488
+ "/api/v1/runs/run-0123/artifacts?workspace=W",
489
+ "/api/v1/runs/run-0123/artifacts/graph-snapshot.json?workspace=W",
490
+ ]
491
+ POST_ROUTES = ["/api/v1/plans/prepare", "/api/v1/runs", "/api/v1/runs/run-0123/cancel"]
492
+
493
+
494
+ @pytest.mark.parametrize("path", GET_ROUTES)
495
+ def test_every_read_needs_a_session_and_refuses_other_sites(
496
+ browser: Browser, gateway: Gateway, path: str
497
+ ) -> None:
498
+ target = path.replace("W", browser.workspace)
499
+ assert Browser(gateway).raw("GET", target)[0] == 401
500
+ for headers in ({"Origin": "https://evil.example"}, {"Sec-Fetch-Site": "cross-site"}):
501
+ status, sent, data = browser.raw("GET", target, headers=headers)
502
+ assert status == 403 and json.loads(data)["error"]["code"] == "ORIGIN_REJECTED"
503
+ assert "access-control-allow-origin" not in sent
504
+
505
+
506
+ @pytest.mark.parametrize("path", POST_ROUTES)
507
+ def test_every_change_needs_session_origin_csrf_and_json(
508
+ browser: Browser, gateway: Gateway, path: str
509
+ ) -> None:
510
+ body = json.dumps({"workspace": browser.workspace}).encode()
511
+ good = {"Origin": browser.origin, "Content-Type": "application/json", CSRF_HEADER: gateway.csrf}
512
+ for change, expected in (
513
+ ({"Origin": ""}, "ORIGIN_REJECTED"),
514
+ ({"Origin": "https://evil.example"}, "ORIGIN_REJECTED"),
515
+ ({"Origin": "null"}, "ORIGIN_REJECTED"),
516
+ ({"Sec-Fetch-Site": "same-site"}, "ORIGIN_REJECTED"),
517
+ ({CSRF_HEADER: ""}, "CSRF_REJECTED"),
518
+ ({CSRF_HEADER: "guess"}, "CSRF_REJECTED"),
519
+ ({"Content-Type": "text/plain"}, "UNSUPPORTED_MEDIA_TYPE"),
520
+ ):
521
+ status, _, data = browser.raw("POST", path, headers={**good, **change}, body=body)
522
+ assert status >= 400 and json.loads(data)["error"]["code"] == expected, change
523
+ assert Browser(gateway).raw("POST", path, headers=good, body=body)[0] == 401
524
+ assert not (browser.g.roots[browser.workspace] / ".ananke" / "app" / "runs").exists()
525
+
526
+
527
+ @pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "TRACE"])
528
+ def test_other_methods_are_refused_with_no_cors(browser: Browser, method: str) -> None:
529
+ status, headers, _ = browser.raw(method, "/api/v1/runs", headers={"Origin": browser.origin})
530
+ assert status == 405 and "access-control-allow-origin" not in headers
531
+
532
+
533
+ @pytest.mark.parametrize(
534
+ ("change", "status", "expected"),
535
+ [
536
+ ({"Cookie": "dev_guru_session_PORT=caf\xe9"}, 401, "NOT_AUTHENTICATED"),
537
+ ({CSRF_HEADER: "caf\xe9"}, 403, "CSRF_REJECTED"),
538
+ ],
539
+ )
540
+ def test_text_that_isnt_ascii_is_a_refusal_not_a_crash(
541
+ browser: Browser, gateway: Gateway, change: dict[str, str], status: int, expected: str
542
+ ) -> None:
543
+ sent = {
544
+ "Origin": browser.origin,
545
+ "Content-Type": "application/json",
546
+ CSRF_HEADER: gateway.csrf,
547
+ "Cookie": browser.cookie,
548
+ **{k: v.replace("PORT", str(gateway.port)) for k, v in change.items()},
549
+ }
550
+ body = json.dumps({"workspace": browser.workspace}).encode()
551
+ got, _, data = browser.raw("POST", "/api/v1/runs", headers=sent, body=body)
552
+ assert got == status and json.loads(data)["error"]["code"] == expected
553
+
554
+
555
+ def test_a_login_token_that_isnt_ascii_is_a_refusal_not_a_crash(gateway: Gateway) -> None:
556
+ status, _, data = Browser(gateway).raw("GET", "/login?token=caf%C3%A9")
557
+ assert status == 401 and json.loads(data)["error"]["code"] == "NOT_AUTHENTICATED"
558
+
559
+
560
+ def test_the_content_policy_is_strict_on_everything_served(
561
+ browser: Browser, gateway: Gateway
562
+ ) -> None:
563
+ answers = [browser.raw("GET", path.replace("W", browser.workspace)) for path in GET_ROUTES[:9]]
564
+ answers.append(Browser(gateway).raw("GET", "/")) # a refusal
565
+ answers.append(browser.raw("GET", "/nope"))
566
+ for _, headers, _ in answers:
567
+ policy = headers["content-security-policy"]
568
+ assert "default-src 'self'" in policy and "frame-ancestors 'none'" in policy
569
+ assert "base-uri 'none'" in policy and "unsafe" not in policy and "*" not in policy
570
+ assert headers["x-content-type-options"] == "nosniff"
571
+
572
+
573
+ # ------------------------------------------------------------------ what the page does with server text
574
+ STATIC = Path(__file__).parents[1] / "src" / "dev_guru" / "gateway" / "static"
575
+
576
+
577
+ def test_server_text_has_no_way_to_become_markup() -> None:
578
+ script = (STATIC / "app.js").read_text()
579
+ for sink in (
580
+ "innerHTML",
581
+ "outerHTML",
582
+ "insertAdjacentHTML",
583
+ "document.write",
584
+ "eval(",
585
+ "new Function",
586
+ "srcdoc",
587
+ 'setAttribute("on',
588
+ ):
589
+ assert sink not in script, sink
590
+
591
+
592
+ def test_every_link_the_page_builds_starts_somewhere_fixed() -> None:
593
+ script = (STATIC / "app.js").read_text()
594
+ hrefs = re.findall(r"\bhref:\s*([^,}]+)", script)
595
+ assert len(hrefs) >= 4
596
+ for value in hrefs:
597
+ assert value.startswith(('"#/', "`#/", "scoped(`/api/v1/")), value
598
+ assert ".href =" not in script and 'setAttribute("href"' not in script
599
+ assert 'if (key === "href" && !LINK.test(value))' in script
600
+ assert "const LINK = /^(#\\/|\\/api\\/v1\\/)/;" in script
601
+
602
+
603
+ def test_the_page_loads_only_this_gateways_files() -> None:
604
+ page = (STATIC / "index.html").read_text()
605
+ assert "http://" not in page and "https://" not in page and "//" not in page.replace("<!--", "")
606
+ assert set(re.findall(r'(?:src|href)="(/[^"]*)"', page)) == {
607
+ "/favicon.svg",
608
+ "/app.css",
609
+ "/app.js",
610
+ }
611
+ for name in ("app.js", "app.css", "favicon.svg"):
612
+ text = (STATIC / name).read_text()
613
+ assert "http://" not in text.replace("http://www.w3.org/2000/svg", "")
614
+ assert "https://" not in text and "@import" not in text
615
+
616
+
617
+ # ------------------------------------------------------------------ the run console's API
618
+ def test_run_history_lists_runs_newest_first(browser: Browser) -> None:
619
+ assert browser.get("/api/v1/runs")[1] == {"total": 0, "runs": []}
620
+ _, one = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "one"})
621
+ browser.finish(one["run_id"])
622
+ _, two = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "two"})
623
+ browser.finish(two["run_id"])
624
+ status, history = browser.get("/api/v1/runs")
625
+ assert status == 200 and history["total"] == 2
626
+ assert {r["run_id"] for r in history["runs"]} == {one["run_id"], two["run_id"]}
627
+ assert history["runs"][0]["created_at"] >= history["runs"][1]["created_at"]
628
+
629
+
630
+ def test_events_dropped_from_the_record_come_back_as_a_gap_with_the_run(browser: Browser) -> None:
631
+ _, run = browser.post("/api/v1/runs", {"kind": "graph.build", "idempotency_key": "g"})
632
+ browser.finish(run["run_id"])
633
+ log = (
634
+ browser.g.roots[browser.workspace]
635
+ / ".ananke"
636
+ / "app"
637
+ / "runs"
638
+ / run["run_id"]
639
+ / "events.jsonl"
640
+ )
641
+ lines = log.read_text().splitlines()
642
+ log.write_text("\n".join(lines[2:]) + "\n")
643
+ status, seen = browser.get(f"/api/v1/runs/{run['run_id']}/events")
644
+ assert status == 200 and seen["gap"] == {"from": 1, "to": 2}
645
+ assert seen["run"]["state"] == "succeeded" and seen["events"][-1]["type"] == "run.succeeded"
646
+ last = seen["events"][-1]["seq"]
647
+ assert "gap" not in browser.get(f"/api/v1/runs/{run['run_id']}/events?after={last}")[1]
@@ -11,8 +11,8 @@ from dev_guru.client import inprocess, negotiation
11
11
  @pytest.mark.parametrize(
12
12
  ("version", "ok"),
13
13
  [
14
- ("0.3.17", True),
15
- ("0.3.16", False),
14
+ ("0.3.19", True),
15
+ ("0.3.18", False),
16
16
  ("0.3.99", True),
17
17
  ("0.3.13", False),
18
18
  ("0.4.0", False),
@@ -1,12 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <meta charset="utf-8">
4
- <meta name="viewport" content="width=device-width, initial-scale=1">
5
- <title>Dev Guru</title>
6
- <body>
7
- <main>
8
- <h1>Dev Guru</h1>
9
- <p>The local gateway is running. The web interface isn't part of this version yet; the API is at <code>/api/v1/</code>.</p>
10
- </main>
11
- </body>
12
- </html>
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