mlspace-plugin 0.2.1__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.
- mlspace_mcp/__init__.py +3 -0
- mlspace_mcp/__main__.py +125 -0
- mlspace_mcp/_status.py +18 -0
- mlspace_mcp/auth.py +109 -0
- mlspace_mcp/bodyspec.py +184 -0
- mlspace_mcp/client.py +170 -0
- mlspace_mcp/client_setup.py +235 -0
- mlspace_mcp/config.py +283 -0
- mlspace_mcp/errors.py +241 -0
- mlspace_mcp/formatting.py +502 -0
- mlspace_mcp/init_cli.py +356 -0
- mlspace_mcp/job_hooks.py +242 -0
- mlspace_mcp/jsonc.py +90 -0
- mlspace_mcp/overview.py +421 -0
- mlspace_mcp/registry.py +632 -0
- mlspace_mcp/resource_refs.py +188 -0
- mlspace_mcp/server.py +190 -0
- mlspace_mcp/setup_ui.py +103 -0
- mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
- mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
- mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
- mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
- mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
- mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
- mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
- mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
- mlspace_mcp/spec/body_schemas.json +7506 -0
- mlspace_mcp/spec/openapi.json +28347 -0
- mlspace_mcp/tls.py +46 -0
- mlspace_mcp/tools/__init__.py +1 -0
- mlspace_mcp/tools/allocations.py +92 -0
- mlspace_mcp/tools/async_inference.py +77 -0
- mlspace_mcp/tools/build_image.py +76 -0
- mlspace_mcp/tools/dalle.py +52 -0
- mlspace_mcp/tools/data_transfer.py +260 -0
- mlspace_mcp/tools/docker_registry.py +156 -0
- mlspace_mcp/tools/inference.py +88 -0
- mlspace_mcp/tools/jobs.py +155 -0
- mlspace_mcp/tools/notebooks.py +174 -0
- mlspace_mcp/tools/queues.py +202 -0
- mlspace_mcp/tools/resources.py +86 -0
- mlspace_mcp/tools/tensorboards.py +112 -0
- mlspace_mcp/tools/workspaces.py +92 -0
- mlspace_mcp/workspace_context.py +107 -0
- mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
- mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
- mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
- mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
- mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Response shaping for tool output.
|
|
2
|
+
|
|
3
|
+
v1 policy (deliberately minimal — see DESIGN.md §formatting):
|
|
4
|
+
- default ``json``: compact structured passthrough with an explicit, testable
|
|
5
|
+
noise-key drop list (k8s manifest cruft) — NOT a universal recursive markdown
|
|
6
|
+
renderer, which mangles the manifests many GET endpoints return.
|
|
7
|
+
- long strings (base64 images, embedded blobs) are elided with a size note.
|
|
8
|
+
- arrays are capped to ``list_cap`` rendered rows with a "… N more" footer.
|
|
9
|
+
- caller-supplied ``redact_keys`` are masked (credential-returning reads).
|
|
10
|
+
- ``kind="log"`` returns the last ``log_tail`` lines with a truncation header.
|
|
11
|
+
- ``kind="catalog"`` losslessly de-duplicates the configs cross-product: the
|
|
12
|
+
same heavy image objects are re-listed under every instance_type, so we hoist
|
|
13
|
+
them into a per-region ``image_catalog`` and leave light ``name:tag`` refs in
|
|
14
|
+
place (the exact per-instance image mapping is preserved). Fail-open on shape.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ._status import JOB_STATUS_CANON
|
|
24
|
+
|
|
25
|
+
# Keys dropped from API JSON to cut Kubernetes/manifest noise.
|
|
26
|
+
# Deliberately NOT including the generic name "annotations": dropping it globally
|
|
27
|
+
# at every depth would silently delete legitimate user/model metadata fields. The
|
|
28
|
+
# bulky k8s annotation blob it carries is the last-applied-config, dropped by name.
|
|
29
|
+
NOISE_KEYS = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"managedFields",
|
|
32
|
+
"resourceVersion",
|
|
33
|
+
"ownerReferences",
|
|
34
|
+
"last-applied-configuration",
|
|
35
|
+
"kubectl.kubernetes.io/last-applied-configuration",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_STR_ELIDE_LEN = 4096
|
|
40
|
+
_REDACTED = "<redacted>"
|
|
41
|
+
|
|
42
|
+
# Advisory attached to each catalog region: the listed instance_types are the
|
|
43
|
+
# region's full catalog, NOT what the caller's allocation actually grants. Worded
|
|
44
|
+
# without any ": " so it can't trip the compact-catalog separator assertions.
|
|
45
|
+
_ALLOCATION_NOTE = (
|
|
46
|
+
"instance_types here is the region's full catalog and is NOT filtered by your "
|
|
47
|
+
"allocation. A catalog-valid type can still be rejected at submit with a 400 "
|
|
48
|
+
"«not available in the allocation <tariff>»; the per-allocation availability "
|
|
49
|
+
"endpoint (mlspace_resources instance_types) may return 405 on PAYG — so the "
|
|
50
|
+
"only reliable check is to try a run."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Marker that replaces a repo list element's heavy recent_image.extra_attrs (~39%
|
|
54
|
+
# of the response). Elision, not a silent drop: it names what was omitted and where
|
|
55
|
+
# to get the full value (get_image).
|
|
56
|
+
_EXTRA_ATTRS_MARKER = "<omitted in list — call get_image for the full docker config>"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _elide_str(value: str) -> str:
|
|
60
|
+
if len(value) > _STR_ELIDE_LEN:
|
|
61
|
+
return f"<{len(value)} chars elided>"
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _mask_secret(value: Any) -> Any:
|
|
66
|
+
"""Uniform masking for a redacted value: keep type-shape signal, drop content."""
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
return f"<redacted, {len(value)} chars>"
|
|
69
|
+
if value is None:
|
|
70
|
+
return None
|
|
71
|
+
return _REDACTED
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Heuristic secret-key detection for the dry-run preview body, where a write domain
|
|
75
|
+
# may not declare exact `redact_result_keys` (e.g. a data-transfer connector body can
|
|
76
|
+
# carry a password / security_key). Matched by NAME substring — over-redacting a
|
|
77
|
+
# preview is safe; leaking a credential into tool output/traces is not.
|
|
78
|
+
_SECRET_NEEDLES = (
|
|
79
|
+
"password", "passwd", "secret", "token", "credential",
|
|
80
|
+
"private_key", "security_key", "access_key", "api_key", "apikey",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _looks_secret(key: Any) -> bool:
|
|
85
|
+
return isinstance(key, str) and any(n in key.lower() for n in _SECRET_NEEDLES)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def redact_secrets(obj: Any) -> Any:
|
|
89
|
+
"""Recursively mask values under secret-looking keys (by key NAME). Pure and
|
|
90
|
+
fail-safe: non-dict/list inputs pass through. Used for the dry-run preview body."""
|
|
91
|
+
if isinstance(obj, dict):
|
|
92
|
+
return {
|
|
93
|
+
k: _mask_secret(v) if _looks_secret(k) else redact_secrets(v)
|
|
94
|
+
for k, v in obj.items()
|
|
95
|
+
}
|
|
96
|
+
if isinstance(obj, list):
|
|
97
|
+
return [redact_secrets(v) for v in obj]
|
|
98
|
+
return obj
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _clean(obj: Any, redact_keys: frozenset[str], list_cap: int) -> Any:
|
|
102
|
+
"""Recursively drop noise keys, redact secrets, elide blobs, cap arrays."""
|
|
103
|
+
if isinstance(obj, dict):
|
|
104
|
+
out: dict[str, Any] = {}
|
|
105
|
+
for key, value in obj.items():
|
|
106
|
+
if key in NOISE_KEYS:
|
|
107
|
+
continue
|
|
108
|
+
if key in redact_keys:
|
|
109
|
+
out[key] = _mask_secret(value)
|
|
110
|
+
continue
|
|
111
|
+
out[key] = _clean(value, redact_keys, list_cap)
|
|
112
|
+
return out
|
|
113
|
+
if isinstance(obj, list):
|
|
114
|
+
cleaned = [_clean(v, redact_keys, list_cap) for v in obj[:list_cap]]
|
|
115
|
+
if len(obj) > list_cap:
|
|
116
|
+
cleaned.append(f"… {len(obj) - list_cap} more item(s) omitted (use offset/limit to page)")
|
|
117
|
+
return cleaned
|
|
118
|
+
if isinstance(obj, str):
|
|
119
|
+
return _elide_str(obj)
|
|
120
|
+
return obj
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _image_ref(img: Any) -> str | None:
|
|
124
|
+
"""Stable ``name:tag`` identity for a configs image object, or None if the
|
|
125
|
+
object isn't shaped like an image (no ``name``) — caller then leaves it inline."""
|
|
126
|
+
if not isinstance(img, dict) or "name" not in img:
|
|
127
|
+
return None
|
|
128
|
+
name = img.get("name")
|
|
129
|
+
tags = img.get("tags")
|
|
130
|
+
if isinstance(tags, list):
|
|
131
|
+
tag_str = ",".join(str(t) for t in tags)
|
|
132
|
+
elif tags is None:
|
|
133
|
+
tag_str = ""
|
|
134
|
+
else:
|
|
135
|
+
tag_str = str(tags)
|
|
136
|
+
return f"{name}:{tag_str}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _shrink_region(region: Any) -> Any:
|
|
140
|
+
"""Hoist a region's repeated image objects into a deduped ``image_catalog``,
|
|
141
|
+
replacing each instance_type's ``images`` with light refs. Fail-open: any
|
|
142
|
+
sub-structure that isn't the expected shape is passed through untouched."""
|
|
143
|
+
if not isinstance(region, dict):
|
|
144
|
+
return region
|
|
145
|
+
instances = region.get("instances_types")
|
|
146
|
+
if not isinstance(instances, list):
|
|
147
|
+
return region
|
|
148
|
+
|
|
149
|
+
catalog: dict[str, Any] = {}
|
|
150
|
+
new_instances: list[Any] = []
|
|
151
|
+
for inst in instances:
|
|
152
|
+
if not isinstance(inst, dict) or not isinstance(inst.get("images"), list):
|
|
153
|
+
new_instances.append(inst)
|
|
154
|
+
continue
|
|
155
|
+
refs: list[Any] = []
|
|
156
|
+
for img in inst["images"]:
|
|
157
|
+
ref = _image_ref(img)
|
|
158
|
+
if ref is None:
|
|
159
|
+
# not image-shaped — keep inline so nothing is lost
|
|
160
|
+
refs.append(img)
|
|
161
|
+
continue
|
|
162
|
+
existing = catalog.get(ref)
|
|
163
|
+
if existing is None:
|
|
164
|
+
catalog[ref] = img
|
|
165
|
+
elif existing != img:
|
|
166
|
+
# same name:tag but different object — disambiguate to stay lossless
|
|
167
|
+
n = 2
|
|
168
|
+
ref = f"{ref}#{n}"
|
|
169
|
+
while ref in catalog and catalog[ref] != img:
|
|
170
|
+
n += 1
|
|
171
|
+
ref = f"{ref.rsplit('#', 1)[0]}#{n}"
|
|
172
|
+
catalog.setdefault(ref, img)
|
|
173
|
+
refs.append(ref)
|
|
174
|
+
new_inst = dict(inst)
|
|
175
|
+
new_inst["images"] = refs
|
|
176
|
+
new_instances.append(new_inst)
|
|
177
|
+
|
|
178
|
+
new_region = dict(region)
|
|
179
|
+
new_region["instances_types"] = new_instances
|
|
180
|
+
# don't clobber a (hypothetical) pre-existing region key of the same name
|
|
181
|
+
key = "image_catalog" if "image_catalog" not in new_region else "_image_catalog"
|
|
182
|
+
new_region[key] = catalog
|
|
183
|
+
# Only on the matched shape with a NON-EMPTY catalog: warn that this list is the
|
|
184
|
+
# region's full catalog, not the caller's allocation grant (a region with no
|
|
185
|
+
# instances_types returned early above and stays untouched).
|
|
186
|
+
if instances:
|
|
187
|
+
new_region.setdefault("_allocation_note", _ALLOCATION_NOTE)
|
|
188
|
+
return new_region
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _region_matches(region: Any, wanted: str) -> bool:
|
|
192
|
+
"""True if a region's ``key`` or ``name`` equals ``wanted`` (case-insensitive)."""
|
|
193
|
+
if not isinstance(region, dict):
|
|
194
|
+
return False
|
|
195
|
+
target = wanted.casefold()
|
|
196
|
+
for field_name in ("key", "name"):
|
|
197
|
+
value = region.get(field_name)
|
|
198
|
+
if isinstance(value, str) and value.casefold() == target:
|
|
199
|
+
return True
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _shrink_configs(data: Any, region: str | None = None) -> Any:
|
|
204
|
+
"""Losslessly shrink a configs-shaped dict (``{"regions": [...]}``). Returns
|
|
205
|
+
the input untouched if it isn't the expected shape (fail-open).
|
|
206
|
+
|
|
207
|
+
If ``region`` is given, keep only the region whose ``key``/``name`` matches it
|
|
208
|
+
(case-insensitive). If nothing matches, keep all regions (fail-open). The
|
|
209
|
+
image-dedup is still applied to whichever region(s) are kept."""
|
|
210
|
+
if not isinstance(data, dict) or not isinstance(data.get("regions"), list):
|
|
211
|
+
return data
|
|
212
|
+
regions = data["regions"]
|
|
213
|
+
if region:
|
|
214
|
+
filtered = [r for r in regions if _region_matches(r, region)]
|
|
215
|
+
if filtered: # fail-open: empty match → keep all
|
|
216
|
+
regions = filtered
|
|
217
|
+
out = dict(data)
|
|
218
|
+
out["regions"] = [_shrink_region(r) for r in regions]
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _to_json(obj: Any) -> str:
|
|
223
|
+
return json.dumps(obj, indent=2, ensure_ascii=False, default=str)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _to_json_compact(obj: Any) -> str:
|
|
227
|
+
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _to_markdown(obj: Any) -> str:
|
|
231
|
+
"""Thin v1 markdown: a JSON code fence (no lossy recursive rendering)."""
|
|
232
|
+
return "```json\n" + _to_json(obj) + "\n```"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _normalise_job(data: Any) -> Any:
|
|
236
|
+
"""Type a jobs `get` response: flag the created→'job not found' race and the
|
|
237
|
+
failed+empty-error trap, and add a canonical status. Strictly fail-open and
|
|
238
|
+
additive — original ``status``/``error_code``/``error_message`` are untouched,
|
|
239
|
+
only ``_wrapper_note``/``status_canonical`` (leading-underscore / unused keys)
|
|
240
|
+
are added.
|
|
241
|
+
|
|
242
|
+
``_wrapper_note`` names BOTH indistinguishable causes and picks neither, so the
|
|
243
|
+
wrapper translates the response shape without inventing a reason. Overlaps the
|
|
244
|
+
mlspace-diagnose-failed-training-job skill (which also says error_code/error_message are deprecated —
|
|
245
|
+
ignore) ON PURPOSE: this is the inline defense for a caller who went straight to
|
|
246
|
+
get without loading the skill."""
|
|
247
|
+
if not isinstance(data, dict):
|
|
248
|
+
return data
|
|
249
|
+
out = dict(data)
|
|
250
|
+
raw = str(data.get("status", ""))
|
|
251
|
+
status = raw.casefold()
|
|
252
|
+
if status == "job not found":
|
|
253
|
+
out["_wrapper_note"] = (
|
|
254
|
+
"status='job not found' under HTTP 200 has two indistinguishable causes "
|
|
255
|
+
"and this body picks NEITHER. (1) The job was created <~2-3s ago and has "
|
|
256
|
+
"not propagated yet — retry the get in a few seconds BEFORE concluding "
|
|
257
|
+
"anything. (2) The name is wrong, or the job does not exist / was pruned by "
|
|
258
|
+
"retention. The API returns an identical body in both cases, so this 200 is "
|
|
259
|
+
"NOT confirmation that creation failed — do not create a duplicate."
|
|
260
|
+
)
|
|
261
|
+
elif (
|
|
262
|
+
status == "failed"
|
|
263
|
+
and data.get("error_code") in (0, None)
|
|
264
|
+
and data.get("error_message") in ("", None)
|
|
265
|
+
):
|
|
266
|
+
out["_wrapper_note"] = (
|
|
267
|
+
"error_code/error_message on GetJobStatusResponse are declared deprecated "
|
|
268
|
+
"constants (const 0 and const empty-string) — they are empty BY CONTRACT on "
|
|
269
|
+
"a failure, not by accident. The real reason is only in the logs. Call "
|
|
270
|
+
"action=logs (job_name + region, tail~100); the wrapper returns them in "
|
|
271
|
+
"chronological order with the cause at the end."
|
|
272
|
+
)
|
|
273
|
+
# Stable canonical spelling for get(lowercase) vs list(Capitalized) equality
|
|
274
|
+
# checks. Absent for 'job not found' and any unknown value (not in the enum).
|
|
275
|
+
canon = JOB_STATUS_CANON.get(status)
|
|
276
|
+
if canon is not None:
|
|
277
|
+
out["status_canonical"] = canon
|
|
278
|
+
return out
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _shrink_repos(data: Any) -> Any:
|
|
282
|
+
"""Elide each repo list element's heavy ``recent_image.extra_attrs`` (~39% of the
|
|
283
|
+
response) with a pointer marker. Fail-open: a non-list input, or any element not
|
|
284
|
+
shaped like a repo, passes through untouched; an EMPTY ``extra_attrs`` ({}) is
|
|
285
|
+
left as-is (nothing to omit). Does NOT mutate the input.
|
|
286
|
+
|
|
287
|
+
Scope is strictly the repos LIST. list_images (also an array of Image-Output with
|
|
288
|
+
extra_attrs) is deliberately OUT of scope — surfacing/rewriting it collides with
|
|
289
|
+
the pagination entity-splitting bug (08#4) — and get_image (kind='item') returns
|
|
290
|
+
extra_attrs untouched, which is exactly where the full config is wanted."""
|
|
291
|
+
if not isinstance(data, list):
|
|
292
|
+
return data
|
|
293
|
+
out: list[Any] = []
|
|
294
|
+
for repo in data:
|
|
295
|
+
if not isinstance(repo, dict):
|
|
296
|
+
out.append(repo)
|
|
297
|
+
continue
|
|
298
|
+
recent = repo.get("recent_image")
|
|
299
|
+
if (
|
|
300
|
+
isinstance(recent, dict)
|
|
301
|
+
and isinstance(recent.get("extra_attrs"), dict)
|
|
302
|
+
and recent["extra_attrs"]
|
|
303
|
+
):
|
|
304
|
+
new_recent = dict(recent)
|
|
305
|
+
new_recent["extra_attrs"] = _EXTRA_ATTRS_MARKER
|
|
306
|
+
new_repo = dict(repo)
|
|
307
|
+
new_repo["recent_image"] = new_recent
|
|
308
|
+
out.append(new_repo)
|
|
309
|
+
else:
|
|
310
|
+
out.append(repo)
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _listish_is_empty(d: Any) -> bool:
|
|
315
|
+
"""True for a dict that has at least one list value and whose EVERY list value is
|
|
316
|
+
empty. Fail-open: anything that isn't such a dict returns False."""
|
|
317
|
+
if not isinstance(d, dict):
|
|
318
|
+
return False
|
|
319
|
+
lists = [v for v in d.values() if isinstance(v, list)]
|
|
320
|
+
return bool(lists) and all(len(v) == 0 for v in lists)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _hoist_manifest_name(item: Any) -> Any:
|
|
324
|
+
"""If ``item`` is a k8s-manifest-shaped dict (has ``kind`` and a dict
|
|
325
|
+
``metadata`` with a str ``name``) and lacks a top-level ``name``, add
|
|
326
|
+
``name`` = ``metadata.name``. Strictly additive and fail-open: nothing is
|
|
327
|
+
removed or renamed; non-manifests are returned unchanged."""
|
|
328
|
+
if isinstance(item, dict) and "kind" in item and "name" not in item:
|
|
329
|
+
meta = item.get("metadata")
|
|
330
|
+
if isinstance(meta, dict) and isinstance(meta.get("name"), str):
|
|
331
|
+
return {**item, "name": meta["name"]}
|
|
332
|
+
return item
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _hoist_manifest_names(cleaned: Any) -> Any:
|
|
336
|
+
"""Apply :func:`_hoist_manifest_name` to a bare top-level list, or to each element
|
|
337
|
+
inside every list value of a dict. Other shapes pass through untouched."""
|
|
338
|
+
if isinstance(cleaned, list):
|
|
339
|
+
return [_hoist_manifest_name(x) for x in cleaned]
|
|
340
|
+
if isinstance(cleaned, dict):
|
|
341
|
+
return {
|
|
342
|
+
k: [_hoist_manifest_name(x) for x in v] if isinstance(v, list) else v
|
|
343
|
+
for k, v in cleaned.items()
|
|
344
|
+
}
|
|
345
|
+
return cleaned
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --- log normalisation -------------------------------------------------------
|
|
349
|
+
# The jobs `logs` endpoint is not chronological when `tail` is passed: it returns
|
|
350
|
+
# the newest N lines in REVERSE order, which inverts Python tracebacks (the
|
|
351
|
+
# ValueError lands above its own frames) and makes a top-down reader name the
|
|
352
|
+
# wrong root cause. Verified live 2026-08-03 with a controlled marker script.
|
|
353
|
+
_TS_RE = re.compile(r"^\s*(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})")
|
|
354
|
+
|
|
355
|
+
# The same endpoint encodes state as English prose inside an HTTP 200 body.
|
|
356
|
+
# Matching these is unavoidable; doing it once here beats every caller guessing.
|
|
357
|
+
_LOG_SENTINELS: tuple[tuple[str, str], ...] = (
|
|
358
|
+
(
|
|
359
|
+
"No such job",
|
|
360
|
+
"logs_expired: the platform no longer retains logs for this job (they age out "
|
|
361
|
+
"roughly 3 months after completion). The job itself still exists — `get` and "
|
|
362
|
+
"`get_params` work. This is NOT a missing job.",
|
|
363
|
+
),
|
|
364
|
+
(
|
|
365
|
+
"in queue. Try later",
|
|
366
|
+
"job_pending: the job is still queued and has produced no output yet. "
|
|
367
|
+
"Poll `get` until status leaves `pending`, then re-read the logs.",
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _log_lines_chronological(lines: list[str]) -> tuple[list[str], bool]:
|
|
373
|
+
"""Return ``lines`` oldest-first plus whether we had to reverse them.
|
|
374
|
+
|
|
375
|
+
Only timestamped lines vote, and only a clear majority flips the order, so a
|
|
376
|
+
log with no timestamps (or a mixed banner) is passed through untouched."""
|
|
377
|
+
stamps = [(i, m.group(1)) for i, line in enumerate(lines) if (m := _TS_RE.match(line))]
|
|
378
|
+
if len(stamps) < 3:
|
|
379
|
+
return lines, False
|
|
380
|
+
pairs = list(zip(stamps, stamps[1:], strict=False))
|
|
381
|
+
ascending = sum(1 for (_, a), (_, b) in pairs if b >= a)
|
|
382
|
+
descending = sum(1 for (_, a), (_, b) in pairs if b <= a)
|
|
383
|
+
if descending > ascending:
|
|
384
|
+
return list(reversed(lines)), True
|
|
385
|
+
return lines, False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# A leading RFC3339/ISO-ish timestamp (verbose logs prepend one), stripped so the
|
|
389
|
+
# extracted cause line reads cleanly.
|
|
390
|
+
_TS_PREFIX = re.compile(
|
|
391
|
+
r"^\s*\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?Z?\]?\s*"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _strip_leading_timestamp(line: str) -> str:
|
|
396
|
+
return _TS_PREFIX.sub("", line, count=1)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _log_notes(lines: list[str]) -> list[str]:
|
|
400
|
+
"""Scan the FULL chronological log for an mpirun/ORTE launch-failure banner and,
|
|
401
|
+
if found, return a single ``[likely_cause: ...]`` note. Scanning the full body
|
|
402
|
+
BEFORE any tail truncation is the point: the note survives even when the banner
|
|
403
|
+
is swept out of the tail. Strictly limited to the mpirun/ORTE family — ordinary
|
|
404
|
+
Python tracebacks are left alone (the chronological order already puts their
|
|
405
|
+
exception line at the end, and a generic parse risks mis-attribution)."""
|
|
406
|
+
for i, line in enumerate(lines):
|
|
407
|
+
low = line.lower()
|
|
408
|
+
if "mpirun was unable to launch" in low or "orte was unable to" in low:
|
|
409
|
+
cause = _strip_leading_timestamp(line).strip()
|
|
410
|
+
parts = [cause]
|
|
411
|
+
# the immediate continuation ("… or execute an executable:")
|
|
412
|
+
if i + 1 < len(lines) and "executable:" in lines[i + 1].lower():
|
|
413
|
+
parts.append(_strip_leading_timestamp(lines[i + 1]).strip())
|
|
414
|
+
# and the concrete "Executable: <path>" line a couple of lines down — the
|
|
415
|
+
# actual missing binary, which makes the cause complete instead of generic.
|
|
416
|
+
for j in range(i + 1, min(i + 6, len(lines))):
|
|
417
|
+
stripped = _strip_leading_timestamp(lines[j]).strip()
|
|
418
|
+
if stripped.lower().startswith("executable:"):
|
|
419
|
+
if stripped not in parts:
|
|
420
|
+
parts.append(stripped)
|
|
421
|
+
break
|
|
422
|
+
return ["[likely_cause: " + " | ".join(parts) + "]"]
|
|
423
|
+
return []
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _format_log(data: Any, log_tail: int, redact_keys: frozenset[str], list_cap: int) -> str:
|
|
427
|
+
if data is None:
|
|
428
|
+
return "(no log output)"
|
|
429
|
+
if isinstance(data, (dict, list)):
|
|
430
|
+
# some log endpoints wrap lines in JSON; clean (redact/elide/cap) then emit
|
|
431
|
+
return _to_json(_clean(data, redact_keys, list_cap))
|
|
432
|
+
text = str(data)
|
|
433
|
+
notes: list[str] = []
|
|
434
|
+
for needle, explanation in _LOG_SENTINELS:
|
|
435
|
+
if needle in text:
|
|
436
|
+
notes.append(f"[{explanation}]")
|
|
437
|
+
lines, reversed_ = _log_lines_chronological(text.splitlines())
|
|
438
|
+
if reversed_:
|
|
439
|
+
notes.append(
|
|
440
|
+
"[note: the API returned these lines newest-first; reordered to "
|
|
441
|
+
"chronological so causes precede effects]"
|
|
442
|
+
)
|
|
443
|
+
# Extract a typed cause note from the FULL chronological body BEFORE the tail
|
|
444
|
+
# truncation below, so the note survives even if the banner is swept out of it.
|
|
445
|
+
notes += _log_notes(lines)
|
|
446
|
+
if len(lines) > log_tail:
|
|
447
|
+
# keep the END of the chronological log — the failure lives there
|
|
448
|
+
lines = [f"[showing last {log_tail} of {len(lines)} lines]"] + lines[-log_tail:]
|
|
449
|
+
return "\n".join(notes + lines) if notes else "\n".join(lines)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def format_response(
|
|
453
|
+
data: Any,
|
|
454
|
+
response_format: str = "json",
|
|
455
|
+
*,
|
|
456
|
+
kind: str = "item",
|
|
457
|
+
redact_keys: tuple[str, ...] = (),
|
|
458
|
+
list_cap: int = 200,
|
|
459
|
+
log_tail: int = 200,
|
|
460
|
+
catalog_region: str | None = None,
|
|
461
|
+
) -> str:
|
|
462
|
+
"""Render an API result for a tool reply.
|
|
463
|
+
|
|
464
|
+
``catalog_region`` (kind=="catalog" only) optionally filters the configs
|
|
465
|
+
cross-product to a single region client-side; ``None`` keeps all regions."""
|
|
466
|
+
if kind == "log":
|
|
467
|
+
return _format_log(data, log_tail, frozenset(redact_keys), list_cap)
|
|
468
|
+
if kind == "catalog":
|
|
469
|
+
data = _shrink_configs(data, catalog_region)
|
|
470
|
+
elif kind == "job":
|
|
471
|
+
data = _normalise_job(data)
|
|
472
|
+
elif kind == "registry_repos":
|
|
473
|
+
data = _shrink_repos(data)
|
|
474
|
+
if data is None or data == "":
|
|
475
|
+
return "(empty response — the operation succeeded with no body)"
|
|
476
|
+
cleaned = _clean(data, frozenset(redact_keys), list_cap)
|
|
477
|
+
if kind == "list" and isinstance(cleaned, (dict, list)):
|
|
478
|
+
# surface an addressable name on manifest-shaped list items (metadata.name)
|
|
479
|
+
cleaned = _hoist_manifest_names(cleaned)
|
|
480
|
+
# flag an empty list that carries no completeness signal (real absence vs a
|
|
481
|
+
# filter/region/permission narrowing). Skip lists that DO signal a total
|
|
482
|
+
# (failed/total/count) so we don't wrongly claim "no signal".
|
|
483
|
+
if (
|
|
484
|
+
isinstance(cleaned, dict)
|
|
485
|
+
and _listish_is_empty(cleaned)
|
|
486
|
+
and not (set(cleaned) & {"failed", "total", "count"})
|
|
487
|
+
and "_scope_note" not in cleaned
|
|
488
|
+
):
|
|
489
|
+
cleaned["_scope_note"] = (
|
|
490
|
+
"The source returned empty and gives no completeness signal — this may "
|
|
491
|
+
"be a real absence OR a narrowing by filter/region/permissions, not "
|
|
492
|
+
"proof that nothing exists. Check region/status/search."
|
|
493
|
+
)
|
|
494
|
+
if isinstance(cleaned, (dict, list)):
|
|
495
|
+
if response_format == "markdown":
|
|
496
|
+
return _to_markdown(cleaned)
|
|
497
|
+
# the catalog is large even after dedup → emit compact JSON
|
|
498
|
+
if kind == "catalog":
|
|
499
|
+
return _to_json_compact(cleaned)
|
|
500
|
+
return _to_json(cleaned)
|
|
501
|
+
# scalar (str/int/bool/float)
|
|
502
|
+
return str(cleaned)
|