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.
Files changed (53) hide show
  1. mlspace_mcp/__init__.py +3 -0
  2. mlspace_mcp/__main__.py +125 -0
  3. mlspace_mcp/_status.py +18 -0
  4. mlspace_mcp/auth.py +109 -0
  5. mlspace_mcp/bodyspec.py +184 -0
  6. mlspace_mcp/client.py +170 -0
  7. mlspace_mcp/client_setup.py +235 -0
  8. mlspace_mcp/config.py +283 -0
  9. mlspace_mcp/errors.py +241 -0
  10. mlspace_mcp/formatting.py +502 -0
  11. mlspace_mcp/init_cli.py +356 -0
  12. mlspace_mcp/job_hooks.py +242 -0
  13. mlspace_mcp/jsonc.py +90 -0
  14. mlspace_mcp/overview.py +421 -0
  15. mlspace_mcp/registry.py +632 -0
  16. mlspace_mcp/resource_refs.py +188 -0
  17. mlspace_mcp/server.py +190 -0
  18. mlspace_mcp/setup_ui.py +103 -0
  19. mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
  20. mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
  21. mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
  22. mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
  23. mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
  24. mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
  25. mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
  26. mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
  27. mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
  28. mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
  29. mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
  30. mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
  31. mlspace_mcp/spec/body_schemas.json +7506 -0
  32. mlspace_mcp/spec/openapi.json +28347 -0
  33. mlspace_mcp/tls.py +46 -0
  34. mlspace_mcp/tools/__init__.py +1 -0
  35. mlspace_mcp/tools/allocations.py +92 -0
  36. mlspace_mcp/tools/async_inference.py +77 -0
  37. mlspace_mcp/tools/build_image.py +76 -0
  38. mlspace_mcp/tools/dalle.py +52 -0
  39. mlspace_mcp/tools/data_transfer.py +260 -0
  40. mlspace_mcp/tools/docker_registry.py +156 -0
  41. mlspace_mcp/tools/inference.py +88 -0
  42. mlspace_mcp/tools/jobs.py +155 -0
  43. mlspace_mcp/tools/notebooks.py +174 -0
  44. mlspace_mcp/tools/queues.py +202 -0
  45. mlspace_mcp/tools/resources.py +86 -0
  46. mlspace_mcp/tools/tensorboards.py +112 -0
  47. mlspace_mcp/tools/workspaces.py +92 -0
  48. mlspace_mcp/workspace_context.py +107 -0
  49. mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
  50. mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
  51. mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
  52. mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
  53. mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,356 @@
1
+ """Terminal onboarding shared by humans and explicitly configured automation."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import stat
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+
16
+ import httpx
17
+
18
+ from .client_setup import (
19
+ CLIENTS,
20
+ atomic_write,
21
+ detect_clients,
22
+ install_clients,
23
+ prepare_clients,
24
+ server_command,
25
+ )
26
+ from .config import DEFAULT_BASE_URL, LITERAL_DOTENV_MARKER, read_dotenv_file, user_config_file
27
+ from .setup_ui import choose_workspaces, display, masked_prompt
28
+ from .tls import safe_error as _safe_error
29
+ from .tls import tls_context
30
+
31
+ HEADER = f'# MLSpace credentials, written by mlspace-plugin setup.\n{LITERAL_DOTENV_MARKER}\n'
32
+
33
+
34
+ def _ask(label: str, secret: bool = True, hidden: bool = True) -> str:
35
+ while True:
36
+ value = masked_prompt(f' {label}: ') if hidden else input(f' {label}: ')
37
+ if re.match(r'^\s*(?:export\s+)?MLSPACE_\w+\s*=', value):
38
+ print('Enter only the value, without MLSPACE_...= or export.', file=sys.stderr)
39
+ elif value.strip():
40
+ return value if secret else value.strip()
41
+ else:
42
+ print('Required. Paste the value; asterisks show that it was entered.', file=sys.stderr)
43
+
44
+
45
+ def _resolve_base_url(target: Path, override: str | None) -> str:
46
+ saved = read_dotenv_file(target) if target.is_file() else {}
47
+ return ((override or '').strip() or os.environ.get('MLSPACE_BASE_URL', '').strip()
48
+ or str(saved.get('MLSPACE_BASE_URL') or DEFAULT_BASE_URL)).rstrip('/')
49
+
50
+
51
+ def _fetch_workspaces(key_id: str, key_secret: str, *, base_url: str,
52
+ ca_file: str = '') -> list[dict[str, str]]:
53
+ from .auth import TokenManager
54
+ from .client import MLSpaceClient
55
+
56
+ async def fetch() -> Any:
57
+ async with httpx.AsyncClient(timeout=30, verify=tls_context(ca_file)) as http:
58
+ tokens = TokenManager(http, base_url=base_url, client_id=key_id, client_secret=key_secret)
59
+ client = MLSpaceClient(http, tokens, base_url=base_url, api_key='', workspace_id='')
60
+ return await client.request('GET', '/public/v2/workspaces/v3/')
61
+
62
+ response = asyncio.run(fetch())
63
+ spaces = response.get('workspaces') if isinstance(response, dict) else None
64
+ if not isinstance(spaces, list) or not spaces:
65
+ raise ValueError('No workspaces visible to these access keys.')
66
+ catalogue = []
67
+ seen = set()
68
+ for row in spaces:
69
+ if not isinstance(row, dict) or not (key := row.get('id') or row.get('workspace_id')):
70
+ raise ValueError('Workspace API returned an entry without an ID.')
71
+ if str(key) in seen:
72
+ raise ValueError('Workspace API returned duplicate IDs.')
73
+ seen.add(str(key))
74
+ catalogue.append({'id': str(key), 'name': str(row.get('name') or key),
75
+ 'project_name': str(row.get('project_name') or '')})
76
+ return sorted(catalogue, key=lambda r: (r['name'].casefold(), r['project_name'], r['id']))
77
+ def _write(path: Path, values: dict[str, str]) -> None:
78
+ """Atomically replace ``path`` with a 0600 dotenv file."""
79
+ if path.is_symlink():
80
+ raise ValueError("Credentials target must not be a symlink.")
81
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
82
+ if path.parent == user_config_file().parent:
83
+ path.parent.chmod(0o700)
84
+ fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
85
+ temporary = Path(temporary_name)
86
+ try:
87
+ os.fchmod(fd, stat.S_IRUSR | stat.S_IWUSR)
88
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
89
+ body = "".join(
90
+ f"MLSPACE_{name}={json.dumps(value, ensure_ascii=False)}\n"
91
+ for name, value in values.items()
92
+ )
93
+ fh.write(HEADER + body)
94
+ fh.flush()
95
+ os.fsync(fh.fileno())
96
+ os.replace(temporary, path)
97
+ except BaseException:
98
+ try:
99
+ os.close(fd)
100
+ except OSError:
101
+ pass
102
+ temporary.unlink(missing_ok=True)
103
+ raise
104
+
105
+
106
+ def _verify(values: dict[str, str], *, base_url: str) -> str | None:
107
+ """Check selected workspace details and IDs. Returns a safe error, or None.
108
+
109
+ The caller preserves the previous file if any selected workspace fails.
110
+ """
111
+ import asyncio
112
+
113
+ import httpx
114
+ from pydantic import SecretStr
115
+
116
+ from .auth import TokenManager
117
+ from .config import Settings
118
+ from .workspace_context import WorkspaceClients
119
+
120
+ async def run() -> str | None:
121
+ try:
122
+ # Explicit values keep old environment selectors out of this check.
123
+ settings = Settings( # type: ignore[call-arg]
124
+ _env_file=None,
125
+ base_url=base_url,
126
+ client_id=values["CLIENT_ID"],
127
+ client_secret=SecretStr(values["CLIENT_SECRET"]),
128
+ api_key=SecretStr(values.get("API_KEY", "")),
129
+ workspace_id=values.get("WORKSPACE_ID", ""),
130
+ workspaces=json.loads(values.get("WORKSPACES", "[]")),
131
+ namespace="",
132
+ )
133
+ async with httpx.AsyncClient(timeout=30, verify=tls_context(values.get("CA_FILE", ""))) as http:
134
+ tokens = TokenManager(
135
+ http, base_url=base_url, client_id=values["CLIENT_ID"],
136
+ client_secret=values["CLIENT_SECRET"],
137
+ )
138
+ clients = WorkspaceClients(settings, http, tokens)
139
+ verified = 0
140
+ errors = []
141
+ for selection in clients.selected:
142
+ try:
143
+ client = await clients.resolve(selection.id)
144
+ data = await client.request(
145
+ "GET", "/public/v2/workspaces/v3/{workspace_id}",
146
+ path_params={"workspace_id": selection.id},
147
+ )
148
+ if not isinstance(data, dict) or data.get("id") != selection.id:
149
+ errors.append(f"{selection.id}: workspace identity was not confirmed")
150
+ continue
151
+ verified += 1
152
+ except Exception as exc: # noqa: BLE001 — verify every selected workspace
153
+ errors.append(f"{selection.id}: {_safe_error(exc)}")
154
+ print(
155
+ f" checked: {verified}/{len(clients.selected)} selected workspace(s) verified "
156
+ "(workspace details and ID)",
157
+ file=sys.stderr,
158
+ )
159
+ return "; ".join(errors) or None
160
+ except Exception as exc: # noqa: BLE001 — report failures without exposing transport details
161
+ return _safe_error(exc)
162
+
163
+ return asyncio.run(run())
164
+
165
+
166
+
167
+ def _clients(requested: list[str], paths: list[str], non_interactive: bool) -> dict[str, str]:
168
+ detected = detect_clients()
169
+ for item in paths:
170
+ name, separator, path = item.partition('=')
171
+ executable = Path(path).expanduser().absolute()
172
+ if not separator or name not in CLIENTS or not executable.is_file() or not os.access(executable, os.X_OK):
173
+ raise ValueError('Use --client-path CLIENT=/absolute/path/to/executable.')
174
+ detected[name] = str(executable)
175
+ if requested:
176
+ if any(name not in detected for name in requested):
177
+ raise ValueError('Requested client not found. Install it or use --client-path CLIENT=/path.')
178
+ return {name: detected[name] for name in requested}
179
+ if len(detected) == 1:
180
+ print('Client detected: ' + next(iter(detected)), file=sys.stderr)
181
+ return detected
182
+ if not detected:
183
+ if non_interactive:
184
+ raise ValueError('No coding client found; use --client-path or --config-only.')
185
+ print('No Claude Code, Codex or OpenCode found. Enter CLIENT=/absolute/path, '
186
+ 'or press Enter to stop and install a client.', file=sys.stderr)
187
+ custom = input('Client executable: ').strip()
188
+ if custom:
189
+ return _clients([], [custom], True)
190
+ raise ValueError('Install Claude Code, Codex or OpenCode, then run setup again. '
191
+ 'For other MCP clients use --config-only.')
192
+ if non_interactive:
193
+ raise ValueError('Multiple clients found; select with --client (repeat for several).')
194
+ print('Choose clients: type to search, Space to select, Enter to continue.', file=sys.stderr)
195
+ selected = choose_workspaces([{'id': name, 'name': name, 'project_name': path}
196
+ for name, path in detected.items()])
197
+ return {row['id']: detected[row['id']] for row in selected}
198
+
199
+
200
+ def run_init(path: Path | None = None, *, force: bool = False, verify: bool = True,
201
+ base_url: str | None = None, config_only: bool = False,
202
+ non_interactive: bool = False, from_env: bool = False,
203
+ credentials_file: Path | None = None, workspace_ids: list[str] | None = None,
204
+ clients: list[str] | None = None, client_paths: list[str] | None = None,
205
+ ca_file: Path | None = None, replace_credentials: bool = False,
206
+ list_workspaces: bool = False, search: str = '', offset: int = 0, limit: int = 20) -> int:
207
+ try:
208
+ return _run_init(path, force=force, verify=verify, base_url=base_url,
209
+ config_only=config_only, non_interactive=non_interactive,
210
+ from_env=from_env, credentials_file=credentials_file,
211
+ workspace_ids=workspace_ids or [], clients=clients or [],
212
+ client_paths=client_paths or [], ca_file=ca_file,
213
+ replace_credentials=replace_credentials, list_workspaces=list_workspaces,
214
+ search=search, offset=offset, limit=limit)
215
+ except KeyboardInterrupt:
216
+ print('\nSetup cancelled; previous credentials preserved unless saving already completed.', file=sys.stderr)
217
+ return 130
218
+ except EOFError:
219
+ print('\nNo interactive input. Run `mlspace-plugin setup` yourself in a terminal (SSH works). '
220
+ 'Agents: use INSTALL_AGENT.md; never ask for keys in chat.', file=sys.stderr)
221
+ return 1
222
+ except (ValueError, OSError, httpx.HTTPError) as exc:
223
+ # Only our own ValueErrors carry displayable text. Third-party errors may echo keys.
224
+ message = str(exc) if type(exc) is ValueError else _safe_error(exc)
225
+ print('Setup failed: ' + display(message), file=sys.stderr)
226
+ return 1
227
+
228
+
229
+ def _run_init(path: Path | None, **options: Any) -> int:
230
+ target = (path or user_config_file()).expanduser().absolute()
231
+ listing = options['list_workspaces']
232
+ non_interactive = options['non_interactive']
233
+ if not non_interactive and not sys.stdin.isatty():
234
+ raise EOFError
235
+ if options['from_env'] and options['credentials_file']:
236
+ raise ValueError('Choose either --from-env or --credentials-file.')
237
+ if target.exists() and not options['force'] and not listing:
238
+ raise ValueError('Configuration already exists. Use --force to reconfigure; saved keys will be reused.')
239
+ if not options['verify'] and (not options['config_only'] or not options['workspace_ids']):
240
+ raise ValueError('--no-verify requires --config-only and explicit --workspace IDs; result is unverified.')
241
+ if options['offset'] < 0 or not 1 <= options['limit'] <= 100:
242
+ raise ValueError('Use offset >= 0 and limit between 1 and 100.')
243
+ saved = read_dotenv_file(target) if target.is_file() else {}
244
+ endpoint = _resolve_base_url(target, options['base_url'])
245
+ parsed = urlsplit(endpoint)
246
+ if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
247
+ raise ValueError('API endpoint must be an HTTPS URL without credentials, query or fragment.')
248
+ if os.environ.get('SSL_CERT_DIR'):
249
+ raise ValueError('SSL_CERT_DIR cannot be carried into client startup. Unset it for setup and use '
250
+ '--ca-file /path/to/trusted-ca.pem or install the CA in the system trust store.')
251
+ selected_clients = {} if options['config_only'] or listing else _clients(
252
+ options['clients'], options['client_paths'], non_interactive)
253
+ command = server_command() if selected_clients else []
254
+ if selected_clients:
255
+ prepare_clients(selected_clients, target, command)
256
+ if not listing:
257
+ print('[1/4] Access keys. Paste values only; asterisks hide both fields. '
258
+ 'Keys stay in your user config, not in chat.', file=sys.stderr)
259
+ if options['credentials_file']:
260
+ source = read_dotenv_file(options['credentials_file'].expanduser())
261
+ elif options['from_env']:
262
+ source = dict(os.environ)
263
+ elif not options['replace_credentials'] and str(saved.get('MLSPACE_BASE_URL') or DEFAULT_BASE_URL).rstrip('/') == endpoint:
264
+ source = saved
265
+ else:
266
+ source = {}
267
+ keys = {}
268
+ for name, label in [('CLIENT_ID', 'Cloud.ru Key ID'), ('CLIENT_SECRET', 'Cloud.ru Key Secret')]:
269
+ value = source.get('MLSPACE_' + name)
270
+ if not value or not value.strip():
271
+ if non_interactive or options['credentials_file'] or options['from_env']:
272
+ raise ValueError('Missing credentials for this endpoint. Run setup in your terminal, '
273
+ 'or explicitly select --from-env / --credentials-file.')
274
+ value = _ask(label, secret=name == 'CLIENT_SECRET')
275
+ keys[name] = value
276
+ ca = str(options['ca_file'] or saved.get('MLSPACE_CA_FILE') or os.environ.get('SSL_CERT_FILE') or '')
277
+ ca = str(Path(ca).expanduser().absolute()) if ca else ''
278
+ if not listing:
279
+ print('[2/4] Checking TLS and loading workspaces…', file=sys.stderr)
280
+ if options['verify'] or listing:
281
+ while True:
282
+ try:
283
+ rows = _fetch_workspaces(keys['CLIENT_ID'], keys['CLIENT_SECRET'], base_url=endpoint, ca_file=ca)
284
+ break
285
+ except ValueError:
286
+ raise
287
+ except Exception as exc:
288
+ error = _safe_error(exc)
289
+ if not non_interactive and 'TLS certificate' in error:
290
+ print(error, file=sys.stderr)
291
+ ca = input('Trusted CA PEM path from your administrator (Enter to cancel): ').strip()
292
+ if not ca:
293
+ raise ValueError('TLS setup stopped; no credentials saved.') from None
294
+ ca = str(Path(ca).expanduser().absolute())
295
+ continue
296
+ raise ValueError(error) from None
297
+ else:
298
+ rows = [{'id': key, 'name': key, 'project_name': ''} for key in dict.fromkeys(options['workspace_ids'])]
299
+ if listing:
300
+ terms = options['search'].casefold().split()
301
+ matches = [row for row in rows if all(term in ' '.join(row.values()).casefold() for term in terms)]
302
+ offset, limit = options['offset'], options['limit']
303
+ print(json.dumps({'total': len(matches), 'offset': offset, 'limit': limit,
304
+ 'has_more': offset + limit < len(matches), 'workspaces': matches[offset:offset+limit]}, ensure_ascii=False))
305
+ return 0
306
+ requested = options['workspace_ids']
307
+ if requested:
308
+ if set(requested) - {row['id'] for row in rows}:
309
+ raise ValueError('Requested workspace IDs are not in the accessible workspace list.')
310
+ selected = [row for row in rows if row['id'] in requested]
311
+ elif non_interactive:
312
+ raise ValueError('Select workspace IDs with --workspace. Use --list-workspaces --search to find them.')
313
+ elif len(rows) == 1:
314
+ selected = rows
315
+ else:
316
+ print('Find your workspaces: type a name, project or ID. Space selects; Enter finishes.', file=sys.stderr)
317
+ old = json.loads(str(saved.get('MLSPACE_WORKSPACES') or '[]'))
318
+ selected = choose_workspaces(rows, [row['id'] for row in old if isinstance(row, dict) and 'id' in row])
319
+ values = {key.removeprefix('MLSPACE_'): value for key, value in saved.items()
320
+ if key.startswith('MLSPACE_') and value is not None
321
+ and key not in {'MLSPACE_API_KEY', 'MLSPACE_WORKSPACE_ID', 'MLSPACE_WORKSPACES', 'MLSPACE_NAMESPACE'}}
322
+ values.update(keys)
323
+ values.update(BASE_URL=endpoint, CA_FILE=ca,
324
+ WORKSPACES=json.dumps(selected, ensure_ascii=False, separators=(',', ':')))
325
+ if len(selected) == 1:
326
+ values['WORKSPACE_ID'] = selected[0]['id']
327
+ print(f'[3/4] Verifying {len(selected)} selected workspace(s)…', file=sys.stderr)
328
+ if options['verify']:
329
+ problem = _verify(values, base_url=endpoint)
330
+ if problem:
331
+ raise ValueError(problem)
332
+ if ca:
333
+ # Persist the validated public CA independently of /tmp and shell environment.
334
+ content = Path(ca).read_text(encoding='ascii')
335
+ destination = target.parent / ('ca-' + hashlib.sha256(content.encode()).hexdigest()[:16] + '.pem')
336
+ tls_context(ca)
337
+ atomic_write(destination, content)
338
+ values['CA_FILE'] = str(destination)
339
+ installed = []
340
+ if selected_clients:
341
+ print('[4/4] Connecting MCP and installing skills…', file=sys.stderr)
342
+ installed = install_clients(selected_clients, target, command)
343
+ _write(target, values)
344
+ print(f'Credentials saved: {target} (0600).', file=sys.stderr)
345
+ if not options['verify']:
346
+ print('UNVERIFIED configuration saved by explicit request. No client was connected.', file=sys.stderr)
347
+ return 0
348
+ if selected_clients:
349
+ for client in installed:
350
+ print(f' {client}: MCP registered and skills installed.', file=sys.stderr)
351
+ print('Done. Open a new client session (restart it if MCP is not visible). '
352
+ 'The client starts the MCP server automatically.\n'
353
+ 'Try: «Покажи выбранные воркспейсы и запущенные задачи, ничего не меняй».', file=sys.stderr)
354
+ else:
355
+ print('API/TLS verified. Config-only setup complete; client integration was not requested.', file=sys.stderr)
356
+ return 0
@@ -0,0 +1,242 @@
1
+ """Job-specific preflight, deletion guard and response compatibility hooks.
2
+
3
+ Leaf module: imports no dispatcher or domain declarations.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ._status import JOB_STATUS_CANON
11
+ from .errors import MLSpaceError
12
+
13
+ # Earlier API observations motivated these compatibility guards. They are not
14
+ # current-tenant evidence. Binary commands are documented; non-binary entrypoints
15
+ # retain regional NFS path checks. The API cannot stat a file or verify an image command.
16
+ NFS_SCRIPT_PREFIX = "/home/jovyan"
17
+
18
+
19
+ def _preflight_job_region(values: dict[str, Any]) -> None:
20
+ """Refuse a job `run` that leaves `region` to the API default.
21
+
22
+ Runs UNCONDITIONALLY — the region trap does not depend on how `script` looks,
23
+ so this must not sit inside the path branch (a missing or empty script would
24
+ otherwise skip it and let the default region through).
25
+
26
+ A top-level ``region=`` is accepted as well and copied into the body: `run`
27
+ carries no top-level region parameter, so it would otherwise be dropped
28
+ silently and the caller refused for something they did supply.
29
+ """
30
+ body = values.get("body")
31
+ in_body = body.get("region") if isinstance(body, dict) else None
32
+ if isinstance(in_body, str) and in_body.strip():
33
+ return
34
+ top = values.get("region")
35
+ if isinstance(top, str) and top.strip():
36
+ if isinstance(body, dict):
37
+ body["region"] = top
38
+ return
39
+ raise MLSpaceError(
40
+ "`region` must be explicit for a job submission. The API default (DGX2-MT) "
41
+ "can select a different /home/jovyan volume and fail only after allocating "
42
+ "a node.",
43
+ hint=(
44
+ "Set body.region. For an NFS entrypoint, verify it from a notebook or "
45
+ "transfer attached to that same region because the Public API cannot stat "
46
+ "the target region's NFS; for type=binary, verify that the command or "
47
+ "executable is available in the selected image."
48
+ ),
49
+ )
50
+
51
+
52
+ def _preflight_job_script(values: dict[str, Any]) -> None:
53
+ """Apply only the script checks supported for the selected job type.
54
+
55
+ Empty strings are unsafe for every type. ``binary`` accepts a command or executable
56
+ path, so the wrapper must preserve a non-empty value without pretending it can
57
+ resolve the command in the selected image. Other types retain the established NFS
58
+ path-shape checks. Existence is never claimed: the Public API has no remote stat.
59
+ """
60
+ body = values.get("body")
61
+ script = body.get("script") if isinstance(body, dict) else values.get("script")
62
+ job_type = body.get("type") if isinstance(body, dict) else values.get("type")
63
+ if script is None:
64
+ return # absent/None: the API's own 422 is exemplary, don't duplicate it
65
+ if not isinstance(script, str):
66
+ return # the spec-driven body lint reports the type error in the real handler
67
+ if not script.strip():
68
+ raise MLSpaceError(
69
+ f"`script` is empty ({script!r}). The API would accept this and return a "
70
+ "job_name, then allocate a node and fail within seconds with an empty "
71
+ "error_message.",
72
+ hint=(
73
+ "Provide a non-empty entrypoint. For type=binary this may be a command "
74
+ "or executable path; for other job types use the documented NFS script "
75
+ f"path under {NFS_SCRIPT_PREFIX}/…."
76
+ ),
77
+ )
78
+ if job_type == "binary":
79
+ return
80
+ if any(seg == ".." for seg in script.split("/")):
81
+ # a '..' segment escapes the NFS home the prefix check exists to enforce:
82
+ # "/home/jovyan/../tmp/x.sh" resolves to /tmp/x.sh
83
+ problem = f"`script` contains a '..' path segment ({script!r})"
84
+ elif script == NFS_SCRIPT_PREFIX or script.endswith("/"):
85
+ problem = f"`script` points at a directory, not a file ({script!r})"
86
+ elif not script.startswith("/"):
87
+ problem = f"`script` is a relative path ({script!r})"
88
+ elif not script.startswith(NFS_SCRIPT_PREFIX + "/"):
89
+ problem = f"`script` points outside the region's NFS ({script!r})"
90
+ else:
91
+ return
92
+ raise MLSpaceError(
93
+ f"{problem}. The API would accept this and return a job_name, then the job "
94
+ f"would allocate a node and fail within seconds with an empty error_message.",
95
+ hint=(
96
+ f"The script must already exist on the chosen region's NFS home, under "
97
+ f"{NFS_SCRIPT_PREFIX}/… — upload it there first, then pass the absolute "
98
+ f"path to the file (e.g. {NFS_SCRIPT_PREFIX}/myproject/train.sh). The same "
99
+ "path in a different notebook region is a different volume."
100
+ ),
101
+ )
102
+
103
+
104
+ def preflight_job_run(values: dict[str, Any]) -> None:
105
+ """Composed preflight for ``POST /jobs`` (one dict key, two independent concerns).
106
+
107
+ Region first — it applies to every submission; then the script shape.
108
+ """
109
+ _preflight_job_region(values)
110
+ _preflight_job_script(values)
111
+
112
+
113
+ def _preflight_jobs_list_region(values: dict[str, Any]) -> None:
114
+ """Refuse a jobs `list` with no region and explain the silent count=0.
115
+
116
+ Live finding: ``GET /jobs`` without ``region`` returns 200 ``{"count":0,
117
+ "jobs":[]}`` for EVERY region, while a scoped ``GET /jobs?region=<r>`` returns the
118
+ real, non-zero count — so an empty result here proves the scope is missing, not
119
+ that the workspace has no jobs. (The exact per-region count is a fast-moving live
120
+ datum, so it is deliberately NOT quoted here.) A deliberate refusal (like the
121
+ /home/jovyan precondition) beats leaking a false emptiness to the model.
122
+ """
123
+ if not values.get("region"):
124
+ raise MLSpaceError(
125
+ 'GET /jobs without `region` returns 200 {"count":0,"jobs":[]} for every '
126
+ "region; with a region it returns the real (non-zero) count — an empty "
127
+ "result here does NOT prove there are no jobs, only that the region scope "
128
+ "is missing.",
129
+ hint="Pass region=<e.g. DGX2-MT|A100-MT|SR003|SR005|SR006|SR008> (examples, "
130
+ "not a closed set). For existing jobs, use mlspace_jobs_overview, which "
131
+ "combines regions from configs and workspace allocations; configs alone "
132
+ "can omit allocated regions. Jobs live per region.",
133
+ )
134
+
135
+
136
+ def _preflight_jobs_list_status(values: dict[str, Any]) -> None:
137
+ """Normalise a jobs `list` status filter to the enum casing the API accepts.
138
+
139
+ The list endpoint filters on the Capitalized ``JobStatus-Input`` enum, but a
140
+ model naturally passes get's lowercase status (e.g. 'failed'). This is a pure
141
+ translation to the value the API itself requires (precedent: the silent limit
142
+ clamp in the handler), NOT invention: unknown values are left untouched so the
143
+ API returns its own exemplary 422.
144
+ """
145
+ raw = values.get("status")
146
+ if raw is None:
147
+ return
148
+ if isinstance(raw, (list, tuple)):
149
+ values["status"] = [JOB_STATUS_CANON.get(str(s).casefold(), s) for s in raw]
150
+ else:
151
+ values["status"] = JOB_STATUS_CANON.get(str(raw).casefold(), raw)
152
+
153
+
154
+ def preflight_jobs_list(values: dict[str, Any]) -> None:
155
+ """Composed preflight for ``GET /jobs`` (one dict key, two concerns).
156
+
157
+ Region enforcement first (may refuse), then status-casing normalisation — the
158
+ two live as separate, independently-testable helpers but share a single
159
+ PREFLIGHT registration to avoid a key collision.
160
+ """
161
+ _preflight_jobs_list_region(values)
162
+ _preflight_jobs_list_status(values)
163
+
164
+
165
+ def _precheck_unconfirmed(job_name: Any, reason: str) -> MLSpaceError:
166
+ """The honest fail-CLOSED refusal shared by every 'could not confirm existence'
167
+ branch of the delete guard."""
168
+ return MLSpaceError(
169
+ f"Could not confirm that job {job_name} exists before deleting it ({reason}). "
170
+ "The delete was NOT sent. MLSpace replies status=\"deleted\" with a fresh "
171
+ "deleted_at even for a name that does not exist, so issuing the DELETE without a "
172
+ "confirmed precheck could report a FALSE 'deleted'.",
173
+ hint="Retry, or confirm the exact job_name via `list` (with region) first, then "
174
+ "re-run the delete.",
175
+ )
176
+
177
+
178
+ async def guard_job_delete(client: Any, values: dict[str, Any]) -> None:
179
+ """Refuse a jobs delete unless a status precheck CONFIRMS the job exists.
180
+
181
+ Live finding: the delete endpoint replies 200 ``status="deleted"`` with a fresh
182
+ ``deleted_at`` even for a name that never existed — a FALSE confirmation. One
183
+ cheap status GET before the destructive call is the gate. This guard is
184
+ FAIL-CLOSED (a safety validator, not a cosmetic transform): the DELETE proceeds
185
+ ONLY when the precheck returns a concrete live status. A positive
186
+ 'job not found', a precheck error (timeout/403/500), or an unexpected shape all
187
+ refuse — because none of them proves the job is really there to delete.
188
+
189
+ Edge case (05#11): a job created <~2s ago also answers 'job not found' and is
190
+ blocked here — fail-safe (better to refuse than to falsely confirm), covered by
191
+ the retry-after-propagation hint.
192
+ """
193
+ job_name = values.get("job_name")
194
+ try:
195
+ body = await client.request(
196
+ "GET", "/public/v2/jobs/{job_name}", path_params={"job_name": str(job_name)}
197
+ )
198
+ except MLSpaceError as exc:
199
+ # precheck could not reach a verdict (4xx/5xx/timeout/odd body already mapped
200
+ # to MLSpaceError) → fail-closed rather than let a blind DELETE claim success.
201
+ raise _precheck_unconfirmed(job_name, f"precheck failed: {exc.message}") from exc
202
+ except Exception as exc: # defensive: any non-mapped failure is still unconfirmed
203
+ raise _precheck_unconfirmed(job_name, f"precheck error: {type(exc).__name__}") from exc
204
+
205
+ raw_status = body.get("status") if isinstance(body, dict) else None
206
+ if not isinstance(raw_status, str):
207
+ # null / object / missing: str() would turn these into a truthy "none"/"{...}"
208
+ raise _precheck_unconfirmed(job_name, "precheck returned an unexpected shape")
209
+ status = raw_status.strip().casefold()
210
+ if status == "job not found":
211
+ raise MLSpaceError(
212
+ f"Job {job_name} does not exist (its status endpoint returns 'job not found') "
213
+ "— nothing to delete. The delete endpoint would still reply status=\"deleted\" "
214
+ "with a fresh deleted_at for this non-existent job; that is a FALSE confirmation.",
215
+ hint="Verify the exact job_name via `list` (with region); if you created it "
216
+ "<~2s ago, wait for it to propagate and retry.",
217
+ )
218
+ if not status:
219
+ raise _precheck_unconfirmed(job_name, "precheck returned no status")
220
+ # else: a concrete live status (pending/running/failed/…) → existence confirmed.
221
+
222
+
223
+ def postprocess_job_delete(data: Any, values: dict[str, Any]) -> Any:
224
+ """Annotate a jobs delete so the caller does not read 'deleted' as a purge.
225
+
226
+ Live finding: after a 200 ``status="deleted"`` the job KEEPS appearing in
227
+ `list`/`get` with a terminal status, and each repeated delete returns a fresh
228
+ ``deleted_at``. MLSpace delete is therefore LOGICAL (mark-deleted/stopped), not a
229
+ history purge. We add a leading-underscore note and leave the original fields
230
+ untouched. Fail-open on any other shape."""
231
+ if isinstance(data, dict) and str(data.get("status", "")).strip().casefold() == "deleted":
232
+ return {
233
+ **data,
234
+ "_wrapper_note": (
235
+ "MLSpace delete is LOGICAL: status='deleted' with a fresh deleted_at means "
236
+ "the job was marked deleted/stopped, NOT purged. It can still appear in "
237
+ "`list`/`get` with a terminal status (completed/failed/stopped) afterward, "
238
+ "and a repeated delete returns a fresh deleted_at again — do NOT treat this "
239
+ "as proof the job is gone from history."
240
+ ),
241
+ }
242
+ return data