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
mlspace_mcp/jsonc.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Small JSONC editor: validate fully, replace only the requested object's member."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_TOKEN = re.compile(r'"(?:[^"\\]|\\.)*"|//[^\n]*|/\*[\s\S]*?\*/|\s+|[^\s"{}\[\],:]+|[{}\[\],:]')
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _tokens(text: str) -> list[re.Match[str]]:
|
|
12
|
+
tokens = list(_TOKEN.finditer(text))
|
|
13
|
+
if ''.join(t.group() for t in tokens) != text:
|
|
14
|
+
raise ValueError('Invalid JSONC')
|
|
15
|
+
return [t for t in tokens if not t.group().isspace() and not t.group().startswith(('//', '/*'))]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _unique(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
19
|
+
result = {}
|
|
20
|
+
for key, value in pairs:
|
|
21
|
+
if key in result:
|
|
22
|
+
raise ValueError('Duplicate configuration key; resolve it before setup.')
|
|
23
|
+
result[key] = value
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def loads(text: str) -> dict[str, Any]:
|
|
28
|
+
tokens = _tokens(text)
|
|
29
|
+
cleaned = ''.join(t.group() for i, t in enumerate(tokens)
|
|
30
|
+
if not (t.group() == ',' and i + 1 < len(tokens)
|
|
31
|
+
and tokens[i+1].group() in ('}', ']')))
|
|
32
|
+
value = json.loads(cleaned, object_pairs_hook=_unique)
|
|
33
|
+
if not isinstance(value, dict):
|
|
34
|
+
raise ValueError('Configuration must be an object.')
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _members(text: str) -> tuple[dict[str, tuple[int, int]], int, bool]:
|
|
39
|
+
tokens = _tokens(text)
|
|
40
|
+
members = {}
|
|
41
|
+
i = 1
|
|
42
|
+
while i < len(tokens) and tokens[i].group() != '}':
|
|
43
|
+
key = json.loads(tokens[i].group())
|
|
44
|
+
i += 2 # key and colon
|
|
45
|
+
start = tokens[i].start()
|
|
46
|
+
depth = 0
|
|
47
|
+
while i < len(tokens):
|
|
48
|
+
token = tokens[i].group()
|
|
49
|
+
if token in ('{', '['):
|
|
50
|
+
depth += 1
|
|
51
|
+
elif token in ('}', ']'):
|
|
52
|
+
if depth == 0:
|
|
53
|
+
break
|
|
54
|
+
depth -= 1
|
|
55
|
+
elif token == ',' and depth == 0:
|
|
56
|
+
break
|
|
57
|
+
end = tokens[i].end()
|
|
58
|
+
i += 1
|
|
59
|
+
members[key] = (start, end)
|
|
60
|
+
if tokens[i].group() == ',':
|
|
61
|
+
i += 1
|
|
62
|
+
return members, tokens[i].start(), tokens[i-1].group() == ','
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_member(text: str, keys: list[str], value: Any) -> str:
|
|
66
|
+
data = loads(text) # validates nesting, duplicate keys, syntax before any modification
|
|
67
|
+
key = keys[0]
|
|
68
|
+
members, closing, trailing_comma = _members(text)
|
|
69
|
+
if key in members:
|
|
70
|
+
start, end = members[key]
|
|
71
|
+
if len(keys) > 1:
|
|
72
|
+
replacement = set_member(text[start:end], keys[1:], value)
|
|
73
|
+
else:
|
|
74
|
+
if data[key] == value:
|
|
75
|
+
return text
|
|
76
|
+
replacement = json.dumps(value, ensure_ascii=False, indent=2)
|
|
77
|
+
return text[:start] + replacement + text[end:]
|
|
78
|
+
nested = value
|
|
79
|
+
for part in reversed(keys[1:]):
|
|
80
|
+
nested = {part: nested}
|
|
81
|
+
prefix = ',' if members and not trailing_comma else ''
|
|
82
|
+
# Insert comma immediately after the preceding value, before any // comment.
|
|
83
|
+
if prefix:
|
|
84
|
+
last_end = list(members.values())[-1][1]
|
|
85
|
+
text = text[:last_end] + ',' + text[last_end:]
|
|
86
|
+
closing += 1
|
|
87
|
+
insertion = '\n ' + json.dumps(key) + ': ' + json.dumps(nested, ensure_ascii=False, indent=2) + '\n'
|
|
88
|
+
result = text[:closing] + insertion + text[closing:]
|
|
89
|
+
loads(result)
|
|
90
|
+
return result
|
mlspace_mcp/overview.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""Bounded read inventories. Fetch coverage is not an attestation of API visibility."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
14
|
+
from mcp.types import ToolAnnotations
|
|
15
|
+
|
|
16
|
+
from ._status import JOB_STATUS_CANON
|
|
17
|
+
from .config import Settings
|
|
18
|
+
from .errors import MLSpaceError
|
|
19
|
+
from .resource_refs import response_references
|
|
20
|
+
|
|
21
|
+
PRIORITIES = ('shared-low', 'shared-medium', 'shared-cluster', 'low', 'medium', 'high')
|
|
22
|
+
# Positive field selection: inventory endpoints can contain S3 credentials, env, etc.
|
|
23
|
+
_FIELDS = frozenset(('id uid uuid name job_name job_id notebook_uuid namespace workspace '
|
|
24
|
+
'workspace_id region status phase state author job_author user_email node_name nodes '
|
|
25
|
+
'instance_type instanceType creationTimestamp created_at type priority gpu_count '
|
|
26
|
+
'unschedulable_worker_count n_workers launch_order allocation_id queue_id queue_label '
|
|
27
|
+
'allocation_label allocation_name queue_name created_dt updated_dt completed_dt job_desc '
|
|
28
|
+
'unschedulable resource_usage queue gpu_type cpu memory ram gpu used total allocated '
|
|
29
|
+
'requested available limit limits count postfix instance_types jobs notebooks').split())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _inventory(value: Any) -> Any:
|
|
33
|
+
if isinstance(value, dict):
|
|
34
|
+
return {k: _inventory(v) for k, v in value.items() if k in _FIELDS}
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
return [_inventory(v) for v in value]
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _segment(value: str) -> str:
|
|
41
|
+
if not value.strip() or value in {'.', '..'} or re.search(r'[/\\?#%\x00-\x1f\x7f]', value):
|
|
42
|
+
raise MLSpaceError('Invalid opaque resource address.')
|
|
43
|
+
return quote(value, safe='')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rows(data: Any, key: str) -> list[dict]:
|
|
47
|
+
if not isinstance(data, dict) or not isinstance(data.get(key), list):
|
|
48
|
+
raise MLSpaceError('malformed_response')
|
|
49
|
+
rows = data[key]
|
|
50
|
+
if not all(isinstance(row, dict) for row in rows):
|
|
51
|
+
raise MLSpaceError('malformed_response')
|
|
52
|
+
return rows
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _node_facts(row: dict) -> None:
|
|
56
|
+
"""Validate the operational facts required by NodeOverview before claiming coverage."""
|
|
57
|
+
if any(not isinstance(row.get(key), str) or not row[key].strip()
|
|
58
|
+
for key in ('name', 'status', 'gpu_type')) or type(row.get('unschedulable')) is not bool:
|
|
59
|
+
raise MLSpaceError('malformed_response')
|
|
60
|
+
_rows(row, 'instance_types')
|
|
61
|
+
usage = row.get('resource_usage')
|
|
62
|
+
if not isinstance(usage, dict):
|
|
63
|
+
raise MLSpaceError('malformed_response')
|
|
64
|
+
for key in ('total', 'allocated', 'available'):
|
|
65
|
+
resources = usage.get(key)
|
|
66
|
+
if not isinstance(resources, dict):
|
|
67
|
+
raise MLSpaceError('malformed_response')
|
|
68
|
+
for field in ('cpu', 'ram', 'gpu'):
|
|
69
|
+
value = resources.get(field)
|
|
70
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
|
|
71
|
+
raise MLSpaceError('malformed_response')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _identity(row: dict) -> str:
|
|
75
|
+
for key in ('id', 'uid', 'uuid', 'job_name', 'name'):
|
|
76
|
+
if isinstance(row.get(key), str) and row[key]:
|
|
77
|
+
return json.dumps([key, row[key], row.get('workspace_id'), row.get('namespace'),
|
|
78
|
+
row.get('type')], sort_keys=True)
|
|
79
|
+
# No documented stable identity: exact duplicate pages still must not loop.
|
|
80
|
+
return hashlib.sha256(json.dumps(row, sort_keys=True).encode()).hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _Read:
|
|
84
|
+
def __init__(self, clients: Any, targets: list[str] | None, page_size: int,
|
|
85
|
+
max_pages: int, max_requests: int, max_items: int):
|
|
86
|
+
for value, upper in ((page_size, 1000), (max_pages, 200),
|
|
87
|
+
(max_requests, 1000), (max_items, 1000)):
|
|
88
|
+
if type(value) is not int or not 1 <= value <= upper:
|
|
89
|
+
raise MLSpaceError(f'Budget must be an integer between 1 and {upper}.')
|
|
90
|
+
self.clients = clients
|
|
91
|
+
self.page_size, self.max_pages = page_size, max_pages
|
|
92
|
+
self.remaining, self.max_items = max_requests, max_items
|
|
93
|
+
self.semaphore = asyncio.Semaphore(4)
|
|
94
|
+
self.output_bytes = 0
|
|
95
|
+
self.out: dict[str, Any] = {
|
|
96
|
+
'environment': clients.environment,
|
|
97
|
+
'observed_at': datetime.now(timezone.utc).isoformat(),
|
|
98
|
+
'requested_scope': {'targets': targets if targets is not None
|
|
99
|
+
else [s.id for s in clients.selected]},
|
|
100
|
+
'connected_contexts': [{'id': s.id, 'name': s.name, 'project_name': s.project_name}
|
|
101
|
+
for s in clients.selected],
|
|
102
|
+
'checked_scope': [], 'failures': [], 'items': [], 'observed_count': 0,
|
|
103
|
+
'output_truncated': False,
|
|
104
|
+
}
|
|
105
|
+
self.targets: list[str] = []
|
|
106
|
+
for target in self.out['requested_scope']['targets']:
|
|
107
|
+
try:
|
|
108
|
+
ident = clients.select(target).id
|
|
109
|
+
if ident not in self.targets:
|
|
110
|
+
self.targets.append(ident)
|
|
111
|
+
except MLSpaceError:
|
|
112
|
+
self.fail({'target': target}, 'unconfigured_or_ambiguous_target')
|
|
113
|
+
if not self.targets:
|
|
114
|
+
self.fail({}, 'empty_scope')
|
|
115
|
+
|
|
116
|
+
def fail(self, scope: dict, reason: str) -> None:
|
|
117
|
+
self.out['failures'].append({**scope, 'reason': reason})
|
|
118
|
+
|
|
119
|
+
async def get(self, target: str, path: str, params: dict | None = None) -> Any:
|
|
120
|
+
async with self.semaphore:
|
|
121
|
+
if self.remaining <= 0:
|
|
122
|
+
raise MLSpaceError('request_budget_exhausted')
|
|
123
|
+
self.remaining -= 1
|
|
124
|
+
|
|
125
|
+
async def request() -> Any:
|
|
126
|
+
client = await self.clients.resolve(target)
|
|
127
|
+
return await client.request('GET', path, params=params)
|
|
128
|
+
return await asyncio.wait_for(request(), timeout=30)
|
|
129
|
+
|
|
130
|
+
def error(self, scope: dict, exc: Exception) -> None:
|
|
131
|
+
# Provider bodies / exception strings may carry credentials: never echo them.
|
|
132
|
+
safe = {'request_budget_exhausted', 'malformed_response', 'inconsistent_count',
|
|
133
|
+
'repeated_items', 'unknown_regions', 'missing_node_facts', 'queue_identity_mismatch'}
|
|
134
|
+
reason = str(exc) if isinstance(exc, MLSpaceError) and str(exc) in safe else type(exc).__name__
|
|
135
|
+
self.fail(scope, reason)
|
|
136
|
+
status = getattr(exc, 'status', None)
|
|
137
|
+
if type(status) is int:
|
|
138
|
+
self.out['failures'][-1]['http_status'] = status
|
|
139
|
+
|
|
140
|
+
def add(self, row: dict, scope: dict, *, owner: bool = False) -> None:
|
|
141
|
+
provenance = {'environment': self.clients.environment,
|
|
142
|
+
'request_workspace_id': scope['target'],
|
|
143
|
+
'endpoint': scope['endpoint']}
|
|
144
|
+
if owner:
|
|
145
|
+
provenance['workspace_id'] = row.get('workspace_id', scope['target'])
|
|
146
|
+
if scope.get('region'):
|
|
147
|
+
provenance['region'] = scope['region']
|
|
148
|
+
item = {'source': scope['source'], 'data': _inventory(row), 'provenance': provenance}
|
|
149
|
+
if owner:
|
|
150
|
+
refs = response_references(scope['source'], 'list', [row], environment=self.clients.environment,
|
|
151
|
+
target=scope['target'], values=scope, list_cap=1)
|
|
152
|
+
if refs:
|
|
153
|
+
item['resource_ref'] = refs[0]
|
|
154
|
+
if 'priority' in scope:
|
|
155
|
+
item['priority'] = scope['priority']
|
|
156
|
+
self.out['observed_count'] += 1
|
|
157
|
+
size = len(json.dumps(item).encode())
|
|
158
|
+
if len(self.out['items']) >= self.max_items or self.output_bytes + size > 200_000:
|
|
159
|
+
self.out['output_truncated'] = True
|
|
160
|
+
else:
|
|
161
|
+
self.out['items'].append(item)
|
|
162
|
+
self.output_bytes += size
|
|
163
|
+
|
|
164
|
+
async def pages(self, target: str, path: str, key: str, *, counted: bool,
|
|
165
|
+
params: dict | None = None, owner: bool = False, source: str | None = None) -> None:
|
|
166
|
+
query = dict(params or {})
|
|
167
|
+
scope = {'target': target, 'endpoint': path, 'source': source or key, **query,
|
|
168
|
+
'pages_read': 0, 'next_offset': 0, 'fetch_complete': False}
|
|
169
|
+
self.out['checked_scope'].append(scope)
|
|
170
|
+
seen: set[str] = set()
|
|
171
|
+
expected = None
|
|
172
|
+
try:
|
|
173
|
+
for _ in range(self.max_pages):
|
|
174
|
+
data = await self.get(target, path, {**query, 'limit': self.page_size,
|
|
175
|
+
'offset': scope['next_offset']})
|
|
176
|
+
rows = _rows(data, key)
|
|
177
|
+
scope['pages_read'] += 1
|
|
178
|
+
if counted:
|
|
179
|
+
count = data.get('count')
|
|
180
|
+
if type(count) is not int or count < 0 or (expected is not None and count != expected):
|
|
181
|
+
raise MLSpaceError('inconsistent_count')
|
|
182
|
+
expected = count
|
|
183
|
+
if scope['next_offset'] + len(rows) > count or (not rows and scope['next_offset'] != count):
|
|
184
|
+
raise MLSpaceError('inconsistent_count')
|
|
185
|
+
identities = [_identity(row) for row in rows]
|
|
186
|
+
# Repeated identities make coverage uncertain, but the observed
|
|
187
|
+
# workload must remain visible. add() still applies redaction and
|
|
188
|
+
# output limits; observed_count is not a unique-resource total.
|
|
189
|
+
for row in rows:
|
|
190
|
+
self.add(row, scope, owner=owner)
|
|
191
|
+
if len(set(identities)) != len(identities) or seen.intersection(identities):
|
|
192
|
+
raise MLSpaceError('repeated_items')
|
|
193
|
+
seen.update(identities)
|
|
194
|
+
scope['next_offset'] += len(rows)
|
|
195
|
+
if not rows or (counted and scope['next_offset'] == expected):
|
|
196
|
+
scope['fetch_complete'] = True
|
|
197
|
+
return
|
|
198
|
+
self.fail(scope, 'page_budget_exhausted')
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
self.error(scope, exc)
|
|
201
|
+
|
|
202
|
+
def finish(self) -> dict:
|
|
203
|
+
self.out['fetch_complete'] = not self.out['failures']
|
|
204
|
+
self.out['complete'] = self.out['fetch_complete'] and not self.out['output_truncated']
|
|
205
|
+
self.out['budget_basis'] = 'max_requests counts logical inventory GET calls; client authentication, lazy key discovery and a possible 401 retry are additional transport calls'
|
|
206
|
+
self.out['count_basis'] = 'observations; not a deduplicated resource or capacity total'
|
|
207
|
+
# Metadata (e.g. unexpectedly huge region names) is also provider-controlled.
|
|
208
|
+
# Preserve the truth flags even if a whole section cannot fit in the response.
|
|
209
|
+
for key in ('checked_scope', 'items', 'failures', 'candidates', 'connected_contexts',
|
|
210
|
+
'requested_scope', 'filtering_basis'):
|
|
211
|
+
if len(json.dumps(self.out).encode()) <= 250_000:
|
|
212
|
+
break
|
|
213
|
+
if key not in self.out:
|
|
214
|
+
continue
|
|
215
|
+
old = self.out[key]
|
|
216
|
+
self.out.setdefault('omitted_output', {})[key] = {
|
|
217
|
+
'entries': len(old), 'reason': 'output_byte_budget'}
|
|
218
|
+
self.out[key] = [] if isinstance(old, list) else {'omitted': True}
|
|
219
|
+
self.out['output_truncated'] = True
|
|
220
|
+
self.out['complete'] = False
|
|
221
|
+
return self.out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def aggregate(clients: Any, kind: Literal['jobs', 'notebooks'], *,
|
|
225
|
+
targets: list[str] | None = None, author: str | None = None,
|
|
226
|
+
status: list[str] | None = None, page_size: int = 100, max_pages: int = 50, max_requests: int = 200,
|
|
227
|
+
max_items: int = 200) -> dict:
|
|
228
|
+
if kind not in {'jobs', 'notebooks'} or ((author is not None or status is not None) and kind != 'jobs'):
|
|
229
|
+
raise MLSpaceError('Only jobs support an explicit author filter.')
|
|
230
|
+
read = _Read(clients, targets, page_size, max_pages, max_requests, max_items)
|
|
231
|
+
read.out['requested_scope']['kind'] = kind
|
|
232
|
+
normalized_status = [JOB_STATUS_CANON.get(s.casefold(), s) for s in status] if status is not None else None
|
|
233
|
+
if normalized_status is not None:
|
|
234
|
+
read.out['requested_scope']['status'] = normalized_status
|
|
235
|
+
read.out['filtering_basis'] = {'author': author, 'basis': 'explicit_user_filter' if author is not None else 'all_workspace_authors'}
|
|
236
|
+
|
|
237
|
+
async def workspace(target: str) -> None:
|
|
238
|
+
if kind == 'notebooks':
|
|
239
|
+
await read.pages(target, '/public/v2/notebooks/v2/notebooks', 'notebooks',
|
|
240
|
+
counted=False, owner=True)
|
|
241
|
+
return
|
|
242
|
+
# The launch catalog can omit allocated regions that still contain jobs.
|
|
243
|
+
# Read both sources independently so either failure retains known regions
|
|
244
|
+
# while keeping the overall result explicitly partial.
|
|
245
|
+
keys: list[str] = []
|
|
246
|
+
for source, path, query in (
|
|
247
|
+
('regions', '/public/v2/configs', {'cluster_type': 'MT'}),
|
|
248
|
+
('allocation_regions', '/public/v2/workspaces/v3/{workspace_id}/allocations', None),
|
|
249
|
+
):
|
|
250
|
+
scope = {'target': target, 'endpoint': path, 'source': source, 'fetch_complete': False}
|
|
251
|
+
read.out['checked_scope'].append(scope)
|
|
252
|
+
try:
|
|
253
|
+
if source == 'allocation_regions':
|
|
254
|
+
path = path.format(workspace_id=_segment(target))
|
|
255
|
+
scope['endpoint'] = path
|
|
256
|
+
data = await read.get(target, path, query)
|
|
257
|
+
rows = _rows(data, 'regions') if source == 'regions' else _rows({'allocations': data}, 'allocations')
|
|
258
|
+
field = 'key' if source == 'regions' else 'region_key'
|
|
259
|
+
discovered: list[str] = []
|
|
260
|
+
for row in rows:
|
|
261
|
+
key = row.get(field)
|
|
262
|
+
if not isinstance(key, str) or not key.strip():
|
|
263
|
+
raise MLSpaceError('unknown_regions')
|
|
264
|
+
discovered.append(key)
|
|
265
|
+
keys.extend(discovered)
|
|
266
|
+
scope['regions'] = list(dict.fromkeys(discovered))
|
|
267
|
+
scope['fetch_complete'] = True
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
read.error(scope, exc)
|
|
270
|
+
if not keys:
|
|
271
|
+
read.fail({'target': target, 'source': 'regions'}, 'unknown_regions')
|
|
272
|
+
for region in dict.fromkeys(keys):
|
|
273
|
+
params: dict[str, Any] = {'region': region}
|
|
274
|
+
if normalized_status is not None:
|
|
275
|
+
params['status'] = normalized_status
|
|
276
|
+
if author is not None:
|
|
277
|
+
params['job_author'] = [author]
|
|
278
|
+
await read.pages(target, '/public/v2/jobs', 'jobs', counted=True,
|
|
279
|
+
params=params, owner=True)
|
|
280
|
+
await asyncio.gather(*(workspace(target) for target in read.targets))
|
|
281
|
+
return read.finish()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def inspect_queue(clients: Any, queue_id: str, *, target: str | None = None,
|
|
285
|
+
candidate_nodes: list[str] | None = None, targets: list[str] | None = None,
|
|
286
|
+
page_size: int = 100, max_pages: int = 50, max_requests: int = 200,
|
|
287
|
+
max_items: int = 200) -> dict:
|
|
288
|
+
primary = clients.select(target).id
|
|
289
|
+
queue_segment = _segment(queue_id)
|
|
290
|
+
read = _Read(clients, targets, page_size, max_pages, max_requests, max_items)
|
|
291
|
+
read.out['requested_scope'].update(queue_id=queue_id, source_target=primary)
|
|
292
|
+
read.out['cross_workspace_coverage'] = {
|
|
293
|
+
'status': 'unknown',
|
|
294
|
+
'reason': 'Queue API does not attest all workspace visibility, even when every connected context responds.',
|
|
295
|
+
}
|
|
296
|
+
read.out['interpretation'] = {
|
|
297
|
+
'active_workload': 'Inspect observed pod status and node load; retain contradictions with notebook status.',
|
|
298
|
+
'pending_demand': 'All six priorities queried separately; duplicate observations are not summed.',
|
|
299
|
+
'paused_notebooks': 'Inventory only; configured GPU does not prove active consumption.',
|
|
300
|
+
'capacity': 'Node facts fetched once from the source context; no capacity summation.',
|
|
301
|
+
}
|
|
302
|
+
path = '/public/v2/queues/' + queue_segment
|
|
303
|
+
|
|
304
|
+
async def single(ws: str, endpoint: str, key: str, params: dict | None = None) -> list[dict] | None:
|
|
305
|
+
scope = {'target': ws, 'endpoint': endpoint, 'source': key, 'fetch_complete': False}
|
|
306
|
+
read.out['checked_scope'].append(scope)
|
|
307
|
+
try:
|
|
308
|
+
rows = _rows(await read.get(ws, endpoint, params), key)
|
|
309
|
+
if key == 'nodes':
|
|
310
|
+
for row in rows:
|
|
311
|
+
_node_facts(row)
|
|
312
|
+
if key == 'loads':
|
|
313
|
+
for row in rows:
|
|
314
|
+
if not isinstance(row.get('node_name'), str):
|
|
315
|
+
raise MLSpaceError('malformed_response')
|
|
316
|
+
_rows(row, 'jobs')
|
|
317
|
+
_rows(row, 'notebooks')
|
|
318
|
+
for row in rows:
|
|
319
|
+
read.add(row, scope)
|
|
320
|
+
scope['fetch_complete'] = True
|
|
321
|
+
return rows
|
|
322
|
+
except Exception as exc:
|
|
323
|
+
read.error(scope, exc)
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
async def details(ws: str) -> dict | None:
|
|
327
|
+
scope = {'target': ws, 'endpoint': path, 'source': 'queue', 'fetch_complete': False}
|
|
328
|
+
read.out['checked_scope'].append(scope)
|
|
329
|
+
try:
|
|
330
|
+
data = await read.get(ws, path)
|
|
331
|
+
if not isinstance(data, dict) or data.get('id') != queue_id:
|
|
332
|
+
raise MLSpaceError('queue_identity_mismatch')
|
|
333
|
+
if not isinstance(data.get('nodes'), list) or not all(isinstance(n, str) for n in data['nodes']) or not isinstance(data.get('allocation_id'), str):
|
|
334
|
+
raise MLSpaceError('malformed_response')
|
|
335
|
+
_segment(data['allocation_id'])
|
|
336
|
+
read.add(data, scope)
|
|
337
|
+
scope['fetch_complete'] = True
|
|
338
|
+
return data
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
read.error(scope, exc)
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
source = await details(primary)
|
|
344
|
+
nodes = source['nodes'] if source else []
|
|
345
|
+
requested_nodes = list(dict.fromkeys(candidate_nodes if candidate_nodes is not None else nodes))
|
|
346
|
+
valid = [n for n in requested_nodes if n in nodes]
|
|
347
|
+
invalid = [n for n in requested_nodes if n not in nodes]
|
|
348
|
+
read.out['candidates'] = {'validated': valid[:max_items], 'invalid': invalid[:max_items],
|
|
349
|
+
'membership_known': source is not None}
|
|
350
|
+
if len(valid) > max_items or len(invalid) > max_items:
|
|
351
|
+
read.out['output_truncated'] = True
|
|
352
|
+
if invalid:
|
|
353
|
+
read.fail({'target': primary, 'source': 'candidates'}, 'candidate_not_in_source_queue')
|
|
354
|
+
|
|
355
|
+
async def workspace(ws: str) -> None:
|
|
356
|
+
if ws != primary:
|
|
357
|
+
sibling = await details(ws)
|
|
358
|
+
if sibling is None:
|
|
359
|
+
return
|
|
360
|
+
if source and sibling['allocation_id'] != source['allocation_id']:
|
|
361
|
+
read.fail({'target': ws, 'source': 'queue'}, 'queue_identity_mismatch')
|
|
362
|
+
return
|
|
363
|
+
await asyncio.gather(*(single(ws, path + '/' + key, key)
|
|
364
|
+
for key in ('jobs', 'notebooks', 'pods')))
|
|
365
|
+
# Separate priority pages preserve coverage and never sum shared observations.
|
|
366
|
+
for priority in PRIORITIES:
|
|
367
|
+
await read.pages(ws, path + '/awaiting-launch-resources', 'items', counted=True,
|
|
368
|
+
params={'priority': priority}, source='pending')
|
|
369
|
+
|
|
370
|
+
await asyncio.gather(*(workspace(ws) for ws in read.targets))
|
|
371
|
+
if source and valid:
|
|
372
|
+
allocation = _segment(source['allocation_id'])
|
|
373
|
+
facts = await single(primary, f'/public/v2/allocations/{allocation}/nodes', 'nodes')
|
|
374
|
+
loads: list[dict] = []
|
|
375
|
+
for start in range(0, len(valid), 100):
|
|
376
|
+
batch = await single(primary, '/public/v2/nodes/load', 'loads',
|
|
377
|
+
{'node_names': valid[start:start + 100]})
|
|
378
|
+
if batch is not None:
|
|
379
|
+
loads.extend(batch)
|
|
380
|
+
for rows, key in ((facts, 'name'), (loads, 'node_name')):
|
|
381
|
+
if rows is not None and not set(valid).issubset({r.get(key) for r in rows}):
|
|
382
|
+
read.fail({'target': primary, 'source': key}, 'missing_node_facts')
|
|
383
|
+
return read.finish()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def register_overviews(mcp: FastMCP, settings: Settings) -> None:
|
|
387
|
+
def runtime(ctx: Context) -> Any:
|
|
388
|
+
return ctx.request_context.lifespan_context.clients
|
|
389
|
+
|
|
390
|
+
async def mlspace_jobs_overview(ctx: Context, targets: list[str] | None = None,
|
|
391
|
+
author: str | None = None, status: list[str] | None = None, page_size: int = 100,
|
|
392
|
+
max_pages: int = 50, max_requests: int = 200,
|
|
393
|
+
max_items: int = 200) -> str:
|
|
394
|
+
return json.dumps(await aggregate(runtime(ctx), 'jobs', targets=targets, author=author, status=status,
|
|
395
|
+
page_size=page_size, max_pages=max_pages, max_requests=max_requests, max_items=max_items))
|
|
396
|
+
|
|
397
|
+
async def mlspace_notebooks_overview(ctx: Context, targets: list[str] | None = None,
|
|
398
|
+
page_size: int = 100, max_pages: int = 50,
|
|
399
|
+
max_requests: int = 200, max_items: int = 200) -> str:
|
|
400
|
+
return json.dumps(await aggregate(runtime(ctx), 'notebooks', targets=targets,
|
|
401
|
+
page_size=page_size, max_pages=max_pages, max_requests=max_requests, max_items=max_items))
|
|
402
|
+
|
|
403
|
+
async def mlspace_queue_inspect(queue_id: str, ctx: Context, target: str | None = None,
|
|
404
|
+
candidate_nodes: list[str] | None = None,
|
|
405
|
+
targets: list[str] | None = None, page_size: int = 100,
|
|
406
|
+
max_pages: int = 50, max_requests: int = 200,
|
|
407
|
+
max_items: int = 200) -> str:
|
|
408
|
+
return json.dumps(await inspect_queue(runtime(ctx), queue_id, target=target,
|
|
409
|
+
candidate_nodes=candidate_nodes, targets=targets, page_size=page_size,
|
|
410
|
+
max_pages=max_pages, max_requests=max_requests, max_items=max_items))
|
|
411
|
+
|
|
412
|
+
enabled = settings.enabled_domain_set
|
|
413
|
+
annotations = ToolAnnotations(readOnlyHint=True, destructiveHint=False,
|
|
414
|
+
idempotentHint=True, openWorldHint=True)
|
|
415
|
+
for domain, fn, description in (
|
|
416
|
+
('jobs', mlspace_jobs_overview, 'Read jobs across all saved contexts (or explicit targets), regions discovered from both the MT launch catalog and workspace allocations, and pages. Optional status filters jobs before pagination; author is an explicit filter, not verified personal identity.'),
|
|
417
|
+
('notebooks', mlspace_notebooks_overview, 'Read notebook inventory across all saved contexts (or explicit targets), including paused notebooks and their status. Configured GPUs do not prove activity.'),
|
|
418
|
+
('queues', mlspace_queue_inspect, 'Inspect a source queue before considering a compute-node move: jobs/notebooks/pods, all six pending priorities, candidate membership and node facts. target selects source; targets selects observation contexts (default all saved). Cross-workspace visibility remains unknown; no safety-to-move conclusion.'),
|
|
419
|
+
):
|
|
420
|
+
if enabled is None or domain in enabled:
|
|
421
|
+
mcp.add_tool(fn, description=description + ' Bounded reads report requested/checked scope, failures, fetch_complete and output_truncated; complete requires both full fetch and full output. max_pages applies per stream, max_requests globally; max_items bounds output only.', annotations=annotations)
|