agent2learn 0.1.2__py3-none-any.whl
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.
- agent2learn/__init__.py +3 -0
- agent2learn/_release.py +19 -0
- agent2learn/aipolicy.py +182 -0
- agent2learn/api.py +590 -0
- agent2learn/audit.py +358 -0
- agent2learn/auth/__init__.py +282 -0
- agent2learn/auth/cdp.py +1067 -0
- agent2learn/auth/paste.py +378 -0
- agent2learn/calendar.py +525 -0
- agent2learn/calibrate.py +347 -0
- agent2learn/check.py +1091 -0
- agent2learn/cli.py +2039 -0
- agent2learn/clock.py +39 -0
- agent2learn/config.py +205 -0
- agent2learn/console.py +229 -0
- agent2learn/convert.py +1223 -0
- agent2learn/doctor.py +1167 -0
- agent2learn/errors.py +32 -0
- agent2learn/ground.py +735 -0
- agent2learn/index.py +614 -0
- agent2learn/ingest.py +3229 -0
- agent2learn/locations.py +247 -0
- agent2learn/outlines.py +754 -0
- agent2learn/paths.py +683 -0
- agent2learn/pipeline.py +392 -0
- agent2learn/privacy.py +1123 -0
- agent2learn/schools/__init__.py +29 -0
- agent2learn/schools/_base.py +194 -0
- agent2learn/schools/generic.py +78 -0
- agent2learn/schools/uwaterloo.py +66 -0
- agent2learn/session.py +373 -0
- agent2learn/skills.py +1081 -0
- agent2learn/snapshot.py +399 -0
- agent2learn/submit.py +1047 -0
- agent2learn/transactions.py +157 -0
- agent2learn/upgrade.py +288 -0
- agent2learn/vault.py +1134 -0
- agent2learn-0.1.2.data/data/a2l-coursework/SKILL.md +52 -0
- agent2learn-0.1.2.data/data/a2l-setup/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-study/SKILL.md +27 -0
- agent2learn-0.1.2.data/data/a2l-sync/SKILL.md +30 -0
- agent2learn-0.1.2.dist-info/METADATA +186 -0
- agent2learn-0.1.2.dist-info/RECORD +46 -0
- agent2learn-0.1.2.dist-info/WHEEL +4 -0
- agent2learn-0.1.2.dist-info/entry_points.txt +3 -0
- agent2learn-0.1.2.dist-info/licenses/LICENSE +202 -0
agent2learn/doctor.py
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
"""Diagnostics, and a support report that is safe to paste in public.
|
|
2
|
+
|
|
3
|
+
Two audiences, two functions, deliberately not shared.
|
|
4
|
+
|
|
5
|
+
``render`` writes for the person at the keyboard. It may show their vault path, because
|
|
6
|
+
that is exactly what they need to see, and it always ends with **one** next command — a
|
|
7
|
+
diagnostic that lists six problems and no action is a diagnostic that gets ignored.
|
|
8
|
+
|
|
9
|
+
``report`` writes for a stranger reading a GitHub issue. It is built from an **allowlist**:
|
|
10
|
+
each field is named and rendered individually, and anything not named is dropped. A denylist
|
|
11
|
+
would only remove the leaks someone anticipated, and every check added later would silently
|
|
12
|
+
become a new way to leak a name, a course code, or a home directory.
|
|
13
|
+
|
|
14
|
+
``doctor`` contacts only the configured LEARN host. It performs no version check against
|
|
15
|
+
PyPI or GitHub, because a diagnostic command should not be a phone-home.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import platform
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from pathlib import Path, PurePosixPath
|
|
31
|
+
from typing import Literal, Protocol
|
|
32
|
+
from urllib.parse import urlencode
|
|
33
|
+
|
|
34
|
+
import requests
|
|
35
|
+
|
|
36
|
+
from agent2learn import __version__, console, paths
|
|
37
|
+
from agent2learn import config as config_module
|
|
38
|
+
from agent2learn import session as session_module
|
|
39
|
+
from agent2learn import skills as skills_module
|
|
40
|
+
from agent2learn.auth import cdp
|
|
41
|
+
from agent2learn.errors import A2LError, SessionExpired
|
|
42
|
+
from agent2learn.index import read_content_map
|
|
43
|
+
from agent2learn.vault import Vault
|
|
44
|
+
|
|
45
|
+
Status = Literal["ok", "warn", "fail"]
|
|
46
|
+
|
|
47
|
+
ISSUE_URL = "https://github.com/ManagementMO/agent2learn/issues/new"
|
|
48
|
+
LONG_PATH_ADVISORY = 240
|
|
49
|
+
MAX_ISSUE_URL_LENGTH = 8_000
|
|
50
|
+
|
|
51
|
+
_STATUS_GLYPH: dict[Status, str] = {"ok": "ok", "warn": "warn", "fail": "fail"}
|
|
52
|
+
_A2L_RECOVERY = re.compile(
|
|
53
|
+
r"\ba2l\s+[a-z][a-z0-9-]*(?:\s+(?!--)[a-z][a-z0-9-]*)?(?:\s+--[a-z][a-z0-9-]*)*"
|
|
54
|
+
)
|
|
55
|
+
_REGISTERED_CLI_COMMANDS = frozenset(
|
|
56
|
+
{
|
|
57
|
+
"auth",
|
|
58
|
+
"calendar",
|
|
59
|
+
"courses",
|
|
60
|
+
"diff",
|
|
61
|
+
"doctor",
|
|
62
|
+
"fetch",
|
|
63
|
+
"init",
|
|
64
|
+
"open",
|
|
65
|
+
"privacy",
|
|
66
|
+
"skills",
|
|
67
|
+
"sync",
|
|
68
|
+
"today",
|
|
69
|
+
"where",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
_GROUP_ORDER = (
|
|
73
|
+
"Environment",
|
|
74
|
+
"Filesystem",
|
|
75
|
+
"Session",
|
|
76
|
+
"Optional tools",
|
|
77
|
+
"Skills",
|
|
78
|
+
"Vault",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DoctorClient(Protocol):
|
|
83
|
+
"""The small live-session surface that diagnostics need from the API client."""
|
|
84
|
+
|
|
85
|
+
def get_json(self, path: str) -> object:
|
|
86
|
+
"""Fetch one same-origin JSON endpoint through the bounded API transport."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_COURSE_SOURCE_DIRECTORIES = frozenset(
|
|
90
|
+
{"announcements", "assignments", "content", "quizzes", "outlines"}
|
|
91
|
+
)
|
|
92
|
+
_SAFE_PUBLIC_NOTES = {"fs.vault": "vault root `~`"}
|
|
93
|
+
_PUBLIC_CHECK_NAMES = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"config.load",
|
|
96
|
+
"env.encoding",
|
|
97
|
+
"env.platform",
|
|
98
|
+
"env.python",
|
|
99
|
+
"env.unavailable",
|
|
100
|
+
"env.uv",
|
|
101
|
+
"env.version",
|
|
102
|
+
"fs.disk",
|
|
103
|
+
"fs.git",
|
|
104
|
+
"fs.long_paths",
|
|
105
|
+
"fs.longest_path",
|
|
106
|
+
"fs.unavailable",
|
|
107
|
+
"fs.vault",
|
|
108
|
+
"session.age",
|
|
109
|
+
"session.api_versions",
|
|
110
|
+
"session.backend",
|
|
111
|
+
"session.present",
|
|
112
|
+
"session.unavailable",
|
|
113
|
+
"session.whoami",
|
|
114
|
+
"skills.installed",
|
|
115
|
+
"skills.unavailable",
|
|
116
|
+
"tools.browser",
|
|
117
|
+
"tools.tesseract",
|
|
118
|
+
"tools.unavailable",
|
|
119
|
+
"vault.citable",
|
|
120
|
+
"vault.courses",
|
|
121
|
+
"vault.empty_twins",
|
|
122
|
+
"vault.gaps",
|
|
123
|
+
"vault.last_sync",
|
|
124
|
+
"vault.present",
|
|
125
|
+
"vault.terms",
|
|
126
|
+
"vault.unavailable",
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
_PUBLIC_STATUSES = frozenset({"ok", "warn", "fail"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Check:
|
|
134
|
+
"""One diagnostic result.
|
|
135
|
+
|
|
136
|
+
``detail`` and ``fix`` are written for the user's own terminal and legitimately contain
|
|
137
|
+
vault paths and course names, so ``report`` never emits them.
|
|
138
|
+
|
|
139
|
+
``public`` is a marker for a check that has something genuinely useful to contribute to a
|
|
140
|
+
support report. ``report`` still maps it through a fixed note allowlist, so a check added
|
|
141
|
+
later cannot leak arbitrary text by default.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
group: str
|
|
145
|
+
name: str
|
|
146
|
+
status: Status
|
|
147
|
+
detail: str
|
|
148
|
+
fix: str | None = None
|
|
149
|
+
public: str | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_checks(
|
|
153
|
+
cfg: config_module.Config, vault: Vault | None, *, client: DoctorClient | None = None
|
|
154
|
+
) -> list[Check]:
|
|
155
|
+
"""Collect every diagnostic. Never raises: a failed check is itself a result."""
|
|
156
|
+
checks: list[Check] = []
|
|
157
|
+
checks.extend(_safe_group("Environment", "env.unavailable", _environment))
|
|
158
|
+
checks.extend(_safe_group("Filesystem", "fs.unavailable", lambda: _filesystem(cfg, vault)))
|
|
159
|
+
checks.extend(_safe_group("Session", "session.unavailable", lambda: _session(client)))
|
|
160
|
+
checks.extend(_safe_group("Optional tools", "tools.unavailable", _optional_tools))
|
|
161
|
+
checks.extend(_safe_group("Skills", "skills.unavailable", lambda: _skills(cfg)))
|
|
162
|
+
checks.extend(_safe_group("Vault", "vault.unavailable", lambda: _vault(vault)))
|
|
163
|
+
return checks
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _safe_group(group: str, name: str, callback: Callable[[], list[Check]]) -> list[Check]:
|
|
167
|
+
"""Turn an unexpected diagnostic implementation error into a safe result."""
|
|
168
|
+
try:
|
|
169
|
+
result = callback()
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
return [
|
|
172
|
+
Check(
|
|
173
|
+
group,
|
|
174
|
+
name,
|
|
175
|
+
"fail",
|
|
176
|
+
f"{group.casefold()} check unavailable ({_exception_class(exc)})",
|
|
177
|
+
"run: a2l doctor",
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
return (
|
|
181
|
+
result
|
|
182
|
+
if isinstance(result, list)
|
|
183
|
+
else [
|
|
184
|
+
Check(group, name, "fail", f"{group.casefold()} check unavailable", "run: a2l doctor")
|
|
185
|
+
]
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ----------------------------------------------------------------------------------------
|
|
190
|
+
# Checks
|
|
191
|
+
# ----------------------------------------------------------------------------------------
|
|
192
|
+
def _environment() -> list[Check]:
|
|
193
|
+
encoding = (getattr(sys.stdout, "encoding", None) or "unknown").casefold()
|
|
194
|
+
encodable = console.GLYPH["ok"].isascii() or _can_encode(encoding)
|
|
195
|
+
return [
|
|
196
|
+
Check("Environment", "env.version", "ok", f"agent2learn {__version__}"),
|
|
197
|
+
Check("Environment", "env.python", "ok", platform.python_version()),
|
|
198
|
+
Check("Environment", "env.platform", "ok", f"{platform.system()} {platform.machine()}"),
|
|
199
|
+
Check(
|
|
200
|
+
"Environment",
|
|
201
|
+
"env.uv",
|
|
202
|
+
"ok" if shutil.which("uv") else "warn",
|
|
203
|
+
"uv found" if shutil.which("uv") else "uv not on PATH",
|
|
204
|
+
None if shutil.which("uv") else "install uv: https://docs.astral.sh/uv/",
|
|
205
|
+
),
|
|
206
|
+
Check(
|
|
207
|
+
"Environment",
|
|
208
|
+
"env.encoding",
|
|
209
|
+
"ok" if encodable else "warn",
|
|
210
|
+
f"console encoding {encoding}",
|
|
211
|
+
None if encodable else "output falls back to ASCII glyphs",
|
|
212
|
+
),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _filesystem(cfg: config_module.Config, vault: Vault | None) -> list[Check]:
|
|
217
|
+
checks: list[Check] = []
|
|
218
|
+
root = cfg.vault
|
|
219
|
+
exists = paths.long_path(root).is_dir()
|
|
220
|
+
writable = exists and os.access(os.fspath(paths.long_path(root)), os.W_OK)
|
|
221
|
+
checks.append(
|
|
222
|
+
Check(
|
|
223
|
+
"Filesystem",
|
|
224
|
+
"fs.vault",
|
|
225
|
+
"ok" if writable else ("fail" if exists else "warn"),
|
|
226
|
+
f"{root}" if exists else f"{root} does not exist yet",
|
|
227
|
+
None if writable else ("check folder permissions" if exists else "run: a2l init"),
|
|
228
|
+
# The public report deliberately emits only this fixed, home-redacted shape. The
|
|
229
|
+
# terminal detail above remains useful locally without becoming a future leak.
|
|
230
|
+
public=_SAFE_PUBLIC_NOTES["fs.vault"],
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if exists:
|
|
235
|
+
try:
|
|
236
|
+
free_gib = shutil.disk_usage(os.fspath(paths.long_path(root))).free / (1024**3)
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
checks.append(
|
|
239
|
+
Check(
|
|
240
|
+
"Filesystem",
|
|
241
|
+
"fs.disk",
|
|
242
|
+
"warn",
|
|
243
|
+
f"free space unavailable ({_exception_class(exc)})",
|
|
244
|
+
"check free disk space before the next sync",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
checks.append(
|
|
249
|
+
Check(
|
|
250
|
+
"Filesystem",
|
|
251
|
+
"fs.disk",
|
|
252
|
+
"ok" if free_gib >= 2 else "warn",
|
|
253
|
+
f"{free_gib:.1f} GiB free",
|
|
254
|
+
None if free_gib >= 2 else "free disk space before the next sync",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
checks.append(_longest_path(root))
|
|
258
|
+
|
|
259
|
+
checks.append(_long_paths_enabled())
|
|
260
|
+
if vault is not None:
|
|
261
|
+
checks.append(_git_tracking(vault))
|
|
262
|
+
return checks
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _longest_path(root: Path) -> Check:
|
|
266
|
+
longest_relative = 0
|
|
267
|
+
longest_absolute = 0
|
|
268
|
+
try:
|
|
269
|
+
for path in paths.walk(root):
|
|
270
|
+
longest_relative = max(longest_relative, len(path.relative_to(root).as_posix()))
|
|
271
|
+
longest_absolute = max(longest_absolute, len(str(path)))
|
|
272
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
273
|
+
return Check(
|
|
274
|
+
"Filesystem",
|
|
275
|
+
"fs.longest_path",
|
|
276
|
+
"warn",
|
|
277
|
+
f"longest path unavailable ({_exception_class(exc)})",
|
|
278
|
+
"check vault permissions before the next sync",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
over = longest_absolute > LONG_PATH_ADVISORY
|
|
282
|
+
return Check(
|
|
283
|
+
"Filesystem",
|
|
284
|
+
"fs.longest_path",
|
|
285
|
+
"warn" if over else "ok",
|
|
286
|
+
f"longest path {longest_absolute} absolute / {longest_relative} vault-relative",
|
|
287
|
+
# Agent2Learn itself handles long paths; the risk is other software touching the
|
|
288
|
+
# same files, so the advice is to move the vault rather than to edit the registry.
|
|
289
|
+
"some editors and sync clients struggle past 260 characters; a shorter vault root avoids it"
|
|
290
|
+
if over
|
|
291
|
+
else None,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _long_paths_enabled() -> Check:
|
|
296
|
+
"""Report the Windows long-path registry flag, informationally and never as a failure.
|
|
297
|
+
|
|
298
|
+
Agent2Learn prefixes its own syscalls with ``\\\\?\\`` and works regardless, so this is
|
|
299
|
+
context for a user debugging another tool — not something to fix on our account.
|
|
300
|
+
"""
|
|
301
|
+
if os.name != "nt":
|
|
302
|
+
return Check("Filesystem", "fs.long_paths", "ok", "not applicable on this platform")
|
|
303
|
+
# winreg exists only on Windows, so mypy on a POSIX host cannot see its attributes.
|
|
304
|
+
# The import is guarded by os.name above and every failure mode collapses to
|
|
305
|
+
# "unreadable" — this flag is informational and must never break a diagnostic run.
|
|
306
|
+
try:
|
|
307
|
+
import winreg # type: ignore[import-not-found,unused-ignore]
|
|
308
|
+
|
|
309
|
+
with winreg.OpenKey( # type: ignore[attr-defined,unused-ignore]
|
|
310
|
+
winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined,unused-ignore]
|
|
311
|
+
r"SYSTEM\CurrentControlSet\Control\FileSystem",
|
|
312
|
+
) as key:
|
|
313
|
+
value, _ = winreg.QueryValueEx(key, "LongPathsEnabled") # type: ignore[attr-defined,unused-ignore]
|
|
314
|
+
state = "enabled" if int(value) == 1 else "disabled"
|
|
315
|
+
except Exception:
|
|
316
|
+
state = "unreadable"
|
|
317
|
+
return Check(
|
|
318
|
+
"Filesystem",
|
|
319
|
+
"fs.long_paths",
|
|
320
|
+
"ok",
|
|
321
|
+
f"Windows LongPathsEnabled: {state} (informational; a2l handles long paths itself)",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _git_tracking(vault: Vault) -> Check:
|
|
326
|
+
"""Fail when private material is tracked by Git; warn when course sources are.
|
|
327
|
+
|
|
328
|
+
Ignore rules are not a privacy or copyright guarantee. A student who committed their
|
|
329
|
+
vault before adding a `.gitignore` still has the files in history, and a public push
|
|
330
|
+
would publish someone else's course material along with their own session state.
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
inside_worktree = _inside_git_worktree(vault.root)
|
|
334
|
+
if not inside_worktree:
|
|
335
|
+
return Check("Filesystem", "fs.git", "ok", "vault is not inside a Git repository")
|
|
336
|
+
tracked = _tracked_files(vault.root)
|
|
337
|
+
except (OSError, UnicodeError, RuntimeError):
|
|
338
|
+
return Check(
|
|
339
|
+
"Filesystem",
|
|
340
|
+
"fs.git",
|
|
341
|
+
"fail",
|
|
342
|
+
"Git metadata is unreadable",
|
|
343
|
+
"inspect Git metadata locally before pushing anywhere",
|
|
344
|
+
)
|
|
345
|
+
if tracked is None:
|
|
346
|
+
return Check(
|
|
347
|
+
"Filesystem",
|
|
348
|
+
"fs.git",
|
|
349
|
+
"fail",
|
|
350
|
+
"inside Git, but the file list is unreadable",
|
|
351
|
+
"inspect Git metadata locally before pushing anywhere",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
normalized = [(entry, _git_entry_parts(entry)) for entry in tracked]
|
|
355
|
+
private = sorted({entry for entry, parts in normalized if _is_private_git_entry(parts)})
|
|
356
|
+
if private:
|
|
357
|
+
return Check(
|
|
358
|
+
"Filesystem",
|
|
359
|
+
"fs.git",
|
|
360
|
+
"fail",
|
|
361
|
+
f"{len(private)} private file(s) are tracked by Git",
|
|
362
|
+
"untrack them and rewrite history before pushing anywhere",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
sources = [entry for entry, parts in normalized if _is_course_source_entry(parts)]
|
|
366
|
+
if sources:
|
|
367
|
+
return Check(
|
|
368
|
+
"Filesystem",
|
|
369
|
+
"fs.git",
|
|
370
|
+
"warn",
|
|
371
|
+
f"{len(sources)} course source file(s) are tracked by Git",
|
|
372
|
+
"course material is not yours to redistribute; keep the vault out of a pushed repo",
|
|
373
|
+
)
|
|
374
|
+
return Check("Filesystem", "fs.git", "ok", "no private or course files are tracked")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _session(client: DoctorClient | None) -> list[Check]:
|
|
378
|
+
try:
|
|
379
|
+
backend = session_module.backend_name()
|
|
380
|
+
except Exception as exc:
|
|
381
|
+
backend = "unavailable"
|
|
382
|
+
backend_detail = f"backend unavailable ({_exception_class(exc)})"
|
|
383
|
+
else:
|
|
384
|
+
backend_detail = {
|
|
385
|
+
"keyring": "OS credential store",
|
|
386
|
+
"file": "permission-restricted local file (not encrypted)",
|
|
387
|
+
}.get(backend, backend)
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
current = session_module.load()
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
return [
|
|
393
|
+
Check("Session", "session.backend", "warn", backend_detail),
|
|
394
|
+
Check(
|
|
395
|
+
"Session",
|
|
396
|
+
"session.present",
|
|
397
|
+
"fail",
|
|
398
|
+
f"stored session is unreadable ({_exception_class(exc)})",
|
|
399
|
+
"run: a2l auth",
|
|
400
|
+
),
|
|
401
|
+
*_unavailable_api_checks(),
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
if current is None:
|
|
405
|
+
return [
|
|
406
|
+
Check("Session", "session.backend", "ok", backend_detail),
|
|
407
|
+
Check("Session", "session.present", "warn", "no stored session", "run: a2l auth"),
|
|
408
|
+
*_unavailable_api_checks(),
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
hours = current.age().total_seconds() / 3600
|
|
412
|
+
stale = hours > 24
|
|
413
|
+
checks = [
|
|
414
|
+
Check("Session", "session.backend", "ok", backend_detail),
|
|
415
|
+
Check(
|
|
416
|
+
"Session",
|
|
417
|
+
"session.age",
|
|
418
|
+
"warn" if stale else "ok",
|
|
419
|
+
f"harvested {hours:.1f} hours ago",
|
|
420
|
+
"run: a2l auth" if stale else None,
|
|
421
|
+
),
|
|
422
|
+
]
|
|
423
|
+
checks.extend(_session_api_checks(client))
|
|
424
|
+
return checks
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _unavailable_api_checks() -> list[Check]:
|
|
428
|
+
return [
|
|
429
|
+
Check(
|
|
430
|
+
"Session",
|
|
431
|
+
"session.api_versions",
|
|
432
|
+
"warn",
|
|
433
|
+
"API versions not checked; no usable session",
|
|
434
|
+
"run: a2l auth",
|
|
435
|
+
),
|
|
436
|
+
Check(
|
|
437
|
+
"Session",
|
|
438
|
+
"session.whoami",
|
|
439
|
+
"warn",
|
|
440
|
+
"whoami not checked; no usable session",
|
|
441
|
+
"run: a2l auth",
|
|
442
|
+
),
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _session_api_checks(client: DoctorClient | None) -> list[Check]:
|
|
447
|
+
if client is None:
|
|
448
|
+
return _unavailable_api_checks()
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
versions = client.get_json("/d2l/api/versions/")
|
|
452
|
+
lp_version = _latest_product_version(versions, "lp")
|
|
453
|
+
except Exception as exc:
|
|
454
|
+
return [
|
|
455
|
+
Check(
|
|
456
|
+
"Session",
|
|
457
|
+
"session.api_versions",
|
|
458
|
+
"fail",
|
|
459
|
+
f"API versions failed ({_api_failure_class(exc)})",
|
|
460
|
+
"run: a2l auth",
|
|
461
|
+
),
|
|
462
|
+
Check(
|
|
463
|
+
"Session",
|
|
464
|
+
"session.whoami",
|
|
465
|
+
"fail",
|
|
466
|
+
"whoami not checked because API versions failed",
|
|
467
|
+
"run: a2l auth",
|
|
468
|
+
),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
payload = client.get_json(f"/d2l/api/lp/{lp_version}/users/whoami")
|
|
473
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("Identifier"), str):
|
|
474
|
+
raise A2LError("whoami response was invalid")
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
whoami = Check(
|
|
477
|
+
"Session",
|
|
478
|
+
"session.whoami",
|
|
479
|
+
"fail",
|
|
480
|
+
f"whoami failed ({_api_failure_class(exc)})",
|
|
481
|
+
"run: a2l auth",
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
whoami = Check("Session", "session.whoami", "ok", "whoami reachable (2xx)")
|
|
485
|
+
return [
|
|
486
|
+
Check("Session", "session.api_versions", "ok", "API versions reachable (2xx)"),
|
|
487
|
+
whoami,
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _latest_product_version(payload: object, product_code: str) -> str:
|
|
492
|
+
if not isinstance(payload, list):
|
|
493
|
+
raise A2LError("API versions response was invalid")
|
|
494
|
+
for item in payload:
|
|
495
|
+
if not isinstance(item, dict):
|
|
496
|
+
continue
|
|
497
|
+
version = item.get("LatestVersion")
|
|
498
|
+
if item.get("ProductCode") == product_code and isinstance(version, str) and version:
|
|
499
|
+
return version
|
|
500
|
+
raise A2LError("API versions response omitted the required product")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _optional_tools() -> list[Check]:
|
|
504
|
+
tesseract = shutil.which("tesseract")
|
|
505
|
+
browser = _cdp_browser()
|
|
506
|
+
return [
|
|
507
|
+
Check(
|
|
508
|
+
"Optional tools",
|
|
509
|
+
"tools.tesseract",
|
|
510
|
+
"ok" if tesseract else "warn",
|
|
511
|
+
"tesseract found" if tesseract else "tesseract not installed",
|
|
512
|
+
None if tesseract else "OCR is skipped for image-only PDFs without it",
|
|
513
|
+
),
|
|
514
|
+
Check(
|
|
515
|
+
"Optional tools",
|
|
516
|
+
"tools.browser",
|
|
517
|
+
"ok" if browser else "warn",
|
|
518
|
+
f"{browser} found" if browser else "no Chrome/Edge/Chromium found",
|
|
519
|
+
None if browser else "browser sign-in needs one; a2l auth --paste works without it",
|
|
520
|
+
),
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _skills(cfg: config_module.Config) -> list[Check]:
|
|
525
|
+
detected = skills_module.current_installations(project=cfg.vault, include_global=True)
|
|
526
|
+
destinations = tuple(
|
|
527
|
+
destination
|
|
528
|
+
for destination in detected
|
|
529
|
+
if _skill_scope(destination.path, cfg.vault) == "project"
|
|
530
|
+
or any(status != "created" for _, status in destination.skills)
|
|
531
|
+
)
|
|
532
|
+
if not destinations:
|
|
533
|
+
return [
|
|
534
|
+
Check(
|
|
535
|
+
"Skills",
|
|
536
|
+
"skills.installed",
|
|
537
|
+
"warn",
|
|
538
|
+
"no detected Agent2Learn skill destination",
|
|
539
|
+
"run: a2l skills install",
|
|
540
|
+
)
|
|
541
|
+
]
|
|
542
|
+
|
|
543
|
+
agents = {agent for destination in destinations for agent in destination.agents}
|
|
544
|
+
current = sum(
|
|
545
|
+
status == "unchanged" for destination in destinations for _, status in destination.skills
|
|
546
|
+
)
|
|
547
|
+
stale = sum(
|
|
548
|
+
status == "updated" for destination in destinations for _, status in destination.skills
|
|
549
|
+
)
|
|
550
|
+
missing = sum(
|
|
551
|
+
status == "created" for destination in destinations for _, status in destination.skills
|
|
552
|
+
)
|
|
553
|
+
conflicts = sum(
|
|
554
|
+
status == "conflict" for destination in destinations for _, status in destination.skills
|
|
555
|
+
)
|
|
556
|
+
descriptions = [
|
|
557
|
+
f"{agent} ({_skill_scope(destination.path, cfg.vault)}): "
|
|
558
|
+
f"{_skill_destination_detail(destination)}"
|
|
559
|
+
for destination in destinations
|
|
560
|
+
for agent in destination.agents
|
|
561
|
+
]
|
|
562
|
+
unhealthy = any(
|
|
563
|
+
status != "unchanged" for destination in destinations for _, status in destination.skills
|
|
564
|
+
)
|
|
565
|
+
detail = (
|
|
566
|
+
f"{len(destinations)} destination(s), {len(agents)} detected agent(s), "
|
|
567
|
+
f"{current} current skill(s), {stale} stale skill(s), {missing} missing skill(s), "
|
|
568
|
+
f"{conflicts} conflict skill(s) left alone: " + "; ".join(descriptions)
|
|
569
|
+
)
|
|
570
|
+
return [
|
|
571
|
+
Check(
|
|
572
|
+
"Skills",
|
|
573
|
+
"skills.installed",
|
|
574
|
+
"warn" if unhealthy else "ok",
|
|
575
|
+
detail,
|
|
576
|
+
"run: a2l skills install" if unhealthy else None,
|
|
577
|
+
)
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _skill_scope(destination: Path, project: Path) -> str:
|
|
582
|
+
try:
|
|
583
|
+
destination.resolve().relative_to(project.resolve())
|
|
584
|
+
except ValueError:
|
|
585
|
+
return "global"
|
|
586
|
+
return "project"
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _skill_destination_detail(destination: skills_module.DestinationResult) -> str:
|
|
590
|
+
"""Describe one skill root without disclosing its filesystem location."""
|
|
591
|
+
counts = {status: 0 for status in ("created", "updated", "unchanged", "conflict")}
|
|
592
|
+
versions: dict[str, list[str]] = {status: [] for status in ("updated", "unchanged", "conflict")}
|
|
593
|
+
unknown_versions = {status: 0 for status in versions}
|
|
594
|
+
for _, status, version in skills_module.installed_package_versions(destination):
|
|
595
|
+
counts[status] += 1
|
|
596
|
+
if status == "created":
|
|
597
|
+
continue
|
|
598
|
+
if version is None:
|
|
599
|
+
unknown_versions[status] += 1
|
|
600
|
+
else:
|
|
601
|
+
versions[status].append(version)
|
|
602
|
+
|
|
603
|
+
def version_detail(status: str) -> str:
|
|
604
|
+
known = sorted(set(versions[status]))
|
|
605
|
+
if unknown_versions[status]:
|
|
606
|
+
known.append("unknown")
|
|
607
|
+
return ", ".join(known)
|
|
608
|
+
|
|
609
|
+
parts: list[str] = []
|
|
610
|
+
for status, label in (("unchanged", "current"), ("updated", "stale")):
|
|
611
|
+
if counts[status]:
|
|
612
|
+
parts.append(f"{counts[status]} {label} skill(s), package {version_detail(status)}")
|
|
613
|
+
if counts["created"]:
|
|
614
|
+
parts.append(f"{counts['created']} missing skill(s)")
|
|
615
|
+
if counts["conflict"]:
|
|
616
|
+
parts.append(
|
|
617
|
+
f"{counts['conflict']} conflict skill(s) left alone, "
|
|
618
|
+
f"package {version_detail('conflict')}"
|
|
619
|
+
)
|
|
620
|
+
return "; ".join(parts)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _vault(vault: Vault | None) -> list[Check]:
|
|
624
|
+
if vault is None or not paths.long_path(vault.root).is_dir():
|
|
625
|
+
return [Check("Vault", "vault.present", "warn", "no vault yet", "run: a2l sync")]
|
|
626
|
+
|
|
627
|
+
courses = 0
|
|
628
|
+
topics = 0
|
|
629
|
+
citable = 0
|
|
630
|
+
gaps = 0
|
|
631
|
+
empty_twins = 0
|
|
632
|
+
unreadable_maps = 0
|
|
633
|
+
term_stats: dict[str, list[int]] = {}
|
|
634
|
+
try:
|
|
635
|
+
map_paths = sorted(
|
|
636
|
+
path for path in paths.walk(vault.root) if path.name == "content_map.json"
|
|
637
|
+
)
|
|
638
|
+
except (OSError, RuntimeError) as exc:
|
|
639
|
+
return [
|
|
640
|
+
Check("Vault", "vault.present", "fail", f"vault scan failed ({_exception_class(exc)})")
|
|
641
|
+
]
|
|
642
|
+
|
|
643
|
+
for map_path in map_paths:
|
|
644
|
+
try:
|
|
645
|
+
rows = read_content_map(map_path.parent.parent)["topics"]
|
|
646
|
+
except (A2LError, OSError, UnicodeError):
|
|
647
|
+
unreadable_maps += 1
|
|
648
|
+
continue
|
|
649
|
+
if not isinstance(rows, list):
|
|
650
|
+
unreadable_maps += 1
|
|
651
|
+
continue
|
|
652
|
+
courses += 1
|
|
653
|
+
term = map_path.parent.parent.parent.name
|
|
654
|
+
stats = term_stats.setdefault(term, [0, 0, 0, 0, 0])
|
|
655
|
+
stats[0] += 1
|
|
656
|
+
for row in rows:
|
|
657
|
+
if not isinstance(row, dict):
|
|
658
|
+
continue
|
|
659
|
+
topics += 1
|
|
660
|
+
stats[2] += 1
|
|
661
|
+
availability = str(row.get("availability", ""))
|
|
662
|
+
if availability == "markdown_ready":
|
|
663
|
+
citable += 1
|
|
664
|
+
stats[1] += 1
|
|
665
|
+
path = row.get("path")
|
|
666
|
+
if isinstance(path, str) and _is_empty_vault_file(vault, path):
|
|
667
|
+
empty_twins += 1
|
|
668
|
+
stats[4] += 1
|
|
669
|
+
elif availability in {
|
|
670
|
+
"unsupported_format",
|
|
671
|
+
"conversion_gap",
|
|
672
|
+
"integrity_gap",
|
|
673
|
+
"download_gap",
|
|
674
|
+
}:
|
|
675
|
+
gaps += 1
|
|
676
|
+
stats[3] += 1
|
|
677
|
+
|
|
678
|
+
if courses == 0:
|
|
679
|
+
if unreadable_maps:
|
|
680
|
+
present_detail = (
|
|
681
|
+
f"vault has no readable courses; {unreadable_maps} unreadable content map(s)"
|
|
682
|
+
)
|
|
683
|
+
terms_detail = (
|
|
684
|
+
f"no readable term/course coverage; {unreadable_maps} unreadable content map(s)"
|
|
685
|
+
)
|
|
686
|
+
else:
|
|
687
|
+
present_detail = "vault has no courses yet"
|
|
688
|
+
terms_detail = "no term/course coverage yet"
|
|
689
|
+
return [
|
|
690
|
+
Check("Vault", "vault.present", "warn", present_detail, "run: a2l sync"),
|
|
691
|
+
Check("Vault", "vault.terms", "warn", terms_detail, "run: a2l sync"),
|
|
692
|
+
Check("Vault", "vault.empty_twins", "ok", "0 empty markdown twin(s)"),
|
|
693
|
+
_last_sync_check(vault),
|
|
694
|
+
]
|
|
695
|
+
|
|
696
|
+
term_detail = "; ".join(
|
|
697
|
+
f"{term}: {course_count} course(s), {resolved}/{total} topic(s) resolved, "
|
|
698
|
+
f"{term_gaps} coverage gap(s), {term_empty} empty twin(s)"
|
|
699
|
+
for term, (course_count, resolved, total, term_gaps, term_empty) in sorted(
|
|
700
|
+
term_stats.items()
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
checks = [
|
|
704
|
+
Check(
|
|
705
|
+
"Vault",
|
|
706
|
+
"vault.courses",
|
|
707
|
+
"warn" if unreadable_maps else "ok",
|
|
708
|
+
f"{courses} course(s), {topics} topic(s)"
|
|
709
|
+
+ (f", {unreadable_maps} unreadable content map(s)" if unreadable_maps else ""),
|
|
710
|
+
),
|
|
711
|
+
Check(
|
|
712
|
+
"Vault",
|
|
713
|
+
"vault.terms",
|
|
714
|
+
"ok" if citable == topics else "warn",
|
|
715
|
+
term_detail,
|
|
716
|
+
None if citable == topics else "run: a2l sync",
|
|
717
|
+
),
|
|
718
|
+
Check(
|
|
719
|
+
"Vault",
|
|
720
|
+
"vault.citable",
|
|
721
|
+
"ok" if topics and citable == topics else "warn",
|
|
722
|
+
f"{citable} of {topics} topic(s) citable",
|
|
723
|
+
None if citable == topics else "run: a2l sync",
|
|
724
|
+
),
|
|
725
|
+
Check(
|
|
726
|
+
"Vault",
|
|
727
|
+
"vault.gaps",
|
|
728
|
+
"ok" if gaps == 0 else "warn",
|
|
729
|
+
f"{gaps} coverage gap(s)",
|
|
730
|
+
None if gaps == 0 else "see .a2l/AUDIT.md",
|
|
731
|
+
),
|
|
732
|
+
]
|
|
733
|
+
checks.append(
|
|
734
|
+
Check(
|
|
735
|
+
"Vault",
|
|
736
|
+
"vault.empty_twins",
|
|
737
|
+
"warn" if empty_twins else "ok",
|
|
738
|
+
f"{empty_twins} empty markdown twin(s)",
|
|
739
|
+
None if empty_twins == 0 else "run: a2l sync",
|
|
740
|
+
)
|
|
741
|
+
)
|
|
742
|
+
checks.append(_last_sync_check(vault))
|
|
743
|
+
return checks
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _is_empty_vault_file(vault: Vault, value: str) -> bool:
|
|
747
|
+
try:
|
|
748
|
+
parts = PurePosixPath(value).parts
|
|
749
|
+
if (
|
|
750
|
+
not parts
|
|
751
|
+
or "\\" in value
|
|
752
|
+
or (len(value) >= 3 and value[1] == ":" and value[2] in "/\\")
|
|
753
|
+
or PurePosixPath(value).is_absolute()
|
|
754
|
+
or any(part in {"", ".", ".."} for part in parts)
|
|
755
|
+
):
|
|
756
|
+
return False
|
|
757
|
+
candidate = (vault.root / Path(*parts)).resolve()
|
|
758
|
+
candidate.relative_to(vault.root)
|
|
759
|
+
return (
|
|
760
|
+
paths.long_path(candidate).is_file() and paths.long_path(candidate).stat().st_size == 0
|
|
761
|
+
)
|
|
762
|
+
except (OSError, RuntimeError, ValueError):
|
|
763
|
+
return False
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _last_sync_check(vault: Vault) -> Check:
|
|
767
|
+
snapshot_dir = vault.state() / "snapshots"
|
|
768
|
+
try:
|
|
769
|
+
candidates = sorted(
|
|
770
|
+
path for path in paths.walk(snapshot_dir) if path.suffix.casefold() == ".json"
|
|
771
|
+
)
|
|
772
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
773
|
+
return Check(
|
|
774
|
+
"Vault",
|
|
775
|
+
"vault.last_sync",
|
|
776
|
+
"warn",
|
|
777
|
+
f"last sync unavailable ({_exception_class(exc)})",
|
|
778
|
+
"run: a2l sync",
|
|
779
|
+
)
|
|
780
|
+
timestamps: list[datetime] = []
|
|
781
|
+
unreadable = 0
|
|
782
|
+
for candidate in candidates:
|
|
783
|
+
try:
|
|
784
|
+
with open(
|
|
785
|
+
os.fspath(paths.long_path(candidate)), encoding="utf-8", newline=""
|
|
786
|
+
) as handle:
|
|
787
|
+
raw = json.load(handle)
|
|
788
|
+
value = raw.get("created_at") if isinstance(raw, dict) else None
|
|
789
|
+
if isinstance(value, str):
|
|
790
|
+
timestamps.append(_parse_timestamp(value))
|
|
791
|
+
except (OSError, UnicodeError, ValueError, TypeError, json.JSONDecodeError):
|
|
792
|
+
unreadable += 1
|
|
793
|
+
continue
|
|
794
|
+
if not timestamps:
|
|
795
|
+
detail = "no completed sync recorded"
|
|
796
|
+
if unreadable:
|
|
797
|
+
detail += f"; {unreadable} unreadable snapshot(s)"
|
|
798
|
+
return Check("Vault", "vault.last_sync", "warn", detail, "run: a2l sync")
|
|
799
|
+
latest = max(timestamps).astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
800
|
+
detail = f"last sync {latest}"
|
|
801
|
+
if unreadable:
|
|
802
|
+
detail += f"; {unreadable} unreadable snapshot(s)"
|
|
803
|
+
return Check("Vault", "vault.last_sync", "warn" if unreadable else "ok", detail)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _parse_timestamp(value: str) -> datetime:
|
|
807
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
808
|
+
parsed = datetime.fromisoformat(normalized)
|
|
809
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
810
|
+
raise ValueError("timestamp is not timezone-aware")
|
|
811
|
+
return parsed
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
# ----------------------------------------------------------------------------------------
|
|
815
|
+
# Rendering
|
|
816
|
+
# ----------------------------------------------------------------------------------------
|
|
817
|
+
def render(checks: Sequence[Check]) -> str:
|
|
818
|
+
"""Render a grouped checklist for the user's own terminal, with one next command."""
|
|
819
|
+
lines: list[str] = []
|
|
820
|
+
for group in _ordered_groups(checks):
|
|
821
|
+
lines.append(f"{group}")
|
|
822
|
+
for check in [item for item in checks if item.group == group]:
|
|
823
|
+
glyph = console.GLYPH[_STATUS_GLYPH[check.status]]
|
|
824
|
+
lines.append(f" {glyph} {check.detail}")
|
|
825
|
+
lines.append("")
|
|
826
|
+
|
|
827
|
+
failures = [item for item in checks if item.status == "fail"]
|
|
828
|
+
warnings = [item for item in checks if item.status == "warn"]
|
|
829
|
+
if failures:
|
|
830
|
+
summary = f"{len(failures)} failure(s), {len(warnings)} warning(s)"
|
|
831
|
+
elif warnings:
|
|
832
|
+
summary = f"{len(warnings)} warning(s)"
|
|
833
|
+
else:
|
|
834
|
+
summary = "all clear"
|
|
835
|
+
lines.append(summary)
|
|
836
|
+
|
|
837
|
+
action = next_command(checks)
|
|
838
|
+
if action is not None:
|
|
839
|
+
# Fixes are stored as "run: a2l auth" so they read correctly inside a check's own
|
|
840
|
+
# line; the summary already says "Next", so the prefix would stutter here.
|
|
841
|
+
lines.append(f"Next: {action.removeprefix('run: ')}")
|
|
842
|
+
return "\n".join(lines) + "\n"
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def next_command(checks: Sequence[Check]) -> str | None:
|
|
846
|
+
"""Return the single most urgent suggested command, or ``None`` when all is well.
|
|
847
|
+
|
|
848
|
+
Exactly one. A diagnostic that emits a list of six things to do is one the user closes
|
|
849
|
+
without doing any of them, so failures outrank warnings and the first wins.
|
|
850
|
+
"""
|
|
851
|
+
for status in ("fail", "warn"):
|
|
852
|
+
for check in checks:
|
|
853
|
+
if check.status != status or not check.fix:
|
|
854
|
+
continue
|
|
855
|
+
match = _A2L_RECOVERY.search(check.fix)
|
|
856
|
+
if match is None:
|
|
857
|
+
continue
|
|
858
|
+
candidate = match.group(0)
|
|
859
|
+
words = candidate.split()
|
|
860
|
+
if len(words) >= 2 and words[1] in _REGISTERED_CLI_COMMANDS:
|
|
861
|
+
return f"run: {candidate}"
|
|
862
|
+
# Free-form repair prose belongs in the check detail, not in the command slot. Keep the output
|
|
863
|
+
# contract literal even when all fixes are manual: the fallback is a real, reversible command.
|
|
864
|
+
return "run: a2l sync"
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def exit_code(checks: Sequence[Check]) -> int:
|
|
868
|
+
"""0 all clear, 1 warnings only, 2 at least one failure."""
|
|
869
|
+
if any(check.status == "fail" for check in checks):
|
|
870
|
+
return 2
|
|
871
|
+
return 1 if any(check.status == "warn" for check in checks) else 0
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ----------------------------------------------------------------------------------------
|
|
875
|
+
# Public support report
|
|
876
|
+
# ----------------------------------------------------------------------------------------
|
|
877
|
+
def report(checks: Sequence[Check]) -> str:
|
|
878
|
+
"""Render a redacted markdown block that is safe to paste into a public issue.
|
|
879
|
+
|
|
880
|
+
Only these fields are emitted, each rendered individually rather than copied through:
|
|
881
|
+
package version, Python version, OS and architecture, install method, and — per check —
|
|
882
|
+
its stable identifier, its status, and any ``public`` note the check opted into.
|
|
883
|
+
``detail`` and ``fix`` are excluded outright because they legitimately contain vault
|
|
884
|
+
paths and course names, and ``public`` is re-redacted here rather than trusted.
|
|
885
|
+
"""
|
|
886
|
+
lines = [
|
|
887
|
+
"### Agent2Learn diagnostics",
|
|
888
|
+
"",
|
|
889
|
+
f"- version: `{_safe_token(__version__)}`",
|
|
890
|
+
f"- python: `{_safe_token(platform.python_version())}`",
|
|
891
|
+
f"- platform: `{_safe_token(platform.system())} {_safe_token(platform.machine())}`",
|
|
892
|
+
f"- install: `{_safe_token(_install_method())}`",
|
|
893
|
+
"",
|
|
894
|
+
"| check | status | note |",
|
|
895
|
+
"| --- | --- | --- |",
|
|
896
|
+
]
|
|
897
|
+
for check in checks:
|
|
898
|
+
note = _safe_public_note(check)
|
|
899
|
+
lines.append(
|
|
900
|
+
f"| `{_public_check_name(check.name)}` | {_public_status(check.status)} | {note} |"
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
failures = [check.name for check in checks if check.status == "fail"]
|
|
904
|
+
if failures:
|
|
905
|
+
lines.extend(
|
|
906
|
+
["", "Failing checks: " + ", ".join(f"`{_public_check_name(n)}`" for n in failures)]
|
|
907
|
+
)
|
|
908
|
+
lines.extend(
|
|
909
|
+
[
|
|
910
|
+
"",
|
|
911
|
+
"_Generated by `a2l doctor --report`. Names, student IDs, course codes, org-unit "
|
|
912
|
+
"IDs, absolute paths, cookies, tokens, and grades are excluded by construction._",
|
|
913
|
+
]
|
|
914
|
+
)
|
|
915
|
+
return "\n".join(lines) + "\n"
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def issue_url(checks: Sequence[Check]) -> str:
|
|
919
|
+
"""Build a pre-filled issue URL bounded to ``MAX_ISSUE_URL_LENGTH`` characters."""
|
|
920
|
+
diagnostics = report(checks)
|
|
921
|
+
url = _encoded_issue_url(diagnostics)
|
|
922
|
+
if len(url) <= MAX_ISSUE_URL_LENGTH:
|
|
923
|
+
return url
|
|
924
|
+
|
|
925
|
+
lines = diagnostics.splitlines(keepends=True)
|
|
926
|
+
marker = "_(truncated)_\n"
|
|
927
|
+
lower = 0
|
|
928
|
+
upper = len(lines)
|
|
929
|
+
while lower < upper:
|
|
930
|
+
midpoint = (lower + upper + 1) // 2
|
|
931
|
+
candidate = "".join(lines[:midpoint]) + marker
|
|
932
|
+
if len(_encoded_issue_url(candidate)) <= MAX_ISSUE_URL_LENGTH:
|
|
933
|
+
lower = midpoint
|
|
934
|
+
else:
|
|
935
|
+
upper = midpoint - 1
|
|
936
|
+
return _encoded_issue_url("".join(lines[:lower]) + marker)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _encoded_issue_url(diagnostics: str) -> str:
|
|
940
|
+
query = urlencode(
|
|
941
|
+
(("template", "bug_report.yml"), ("labels", "bug"), ("diagnostics", diagnostics))
|
|
942
|
+
)
|
|
943
|
+
return f"{ISSUE_URL}?{query}"
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def open_notice(checks: Sequence[Check]) -> str:
|
|
947
|
+
"""Show the body and explain that opening the prefilled page sends it to GitHub."""
|
|
948
|
+
return (
|
|
949
|
+
f"This opens {ISSUE_URL} in your browser with the block below pre-filled.\n"
|
|
950
|
+
"Opening this page sends the displayed redacted body to GitHub; review it there before "
|
|
951
|
+
"submitting.\n\n"
|
|
952
|
+
f"{report(checks)}"
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# ----------------------------------------------------------------------------------------
|
|
957
|
+
# Helpers
|
|
958
|
+
# ----------------------------------------------------------------------------------------
|
|
959
|
+
def _safe_token(value: object) -> str:
|
|
960
|
+
"""Reduce any value to a short, inert token.
|
|
961
|
+
|
|
962
|
+
Applied even to values that look obviously safe. ``platform.machine()`` and a version
|
|
963
|
+
string are attacker-influenced in principle and formatting-hostile in practice, and a
|
|
964
|
+
single uniform rule is easier to keep correct than a set of judgement calls.
|
|
965
|
+
"""
|
|
966
|
+
text = "".join(char for char in str(value) if char.isalnum() or char in "._- +")
|
|
967
|
+
return text.strip()[:64] or "unknown"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _public_check_name(value: object) -> str:
|
|
971
|
+
if isinstance(value, str) and value in _PUBLIC_CHECK_NAMES:
|
|
972
|
+
return value
|
|
973
|
+
return "unknown-check"
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _public_status(value: object) -> str:
|
|
977
|
+
if isinstance(value, str) and value in _PUBLIC_STATUSES:
|
|
978
|
+
return value
|
|
979
|
+
return "unknown"
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _safe_public_note(check: Check) -> str:
|
|
983
|
+
"""Allow only fixed notes whose redaction does not depend on caller-supplied text.
|
|
984
|
+
|
|
985
|
+
``Check.public`` is convenient inside the implementation, but it is not a security type:
|
|
986
|
+
tests, plugins, or a future check can construct it with arbitrary text. A strict note
|
|
987
|
+
allowlist makes the report safe even when a caller violates the convention in the docstring.
|
|
988
|
+
"""
|
|
989
|
+
if check.public is None:
|
|
990
|
+
return ""
|
|
991
|
+
return _SAFE_PUBLIC_NOTES.get(check.name, "")
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _install_method() -> str:
|
|
995
|
+
executable = str(Path(sys.executable)).casefold()
|
|
996
|
+
if "uv" in executable or "uv" in str(Path(sys.prefix)).casefold():
|
|
997
|
+
return "uv tool"
|
|
998
|
+
if hasattr(sys, "real_prefix") or sys.prefix != sys.base_prefix:
|
|
999
|
+
return "virtualenv"
|
|
1000
|
+
return "system"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _can_encode(encoding: str) -> bool:
|
|
1004
|
+
try:
|
|
1005
|
+
"✓".encode(encoding)
|
|
1006
|
+
except (LookupError, UnicodeEncodeError):
|
|
1007
|
+
return False
|
|
1008
|
+
return True
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _cdp_browser() -> str | None:
|
|
1012
|
+
try:
|
|
1013
|
+
return cdp.locate_browser().name
|
|
1014
|
+
except Exception:
|
|
1015
|
+
return None
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _inside_git_worktree(root: Path) -> bool:
|
|
1019
|
+
return _git_metadata(root) is not None
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _tracked_files(root: Path) -> list[str] | None:
|
|
1023
|
+
"""List Git-tracked paths, using Git only for unsupported index layouts."""
|
|
1024
|
+
metadata = _git_metadata(root)
|
|
1025
|
+
if metadata is None:
|
|
1026
|
+
return None
|
|
1027
|
+
repo, git_directory = metadata
|
|
1028
|
+
index = git_directory / "index"
|
|
1029
|
+
try:
|
|
1030
|
+
with open(os.fspath(paths.long_path(index)), "rb") as handle:
|
|
1031
|
+
raw = handle.read()
|
|
1032
|
+
except OSError:
|
|
1033
|
+
return None
|
|
1034
|
+
parsed = _parse_git_index(raw, root, repo)
|
|
1035
|
+
return parsed if parsed is not None else _git_ls_files(root)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _git_ls_files(root: Path) -> list[str] | None:
|
|
1039
|
+
"""Read a valid but unsupported index with Git, without releasing its filenames."""
|
|
1040
|
+
try:
|
|
1041
|
+
result = subprocess.run(
|
|
1042
|
+
["git", "-C", os.fspath(paths.long_path(root)), "ls-files", "-z", "--"],
|
|
1043
|
+
stdin=subprocess.DEVNULL,
|
|
1044
|
+
stdout=subprocess.PIPE,
|
|
1045
|
+
stderr=subprocess.DEVNULL,
|
|
1046
|
+
check=False,
|
|
1047
|
+
shell=False,
|
|
1048
|
+
timeout=5,
|
|
1049
|
+
)
|
|
1050
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1051
|
+
return None
|
|
1052
|
+
if result.returncode != 0:
|
|
1053
|
+
return None
|
|
1054
|
+
return [entry for entry in os.fsdecode(result.stdout).split("\0") if entry]
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _git_metadata(root: Path) -> tuple[Path, Path] | None:
|
|
1058
|
+
"""Return worktree root and git dir, including linked-worktree ``.git`` files."""
|
|
1059
|
+
for directory in [root, *root.parents]:
|
|
1060
|
+
marker = directory / ".git"
|
|
1061
|
+
if paths.long_path(marker).is_dir():
|
|
1062
|
+
return directory, marker
|
|
1063
|
+
if not paths.long_path(marker).is_file():
|
|
1064
|
+
continue
|
|
1065
|
+
with open(os.fspath(paths.long_path(marker)), encoding="utf-8", newline="") as handle:
|
|
1066
|
+
line = handle.readline().strip()
|
|
1067
|
+
prefix = "gitdir:"
|
|
1068
|
+
if not line.casefold().startswith(prefix):
|
|
1069
|
+
return None
|
|
1070
|
+
git_directory = Path(line[len(prefix) :].strip())
|
|
1071
|
+
if not git_directory.is_absolute():
|
|
1072
|
+
git_directory = directory / git_directory
|
|
1073
|
+
return directory, git_directory.resolve()
|
|
1074
|
+
return None
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _parse_git_index(raw: bytes, root: Path, repo: Path) -> list[str] | None:
|
|
1078
|
+
if not raw.startswith(b"DIRC") or len(raw) < 12:
|
|
1079
|
+
return None
|
|
1080
|
+
version = int.from_bytes(raw[4:8], "big")
|
|
1081
|
+
if version not in {2, 3}:
|
|
1082
|
+
return None
|
|
1083
|
+
count = int.from_bytes(raw[8:12], "big")
|
|
1084
|
+
entries: list[str] = []
|
|
1085
|
+
offset = 12
|
|
1086
|
+
prefix = ""
|
|
1087
|
+
try:
|
|
1088
|
+
relative = root.resolve().relative_to(repo.resolve()).as_posix()
|
|
1089
|
+
prefix = "" if relative == "." else f"{relative}/"
|
|
1090
|
+
except ValueError:
|
|
1091
|
+
prefix = ""
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
for _ in range(count):
|
|
1095
|
+
if offset + 62 > len(raw):
|
|
1096
|
+
return None
|
|
1097
|
+
flags = int.from_bytes(raw[offset + 60 : offset + 62], "big")
|
|
1098
|
+
name_start = offset + 62 + (2 if flags & 0x4000 else 0)
|
|
1099
|
+
end = raw.index(b"\x00", name_start)
|
|
1100
|
+
name = raw[name_start:end].decode("utf-8", "replace")
|
|
1101
|
+
if not prefix or name.startswith(prefix):
|
|
1102
|
+
entries.append(name[len(prefix) :] if prefix else name)
|
|
1103
|
+
offset = (end + 8) & ~7
|
|
1104
|
+
except (ValueError, UnicodeDecodeError):
|
|
1105
|
+
return None
|
|
1106
|
+
return entries
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _git_entry_parts(entry: str) -> tuple[str, ...]:
|
|
1110
|
+
normalized = entry.replace("\\", "/")
|
|
1111
|
+
if normalized.startswith("./"):
|
|
1112
|
+
normalized = normalized[2:]
|
|
1113
|
+
# PurePosixPath intentionally normalizes interior ``.`` segments in Git-style paths.
|
|
1114
|
+
return tuple(part.casefold() for part in PurePosixPath(normalized).parts)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _is_private_git_entry(parts: tuple[str, ...]) -> bool:
|
|
1118
|
+
sensitive_components = {"session", "sessions", "discussions"}
|
|
1119
|
+
sensitive_basenames = {"session.json", "sessions.json", "discussions.json", "my_grades.json"}
|
|
1120
|
+
if set(parts) & sensitive_components:
|
|
1121
|
+
return True
|
|
1122
|
+
if parts and parts[-1] in sensitive_basenames:
|
|
1123
|
+
return True
|
|
1124
|
+
for index, part in enumerate(parts[:-1]):
|
|
1125
|
+
if part == ".a2l" and parts[index + 1] in {"private", "submissions"}:
|
|
1126
|
+
return True
|
|
1127
|
+
return False
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _is_course_source_entry(parts: tuple[str, ...]) -> bool:
|
|
1131
|
+
return bool(set(parts) & _COURSE_SOURCE_DIRECTORIES)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _exception_class(exc: BaseException) -> str:
|
|
1135
|
+
return _safe_token(type(exc).__name__)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _api_failure_class(exc: BaseException) -> str:
|
|
1139
|
+
if isinstance(exc, SessionExpired):
|
|
1140
|
+
return "authentication failure"
|
|
1141
|
+
if isinstance(exc, requests.RequestException):
|
|
1142
|
+
response = getattr(exc, "response", None)
|
|
1143
|
+
status_code = getattr(response, "status_code", None)
|
|
1144
|
+
if isinstance(status_code, int) and 100 <= status_code <= 599:
|
|
1145
|
+
return f"HTTP {status_code // 100}xx"
|
|
1146
|
+
return "network failure"
|
|
1147
|
+
return _exception_class(exc)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def _ordered_groups(checks: Iterable[Check]) -> list[str]:
|
|
1151
|
+
present = {check.group for check in checks}
|
|
1152
|
+
ordered = [group for group in _GROUP_ORDER if group in present]
|
|
1153
|
+
return ordered + sorted(present - set(ordered))
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
__all__ = [
|
|
1157
|
+
"Check",
|
|
1158
|
+
"ISSUE_URL",
|
|
1159
|
+
"MAX_ISSUE_URL_LENGTH",
|
|
1160
|
+
"exit_code",
|
|
1161
|
+
"issue_url",
|
|
1162
|
+
"next_command",
|
|
1163
|
+
"open_notice",
|
|
1164
|
+
"render",
|
|
1165
|
+
"report",
|
|
1166
|
+
"run_checks",
|
|
1167
|
+
]
|