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.
- {dev_guru-0.3.0 → dev_guru-0.4.0}/CHANGELOG.md +35 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/PKG-INFO +16 -6
- {dev_guru-0.3.0 → dev_guru-0.4.0}/README.md +13 -3
- {dev_guru-0.3.0 → dev_guru-0.4.0}/pyproject.toml +2 -2
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/inprocess.py +3 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/negotiation.py +3 -1
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/port.py +2 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/gateway/server.py +32 -8
- dev_guru-0.4.0/src/dev_guru/gateway/static/app.css +73 -0
- dev_guru-0.4.0/src/dev_guru/gateway/static/app.js +405 -0
- dev_guru-0.4.0/src/dev_guru/gateway/static/favicon.svg +1 -0
- dev_guru-0.4.0/src/dev_guru/gateway/static/index.html +37 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/render.py +3 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/version.py +1 -1
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_gateway.py +224 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_negotiation.py +2 -2
- dev_guru-0.3.0/src/dev_guru/gateway/static/index.html +0 -12
- {dev_guru-0.3.0 → dev_guru-0.4.0}/.gitignore +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/LICENSE +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/__init__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/__main__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/cli.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/client/__init__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/diagram/__init__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/errors.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/gateway/__init__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/present/__init__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/__init__.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/assets/explorer.css +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/assets/explorer.html +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/assets/explorer.js +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/model.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/pages.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/reports/profiles.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/sdk.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/src/dev_guru/service.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/conftest.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_cli.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_client.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_diagram.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_facade_features.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_packaging.py +0 -0
- {dev_guru-0.3.0 → dev_guru-0.4.0}/tests/test_present.py +0 -0
- {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.
|
|
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
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
@@ -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.
|
|
15
|
-
("0.3.
|
|
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
|
|
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
|