codecalc 0.1.0__py3-none-win_amd64.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.
- codecalc/SKILL.md +87 -0
- codecalc/__init__.py +3 -0
- codecalc/__main__.py +19 -0
- codecalc/_worker_bootstrap.py +211 -0
- codecalc/bin/codecalc-exec.exe +0 -0
- codecalc/complexity.py +162 -0
- codecalc/contract.py +624 -0
- codecalc/doctor.py +428 -0
- codecalc/errors.py +214 -0
- codecalc/exact.py +969 -0
- codecalc/execution_service.py +302 -0
- codecalc/executor.py +1324 -0
- codecalc/guarded.py +300 -0
- codecalc/landlock.py +231 -0
- codecalc/logic.py +500 -0
- codecalc/mcp_middleware.py +135 -0
- codecalc/optimization.py +296 -0
- codecalc/optional.py +107 -0
- codecalc/packages.py +360 -0
- codecalc/parsing.py +266 -0
- codecalc/provider_adapters/__init__.py +1 -0
- codecalc/provider_adapters/piston.py +34 -0
- codecalc/providers.py +802 -0
- codecalc/py.typed +0 -0
- codecalc/registry.py +254 -0
- codecalc/run_supervisor.py +178 -0
- codecalc/runtimes.py +542 -0
- codecalc/safe_expr.py +346 -0
- codecalc/server.py +1103 -0
- codecalc/sessions.py +1060 -0
- codecalc/strict_runtime.py +232 -0
- codecalc/tools.py +326 -0
- codecalc/translation.py +286 -0
- codecalc/units.py +410 -0
- codecalc-0.1.0.dist-info/METADATA +838 -0
- codecalc-0.1.0.dist-info/RECORD +39 -0
- codecalc-0.1.0.dist-info/WHEEL +4 -0
- codecalc-0.1.0.dist-info/entry_points.txt +2 -0
- codecalc-0.1.0.dist-info/licenses/LICENSE +201 -0
codecalc/doctor.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""What this install actually resolved, as data (THE-780).
|
|
2
|
+
|
|
3
|
+
`codecalc doctor` existed and printed prose. Prose is right for the operator
|
|
4
|
+
reading it and useless to the installer script, the CI job or the agent that
|
|
5
|
+
wants to branch on whether the native executor is present — all of which had to
|
|
6
|
+
either parse English or make a tool call and read a field out of the result.
|
|
7
|
+
|
|
8
|
+
So the report is built here as a dict and rendered by the caller. Two
|
|
9
|
+
consequences worth having: the text and the JSON cannot disagree, because there
|
|
10
|
+
is one source; and the report is testable without spawning a CLI, which is what
|
|
11
|
+
made the degraded-environment cases (no binary, no runtimes, unwritable
|
|
12
|
+
workspace) reachable at all.
|
|
13
|
+
|
|
14
|
+
THE FOUR-STATE RUNTIME VOCABULARY, AND WHY IT IS NOT FOUR STATES BY DEFAULT
|
|
15
|
+
|
|
16
|
+
THE-780 asks doctor to distinguish `supported`, `installed`, `available` and
|
|
17
|
+
`unhealthy`. The honest version of that distinction costs something:
|
|
18
|
+
|
|
19
|
+
supported codecalc knows this language; nothing for it resolves here
|
|
20
|
+
installed its command resolves on the sandbox PATH and is executable
|
|
21
|
+
unhealthy its command resolves and is NOT executable, or was run and failed
|
|
22
|
+
available it was actually RUN here and answered
|
|
23
|
+
|
|
24
|
+
`available` is the only one that requires executing the runtime, and there are
|
|
25
|
+
31 of them. Doing that on every `doctor` invocation would turn a diagnostic
|
|
26
|
+
into a minute-long build step, so the default reports resolution only —
|
|
27
|
+
`installed`, never `available` — and `--deep` is what promotes a resolved
|
|
28
|
+
runtime to `available` or demotes it to `unhealthy` by running it.
|
|
29
|
+
|
|
30
|
+
That split is the point rather than a shortcut. Reporting `available` for a
|
|
31
|
+
binary this process only found on PATH would be the exact defect this project
|
|
32
|
+
keeps correcting: a field that claims a stronger measurement than was taken.
|
|
33
|
+
`status_basis` says which of the two ran, per report, so a reader never has to
|
|
34
|
+
infer it.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
import shutil
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import tempfile
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from . import __version__, contract, executor, landlock, optional, providers, registry
|
|
47
|
+
|
|
48
|
+
#: The four states, ordered weakest to strongest claim. Defined in registry.py
|
|
49
|
+
#: and re-exported here: `list_languages` reports the same vocabulary now
|
|
50
|
+
#: (THE-817), and registry is the module all three readers already import.
|
|
51
|
+
#: contract.py's schema still imports it from this name.
|
|
52
|
+
RUNTIME_STATES = registry.RUNTIME_STATES
|
|
53
|
+
|
|
54
|
+
#: Extras, and the modules whose presence defines them. Mirrors what the text
|
|
55
|
+
#: renderer used to hardcode.
|
|
56
|
+
EXTRAS: dict[str, tuple[str, ...]] = {
|
|
57
|
+
"symbolic": ("sympy", "z3"),
|
|
58
|
+
"parsing": ("tree_sitter_language_pack",),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Registry entries that are a second spelling of a language already counted.
|
|
62
|
+
#: Mirrors `_ALIAS_ENTRIES` in server.py and `ALIAS_ENTRIES` in
|
|
63
|
+
#: scripts/check_claims.py — the count this reports has to agree with the
|
|
64
|
+
#: README's and the gate's, or it is a fourth opinion on a number this repo
|
|
65
|
+
#: already has a gate for.
|
|
66
|
+
ALIAS_ENTRIES = frozenset({"c++"})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def primary_command(entry: dict, name: str | None = None) -> str:
|
|
70
|
+
"""The command whose presence decides whether a language resolves.
|
|
71
|
+
|
|
72
|
+
Mirrors the fallback in `executor.probe()` deliberately, and
|
|
73
|
+
`tests/test_doctor.py` asserts the two agree on every language. Two copies
|
|
74
|
+
of this rule that drift would make `doctor` and the executor disagree about
|
|
75
|
+
which runtimes exist, which is worse than either being wrong on its own.
|
|
76
|
+
|
|
77
|
+
For a shell-wrapped language the deciding command is the REAL tool, not
|
|
78
|
+
`bash` (THE-835): `bash` resolving says nothing about whether gleam is
|
|
79
|
+
installed, and probing it advertised wrapper languages on machines that
|
|
80
|
+
had never seen them. `name` is optional only for callers that predate the
|
|
81
|
+
wrapper distinction; passing it is what makes the answer honest.
|
|
82
|
+
"""
|
|
83
|
+
if name is not None and name in registry.SHELL_WRAPPED:
|
|
84
|
+
return registry.WRAPPED_TOOL[name]
|
|
85
|
+
cmd = entry["run"][0] if entry["run"] else ""
|
|
86
|
+
if cmd.startswith("{"):
|
|
87
|
+
cmd = (entry["compile"] or ["bash"])[0] if entry["compile"] else "bash"
|
|
88
|
+
if cmd.startswith(("bash", "sh")):
|
|
89
|
+
cmd = "bash"
|
|
90
|
+
return cmd
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _runtime_status(cmd: str) -> tuple[str, str | None]:
|
|
94
|
+
"""`(status, resolved_path)` from resolution alone — no execution."""
|
|
95
|
+
if not cmd:
|
|
96
|
+
return "supported", None
|
|
97
|
+
runtime_path = registry.runtime_path()
|
|
98
|
+
path = shutil.which(cmd, path=runtime_path)
|
|
99
|
+
if path is not None:
|
|
100
|
+
return "installed", path
|
|
101
|
+
|
|
102
|
+
# `shutil.which` already filters by the execute bit, so a present-but-not-
|
|
103
|
+
# executable command comes back as None and would report `supported` —
|
|
104
|
+
# "codecalc knows this language, nothing for it here" — when the file is
|
|
105
|
+
# sitting right there unable to run. That is the most confusing answer
|
|
106
|
+
# available: the operator can see the binary and doctor says it is absent.
|
|
107
|
+
#
|
|
108
|
+
# The first version of this function had exactly that bug. It called
|
|
109
|
+
# os.access(X_OK) on which()'s result, which can only ever be True, so
|
|
110
|
+
# `unhealthy` was unreachable without --deep and the four-state vocabulary
|
|
111
|
+
# was three states wearing four names. Caught by the test written for it.
|
|
112
|
+
#
|
|
113
|
+
# So the fallback scan is a SECOND pass, deliberately after which(): which()
|
|
114
|
+
# handles PATHEXT on Windows and this does not, and getting that wrong would
|
|
115
|
+
# trade a rare state for a common platform.
|
|
116
|
+
for directory in runtime_path.split(os.pathsep):
|
|
117
|
+
if not directory:
|
|
118
|
+
continue
|
|
119
|
+
candidate = Path(directory) / cmd
|
|
120
|
+
if candidate.is_file():
|
|
121
|
+
return "unhealthy", str(candidate)
|
|
122
|
+
return "supported", None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _grammar_cache() -> dict:
|
|
126
|
+
"""Is the tree-sitter grammar cache warm? (THE-821)
|
|
127
|
+
|
|
128
|
+
`analyze_complexity` parses with tree-sitter, and the grammars are NOT in
|
|
129
|
+
the wheel — `tree-sitter-language-pack` ships a ~5 MB extension and fetches
|
|
130
|
+
each grammar on first use, in-process, into a local cache (28 grammars,
|
|
131
|
+
89 MB, ~15s cold).
|
|
132
|
+
|
|
133
|
+
That is a socket opened from inside the server, which is a surprise on a
|
|
134
|
+
host chosen for being offline. Reported here so it is discoverable BEFORE a
|
|
135
|
+
tool call degrades to `regex-fallback`, rather than after — an operator
|
|
136
|
+
planning an air-gapped install otherwise has no way to find out.
|
|
137
|
+
|
|
138
|
+
`cached: false` is not an error. It means the first analysis of each
|
|
139
|
+
language will reach the network; `scripts/prefetch_grammars.py` warms it.
|
|
140
|
+
"""
|
|
141
|
+
if not optional.have("tree_sitter_language_pack"):
|
|
142
|
+
return {"extra_installed": False, "cached": False, "path": None,
|
|
143
|
+
"grammars": 0,
|
|
144
|
+
"detail": "the 'parsing' extra is not installed; "
|
|
145
|
+
"analyze_complexity uses the regex fallback"}
|
|
146
|
+
try:
|
|
147
|
+
from tree_sitter_language_pack import cache_dir as _cache_dir
|
|
148
|
+
|
|
149
|
+
path = str(_cache_dir())
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
return {"extra_installed": True, "cached": False, "path": None,
|
|
152
|
+
"grammars": 0,
|
|
153
|
+
"detail": f"cannot resolve the grammar cache: "
|
|
154
|
+
f"{type(exc).__name__}: {exc}"}
|
|
155
|
+
|
|
156
|
+
# COUNTED, not just existence-checked: an empty cache directory is created
|
|
157
|
+
# by the pack before anything is downloaded, so `path.is_dir()` is true on a
|
|
158
|
+
# completely cold host and would report a warm cache that is not there.
|
|
159
|
+
count = 0
|
|
160
|
+
p = Path(path)
|
|
161
|
+
if p.is_dir():
|
|
162
|
+
count = sum(1 for f in p.iterdir()
|
|
163
|
+
if f.is_file() and f.suffix in (".so", ".dylib", ".dll", ".pyd"))
|
|
164
|
+
return {
|
|
165
|
+
"extra_installed": True,
|
|
166
|
+
"cached": count > 0,
|
|
167
|
+
"path": path,
|
|
168
|
+
"grammars": count,
|
|
169
|
+
"detail": None if count else (
|
|
170
|
+
"no grammars cached — the first analyze_complexity call per "
|
|
171
|
+
"language will DOWNLOAD one. Run scripts/prefetch_grammars.py to "
|
|
172
|
+
"warm it, which is what an offline install needs"),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _workspace_check() -> dict:
|
|
177
|
+
"""Can we actually create a workdir? Written, not assumed.
|
|
178
|
+
|
|
179
|
+
`tempfile.gettempdir()` returning a path says nothing about whether this
|
|
180
|
+
process may write there — a read-only TMPDIR, a full disk and a correct
|
|
181
|
+
setup all return the same string. Every execution needs a writable workdir,
|
|
182
|
+
so this is the one check whose failure means nothing will work.
|
|
183
|
+
"""
|
|
184
|
+
root = tempfile.gettempdir()
|
|
185
|
+
try:
|
|
186
|
+
with tempfile.TemporaryDirectory(prefix="codecalc-doctor-") as d:
|
|
187
|
+
probe = Path(d) / "w"
|
|
188
|
+
probe.write_text("ok", encoding="utf-8")
|
|
189
|
+
probe.read_text(encoding="utf-8")
|
|
190
|
+
return {"path": root, "writable": True, "error": None}
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
return {"path": root, "writable": False,
|
|
193
|
+
"error": f"{type(exc).__name__}: {exc}"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def report(deep: bool = False) -> dict:
|
|
197
|
+
"""Everything `doctor` knows, as data.
|
|
198
|
+
|
|
199
|
+
`deep=True` executes each resolved runtime to promote it to `available` or
|
|
200
|
+
demote it to `unhealthy`. Off by default: 31 runtimes is not a diagnostic,
|
|
201
|
+
it is a build.
|
|
202
|
+
"""
|
|
203
|
+
backend = executor.backend()
|
|
204
|
+
abi = landlock.abi_version()
|
|
205
|
+
|
|
206
|
+
extras = []
|
|
207
|
+
for name, modules in EXTRAS.items():
|
|
208
|
+
missing = [m for m in modules if not optional.have(m)]
|
|
209
|
+
extras.append({
|
|
210
|
+
"name": name,
|
|
211
|
+
"installed": not missing,
|
|
212
|
+
"missing": missing,
|
|
213
|
+
# The exact command, because "install the extra" is not actionable
|
|
214
|
+
# and this is the one place that knows the distribution name.
|
|
215
|
+
"remedy": None if not missing else f"pip install 'codecalc[{name}]'",
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
langs = [name for name in sorted(registry.LANGUAGES) if name not in ALIAS_ENTRIES]
|
|
219
|
+
runtimes = []
|
|
220
|
+
for name in langs:
|
|
221
|
+
cmd = primary_command(registry.LANGUAGES[name], name)
|
|
222
|
+
# A plan the platform cannot run reports `supported` — "codecalc knows
|
|
223
|
+
# this language, nothing FOR IT here" — with the reason in `detail`,
|
|
224
|
+
# BEFORE any resolution happens. Resolving the tool first and then
|
|
225
|
+
# deciding would report gleam `installed` on a Windows box where the
|
|
226
|
+
# plan is structurally unable to run (THE-835): a stronger claim than
|
|
227
|
+
# was measured, which is the defect this file's docstring is about.
|
|
228
|
+
if not registry.plan_supported(name, windows=os.name == "nt"):
|
|
229
|
+
runtimes.append({
|
|
230
|
+
"name": name, "command": cmd, "status": "supported",
|
|
231
|
+
"path": None, "version": None,
|
|
232
|
+
"detail": "the canonical plan needs a POSIX shell to scaffold "
|
|
233
|
+
"a project; unsupported on Windows",
|
|
234
|
+
})
|
|
235
|
+
continue
|
|
236
|
+
status, path = _runtime_status(cmd)
|
|
237
|
+
# A wrapped plan needs its shell AS WELL as its tool. The tool
|
|
238
|
+
# resolving while bash is absent (minimal containers) is still a plan
|
|
239
|
+
# that cannot run, and `installed` would overstate it.
|
|
240
|
+
if (name in registry.SHELL_WRAPPED and status == "installed"
|
|
241
|
+
and shutil.which("bash", path=registry.runtime_path()) is None):
|
|
242
|
+
status, path = "supported", None
|
|
243
|
+
# `version` is present on every row so a caller never has to branch on
|
|
244
|
+
# the key's existence, and is None unless it was actually read. Under
|
|
245
|
+
# --deep only, for the same reason `available` is: asking 31 runtimes
|
|
246
|
+
# their version is a build, not a diagnostic.
|
|
247
|
+
row = {"name": name, "command": cmd, "status": status, "path": path,
|
|
248
|
+
"version": _runtime_version(cmd, path) if deep else None}
|
|
249
|
+
# Only languages with a hello program are promoted. Running the others
|
|
250
|
+
# with an EMPTY source would execute a no-op, find no "codecalc" in its
|
|
251
|
+
# stdout, and demote a perfectly good runtime to `unhealthy` — a --deep
|
|
252
|
+
# run reporting failures it manufactured itself. They stay `installed`,
|
|
253
|
+
# which is what was actually measured about them.
|
|
254
|
+
if deep and status == "installed" and name in _HELLO:
|
|
255
|
+
probe = executor.execute(name, _HELLO[name], timeout=20)
|
|
256
|
+
ran = probe.get("ok") is True and "codecalc" in (probe.get("stdout") or "")
|
|
257
|
+
row["status"] = "available" if ran else "unhealthy"
|
|
258
|
+
if not ran:
|
|
259
|
+
row["detail"] = (probe.get("error")
|
|
260
|
+
or (probe.get("stderr") or "")[:200]
|
|
261
|
+
or f"verdict={probe.get('verdict')}")
|
|
262
|
+
runtimes.append(row)
|
|
263
|
+
|
|
264
|
+
summary = {state: sum(1 for r in runtimes if r["status"] == state)
|
|
265
|
+
for state in RUNTIME_STATES}
|
|
266
|
+
workspace = _workspace_check()
|
|
267
|
+
provider_registry = providers.configured_registry()
|
|
268
|
+
execution_providers = []
|
|
269
|
+
for descriptor in provider_registry.descriptors():
|
|
270
|
+
row = dict(descriptor)
|
|
271
|
+
if descriptor["host_class"] in {"local", providers.strict_host_platform()}:
|
|
272
|
+
health = provider_registry.select(descriptor["provider_id"]).health()
|
|
273
|
+
row["ready"] = health["ready"]
|
|
274
|
+
row["technology"] = health.get("technology")
|
|
275
|
+
row["detail"] = health.get("error")
|
|
276
|
+
else:
|
|
277
|
+
# Doctor must not turn into an unsolicited remote network probe.
|
|
278
|
+
row["ready"] = None
|
|
279
|
+
row["technology"] = None
|
|
280
|
+
row["detail"] = "remote readiness not probed by doctor"
|
|
281
|
+
execution_providers.append(row)
|
|
282
|
+
|
|
283
|
+
# `healthy` is deliberately NARROW. A missing optional extra and an
|
|
284
|
+
# uninstalled Haskell are ordinary facts about a host, not faults, and
|
|
285
|
+
# exiting non-zero for them would make `doctor` useless as an install check
|
|
286
|
+
# — the thing THE-780 wants it to be. What makes an install unhealthy is
|
|
287
|
+
# that it cannot execute anything: no workspace to run in, or no backend.
|
|
288
|
+
healthy = bool(workspace["writable"]) and backend in ("rust", "python")
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
# Under the result contract's version and policy rather than a third
|
|
292
|
+
# number of its own. This is machine-readable output a client programs
|
|
293
|
+
# against, which is what that policy is for; a separate doctor version
|
|
294
|
+
# would mean two documents to keep in step and two policies to read.
|
|
295
|
+
"contract_version": contract.CONTRACT_VERSION,
|
|
296
|
+
"codecalc_version": __version__,
|
|
297
|
+
"healthy": healthy,
|
|
298
|
+
"python": {"version": sys.version.split()[0], "platform": sys.platform},
|
|
299
|
+
"backend": {
|
|
300
|
+
"kind": backend,
|
|
301
|
+
"binary": executor._rust,
|
|
302
|
+
# Named rather than left as an absence: "no binary" and "a binary
|
|
303
|
+
# that would not run" are different problems with different fixes.
|
|
304
|
+
"detail": None if backend == "rust" else (
|
|
305
|
+
"native executor not found — running the pure-Python fallback, "
|
|
306
|
+
"which cannot enforce no_net. Set CODECALC_REQUIRE_NATIVE=1 to "
|
|
307
|
+
"make this a startup failure instead of a weaker sandbox."),
|
|
308
|
+
},
|
|
309
|
+
"execution_providers": execution_providers,
|
|
310
|
+
"install_sandbox": {
|
|
311
|
+
"landlock_abi": abi,
|
|
312
|
+
"confined": bool(abi),
|
|
313
|
+
"detail": None if abi else "installs are not confined on this host",
|
|
314
|
+
},
|
|
315
|
+
"extras": extras,
|
|
316
|
+
# Where analyze_complexity's grammars come from, and whether they are
|
|
317
|
+
# here yet. Not an extra and not a runtime: it is a network dependency
|
|
318
|
+
# of a tool, which neither of those blocks describes.
|
|
319
|
+
"grammar_cache": _grammar_cache(),
|
|
320
|
+
# Which measurement produced the statuses above. Without this a reader
|
|
321
|
+
# cannot tell `installed` ("found on PATH") from a --deep run that
|
|
322
|
+
# simply found nothing runnable.
|
|
323
|
+
"status_basis": "executed" if deep else "resolved",
|
|
324
|
+
"runtimes": runtimes,
|
|
325
|
+
"runtime_summary": summary,
|
|
326
|
+
"workspace": workspace,
|
|
327
|
+
"skill_file": _skill_path(),
|
|
328
|
+
"remedies": _remedies(backend, extras, runtimes, workspace),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
#: A one-line program per language would be 31 more things to keep correct, and
|
|
333
|
+
#: `--deep` only needs to prove the runtime answers at all. Languages absent
|
|
334
|
+
#: here are still resolved and reported; they are simply not promoted.
|
|
335
|
+
_HELLO = {
|
|
336
|
+
"python3": 'print("codecalc")',
|
|
337
|
+
"node": 'console.log("codecalc")',
|
|
338
|
+
"ruby": 'puts "codecalc"',
|
|
339
|
+
"perl": 'print "codecalc\\n";',
|
|
340
|
+
"php": '<?php echo "codecalc\\n";',
|
|
341
|
+
"lua": 'print("codecalc")',
|
|
342
|
+
"bash": 'echo codecalc',
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
#: How to ask a runtime its version. Keyed by COMMAND rather than by language,
|
|
347
|
+
#: because `primary_command` collapses several languages onto one binary and
|
|
348
|
+
#: asking `bash` its version four times would be three wasted spawns.
|
|
349
|
+
#:
|
|
350
|
+
#: `--version` is the default and the map holds only the exceptions, so a
|
|
351
|
+
#: runtime added later works without an entry. An exception is not a special
|
|
352
|
+
#: case for its own sake: `java -version` predates the GNU convention and
|
|
353
|
+
#: prints to STDERR, which is why both streams are read below.
|
|
354
|
+
_VERSION_FLAG: dict[str, str] = {
|
|
355
|
+
"java": "-version",
|
|
356
|
+
"kotlinc": "-version",
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#: Commands with no version flag worth calling. `escript` and `tclsh` have no
|
|
360
|
+
#: non-interactive `--version`, and invoking them without one starts a REPL that
|
|
361
|
+
#: would hang until the timeout. Listed rather than discovered, because "it hung
|
|
362
|
+
#: for 10 seconds" is a bad way to learn this.
|
|
363
|
+
_NO_VERSION = frozenset({"escript", "tclsh"})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _runtime_version(command: str, path: str | None) -> str | None:
|
|
367
|
+
"""The runtime's own version string, or None when it could not be read.
|
|
368
|
+
|
|
369
|
+
NONE MEANS NOT MEASURED, NEVER "no version". THE-780 asks doctor to report
|
|
370
|
+
versions; it does not ask it to invent them. A runtime that has no version
|
|
371
|
+
flag, times out, crashes, or answers with something unparsable all produce
|
|
372
|
+
`None` here and leave `status` untouched — a version that could not be read
|
|
373
|
+
says nothing about whether the runtime works, and demoting it would report a
|
|
374
|
+
failure this function manufactured.
|
|
375
|
+
|
|
376
|
+
Runs under the executor's 23-entry env allowlist rather than the doctor
|
|
377
|
+
process's own environment. This is a diagnostic, but it is still spawning
|
|
378
|
+
host binaries, and there is no reason for `gcc --version` to see an API key.
|
|
379
|
+
"""
|
|
380
|
+
if not path or command in _NO_VERSION:
|
|
381
|
+
return None
|
|
382
|
+
flag = _VERSION_FLAG.get(command, "--version")
|
|
383
|
+
try:
|
|
384
|
+
proc = subprocess.run(
|
|
385
|
+
[path, flag],
|
|
386
|
+
capture_output=True, timeout=10, env=executor._env(), check=False,
|
|
387
|
+
)
|
|
388
|
+
except (OSError, subprocess.SubprocessError):
|
|
389
|
+
return None
|
|
390
|
+
# stdout OR stderr: `java -version` uses stderr, and a runtime that answers
|
|
391
|
+
# on the stream we did not read is indistinguishable from one that did not
|
|
392
|
+
# answer at all.
|
|
393
|
+
for stream in (proc.stdout, proc.stderr):
|
|
394
|
+
text = (stream or b"").decode("utf-8", "replace").strip()
|
|
395
|
+
for line in text.splitlines():
|
|
396
|
+
line = line.strip()
|
|
397
|
+
if line:
|
|
398
|
+
# First non-empty line, capped. Some runtimes print a paragraph
|
|
399
|
+
# (gcc prints its licence), and the report is a diagnostic, not
|
|
400
|
+
# a transcript.
|
|
401
|
+
return line[:120]
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _skill_path() -> str | None:
|
|
406
|
+
p = Path(__file__).resolve().parent / "SKILL.md"
|
|
407
|
+
return str(p) if p.is_file() else None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _remedies(backend: str, extras: list, runtimes: list, workspace: dict) -> list[str]:
|
|
411
|
+
"""What to do about it, in the order it matters."""
|
|
412
|
+
out = []
|
|
413
|
+
if not workspace["writable"]:
|
|
414
|
+
out.append(f"workspace {workspace['path']} is not writable "
|
|
415
|
+
f"({workspace['error']}) — nothing can execute until it is; "
|
|
416
|
+
f"set TMPDIR to a writable directory")
|
|
417
|
+
if backend != "rust":
|
|
418
|
+
out.append("build the native executor (`cargo build --release` in "
|
|
419
|
+
"executor/, then copy it to bin/) or install a platform "
|
|
420
|
+
"wheel — the fallback cannot enforce no_net")
|
|
421
|
+
for e in extras:
|
|
422
|
+
if e["remedy"]:
|
|
423
|
+
out.append(e["remedy"])
|
|
424
|
+
unhealthy = [r["name"] for r in runtimes if r["status"] == "unhealthy"]
|
|
425
|
+
if unhealthy:
|
|
426
|
+
out.append(f"resolved but not runnable: {', '.join(unhealthy)} — "
|
|
427
|
+
f"check the file mode and the runtime PATH")
|
|
428
|
+
return out
|
codecalc/errors.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Stable error codes for the result contract (THE-781).
|
|
2
|
+
|
|
3
|
+
Every failing tool result carried a free-form English sentence and nothing
|
|
4
|
+
else — 121 distinct `"error":` strings across the package. A caller could show
|
|
5
|
+
them to a human and could do nothing else with them: "unknown language 'foo'",
|
|
6
|
+
"expression too long", "uv not found" and "session worker died" are four
|
|
7
|
+
different situations that a program has to tell apart by matching prose that
|
|
8
|
+
nobody promised to keep stable.
|
|
9
|
+
|
|
10
|
+
So this adds a `code` beside the message rather than replacing it. The message
|
|
11
|
+
stays human-facing and free to improve; the code is the part a caller may
|
|
12
|
+
branch on, and the part this module promises not to churn.
|
|
13
|
+
|
|
14
|
+
TWO MECHANISMS, AND THE SECOND IS TRANSITIONAL
|
|
15
|
+
`classify()` maps an EXCEPTION to a code and is the mechanism worth having: it
|
|
16
|
+
keys on the exception type, which the code that raised it chose deliberately.
|
|
17
|
+
|
|
18
|
+
That was going to be enough. It is not. The first draft attached it only to
|
|
19
|
+
`guarded_call`, on the assumption that symbolic tools raise and it catches
|
|
20
|
+
them. Measured against the eight most reachable failures — a bad expression,
|
|
21
|
+
an unknown language, an unknown session, a blown recursion ceiling — it
|
|
22
|
+
reached ZERO of them, because those paths `return {"ok": False, ...}` directly
|
|
23
|
+
and never raise. An assumption about control flow, disproved by running it.
|
|
24
|
+
|
|
25
|
+
So `ensure_code()` is the second mechanism: it fills in a code for a failing
|
|
26
|
+
result that has none, by matching the message. Message matching is fragile —
|
|
27
|
+
improve a sentence and the classification changes with it — so a code derived
|
|
28
|
+
that way is marked `code_inferred: true` and is NOT the same claim as one
|
|
29
|
+
attached at the raise site.
|
|
30
|
+
|
|
31
|
+
The distinction is the point. `scripts/check_claims.py` counts inferred codes
|
|
32
|
+
and floors the total, so the transitional half is visible and shrinking rather
|
|
33
|
+
than quietly permanent.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
#: The taxonomy. Values are the wire format and are API: renaming one is a
|
|
39
|
+
#: breaking change to the result contract, which is the whole point of having
|
|
40
|
+
#: them. Deliberately coarse — eight buckets a caller can act on differently,
|
|
41
|
+
#: not a catalogue of every way a call can fail.
|
|
42
|
+
VALIDATION = "validation" # the request was malformed or out of range
|
|
43
|
+
RUNTIME_UNAVAILABLE = "runtime_unavailable" # a language/compiler is not installed here
|
|
44
|
+
TIMEOUT = "timeout" # a wall clock or CPU deadline expired
|
|
45
|
+
RESOURCE_EXHAUSTED = "resource_exhausted" # memory, output or process ceiling hit
|
|
46
|
+
PERMISSION_DENIED = "permission_denied" # refused by a jail, ACL or elevation gate
|
|
47
|
+
DEPENDENCY_MISSING = "dependency_missing" # an optional extra is not installed
|
|
48
|
+
WORKER_FAILURE = "worker_failure" # a session worker died or desynced
|
|
49
|
+
INTERNAL = "internal" # a defect here, not in the request
|
|
50
|
+
|
|
51
|
+
#: Every code, for the gate and for callers that want to validate.
|
|
52
|
+
ALL_CODES = frozenset({
|
|
53
|
+
VALIDATION, RUNTIME_UNAVAILABLE, TIMEOUT, RESOURCE_EXHAUSTED,
|
|
54
|
+
PERMISSION_DENIED, DEPENDENCY_MISSING, WORKER_FAILURE, INTERNAL,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
#: What a caller can do about each, in one line. Returned by the MCP surface so
|
|
58
|
+
#: the remedy travels with the code instead of living only in documentation
|
|
59
|
+
#: somebody has to find.
|
|
60
|
+
REMEDIES = {
|
|
61
|
+
VALIDATION: "fix the arguments and retry; the message names the field",
|
|
62
|
+
RUNTIME_UNAVAILABLE: "install the runtime, or call list_languages to see what this host has",
|
|
63
|
+
TIMEOUT: "raise `timeout`, or reduce the work; a partial result is not returned",
|
|
64
|
+
RESOURCE_EXHAUSTED: "raise the relevant ceiling (max_memory_mb, max_output_kb) or reduce the work",
|
|
65
|
+
PERMISSION_DENIED: "the path or operation is outside what this server permits; it will not succeed on retry",
|
|
66
|
+
DEPENDENCY_MISSING: "install the named extra; the message gives the exact pip command",
|
|
67
|
+
WORKER_FAILURE: "the session is unusable — call session_stop and start a new one",
|
|
68
|
+
INTERNAL: "a defect in codecalc; the message is worth reporting verbatim",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def classify(exc: BaseException) -> str:
|
|
73
|
+
"""Map an exception to a code. Pure, and exception-type first.
|
|
74
|
+
|
|
75
|
+
Ordering matters. `MissingExtra` subclasses `ImportError` on purpose (see
|
|
76
|
+
optional.py), so it has to be tested BEFORE the ImportError branch or every
|
|
77
|
+
absent extra reports as an internal defect — which is the opposite of
|
|
78
|
+
actionable, since the caller can fix a missing extra and cannot fix a bug.
|
|
79
|
+
|
|
80
|
+
Type before message, always. Matching on message text is how a classifier
|
|
81
|
+
becomes wrong silently when someone improves a sentence, so `_from_message`
|
|
82
|
+
below is the last resort and is only consulted for exception types that
|
|
83
|
+
carry no useful class.
|
|
84
|
+
"""
|
|
85
|
+
from .optional import MissingExtra
|
|
86
|
+
|
|
87
|
+
if isinstance(exc, MissingExtra):
|
|
88
|
+
return DEPENDENCY_MISSING
|
|
89
|
+
if isinstance(exc, TimeoutError):
|
|
90
|
+
return TIMEOUT
|
|
91
|
+
if isinstance(exc, MemoryError):
|
|
92
|
+
return RESOURCE_EXHAUSTED
|
|
93
|
+
if isinstance(exc, RecursionError):
|
|
94
|
+
# Depth is a resource here: the guard exists so a deeply nested
|
|
95
|
+
# expression cannot take the process down, and the caller's remedy is
|
|
96
|
+
# to simplify the input, same as any other ceiling.
|
|
97
|
+
return RESOURCE_EXHAUSTED
|
|
98
|
+
if isinstance(exc, PermissionError):
|
|
99
|
+
return PERMISSION_DENIED
|
|
100
|
+
if isinstance(exc, FileNotFoundError):
|
|
101
|
+
# A missing executable is the common case on this path — a language
|
|
102
|
+
# whose runtime is not installed. A missing FILE is validation, but the
|
|
103
|
+
# executor reports that through its own result shape rather than here.
|
|
104
|
+
return RUNTIME_UNAVAILABLE
|
|
105
|
+
if isinstance(exc, ImportError):
|
|
106
|
+
return DEPENDENCY_MISSING
|
|
107
|
+
if isinstance(exc, (ValueError, TypeError, KeyError, IndexError, ArithmeticError)):
|
|
108
|
+
return VALIDATION
|
|
109
|
+
if isinstance(exc, OSError):
|
|
110
|
+
return INTERNAL
|
|
111
|
+
return _from_message(str(exc))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
#: Message fragments consulted ONLY for exceptions with no informative type.
|
|
115
|
+
#: Ordered, first match wins, lowercase comparison.
|
|
116
|
+
#: The first version of this list had ten entries and classified four of the
|
|
117
|
+
#: seven most reachable failures as INTERNAL — including a bad expression and
|
|
118
|
+
#: an unknown session id. INTERNAL's remedy is "a defect in codecalc; the
|
|
119
|
+
#: message is worth reporting verbatim", so that told a caller who mistyped an
|
|
120
|
+
#: expression to go and file a bug. A wrong code is worse than a missing one,
|
|
121
|
+
#: because it is actionable in the wrong direction.
|
|
122
|
+
#:
|
|
123
|
+
#: INTERNAL is the fallback and should be RARE. If a common failure lands there,
|
|
124
|
+
#: the list is wrong, not the failure.
|
|
125
|
+
_MESSAGE_HINTS = (
|
|
126
|
+
# resource ceilings — before the parse hints, because "expression too long"
|
|
127
|
+
# is a ceiling and contains no parse vocabulary but sits near it
|
|
128
|
+
("timed out", TIMEOUT),
|
|
129
|
+
("timeout", TIMEOUT),
|
|
130
|
+
("memory ceiling", RESOURCE_EXHAUSTED),
|
|
131
|
+
("memory", RESOURCE_EXHAUSTED),
|
|
132
|
+
("too long", RESOURCE_EXHAUSTED),
|
|
133
|
+
("too deeply nested", RESOURCE_EXHAUSTED),
|
|
134
|
+
("too many", RESOURCE_EXHAUSTED),
|
|
135
|
+
("resource limit", RESOURCE_EXHAUSTED),
|
|
136
|
+
("exceeded", RESOURCE_EXHAUSTED),
|
|
137
|
+
# dependency
|
|
138
|
+
("not installed", DEPENDENCY_MISSING),
|
|
139
|
+
("ships in the", DEPENDENCY_MISSING),
|
|
140
|
+
# permission / jail
|
|
141
|
+
("permission", PERMISSION_DENIED),
|
|
142
|
+
("escapes", PERMISSION_DENIED),
|
|
143
|
+
("outside", PERMISSION_DENIED),
|
|
144
|
+
("refus", PERMISSION_DENIED),
|
|
145
|
+
# worker / session lifecycle
|
|
146
|
+
("worker", WORKER_FAILURE),
|
|
147
|
+
("session is", WORKER_FAILURE),
|
|
148
|
+
("desync", WORKER_FAILURE),
|
|
149
|
+
# runtime availability
|
|
150
|
+
("runtime unavailable", RUNTIME_UNAVAILABLE),
|
|
151
|
+
("not found in path", RUNTIME_UNAVAILABLE),
|
|
152
|
+
# validation — the largest bucket, and the one INTERNAL was stealing
|
|
153
|
+
("unknown language", VALIDATION),
|
|
154
|
+
("unknown session", VALIDATION),
|
|
155
|
+
("no such file", VALIDATION),
|
|
156
|
+
("no such directory", VALIDATION),
|
|
157
|
+
("parse error", VALIDATION),
|
|
158
|
+
("could not parse", VALIDATION),
|
|
159
|
+
("could not be tokenised", VALIDATION),
|
|
160
|
+
("sympify", VALIDATION),
|
|
161
|
+
("unexpected character", VALIDATION),
|
|
162
|
+
("invalid", VALIDATION),
|
|
163
|
+
("must be", VALIDATION),
|
|
164
|
+
("empty", VALIDATION),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _from_message(message: str) -> str:
|
|
169
|
+
low = message.lower()
|
|
170
|
+
for needle, code in _MESSAGE_HINTS:
|
|
171
|
+
if needle in low:
|
|
172
|
+
return code
|
|
173
|
+
return INTERNAL
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def error_result(code: str, message: str, **extra) -> dict:
|
|
177
|
+
"""The failing half of the result contract.
|
|
178
|
+
|
|
179
|
+
`code` is validated rather than trusted: a typo'd code is worse than no
|
|
180
|
+
code, because a caller branching on it silently takes the wrong branch and
|
|
181
|
+
nothing raises. An unknown code becomes INTERNAL and says so in the
|
|
182
|
+
message, which is a loud failure at the point of the mistake.
|
|
183
|
+
"""
|
|
184
|
+
if code not in ALL_CODES:
|
|
185
|
+
message = f"[unclassified code {code!r}] {message}"
|
|
186
|
+
code = INTERNAL
|
|
187
|
+
return {"ok": False, "code": code, "error": message,
|
|
188
|
+
"remedy": REMEDIES[code], **extra}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ensure_code(result: dict) -> dict:
|
|
192
|
+
"""Give a failing result a code if it has none. Mutates and returns it.
|
|
193
|
+
|
|
194
|
+
The transitional half. Applied where every tool result passes on its way
|
|
195
|
+
out, so the model-facing surface is covered without editing 121 return
|
|
196
|
+
sites — but the code comes from the MESSAGE, which is a weaker claim than
|
|
197
|
+
one chosen at the raise site, so it is labelled `code_inferred: true`.
|
|
198
|
+
|
|
199
|
+
A caller can therefore tell the two apart, and so can the gate: the goal is
|
|
200
|
+
for this function to have less to do over time, not to be the answer.
|
|
201
|
+
|
|
202
|
+
Only touches results that are failing and codeless. A successful result
|
|
203
|
+
gets nothing — a `code` on an `ok: True` payload would read as a warning
|
|
204
|
+
and there is no such thing here.
|
|
205
|
+
"""
|
|
206
|
+
if not isinstance(result, dict) or result.get("ok") is not False:
|
|
207
|
+
return result
|
|
208
|
+
if result.get("code"):
|
|
209
|
+
return result
|
|
210
|
+
code = _from_message(str(result.get("error") or ""))
|
|
211
|
+
result["code"] = code
|
|
212
|
+
result["remedy"] = REMEDIES[code]
|
|
213
|
+
result["code_inferred"] = True
|
|
214
|
+
return result
|