wafer-cli 0.2.9__py3-none-any.whl → 0.2.10__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.
- wafer/GUIDE.md +18 -7
- wafer/api_client.py +4 -0
- wafer/cli.py +1177 -278
- wafer/corpus.py +158 -32
- wafer/evaluate.py +75 -6
- wafer/kernel_scope.py +132 -31
- wafer/nsys_analyze.py +903 -73
- wafer/nsys_profile.py +511 -0
- wafer/output.py +241 -0
- wafer/skills/wafer-guide/SKILL.md +13 -0
- wafer/ssh_keys.py +261 -0
- wafer/targets_ops.py +718 -0
- wafer/wevin_cli.py +127 -18
- wafer/workspaces.py +232 -184
- {wafer_cli-0.2.9.dist-info → wafer_cli-0.2.10.dist-info}/METADATA +1 -1
- {wafer_cli-0.2.9.dist-info → wafer_cli-0.2.10.dist-info}/RECORD +19 -15
- {wafer_cli-0.2.9.dist-info → wafer_cli-0.2.10.dist-info}/WHEEL +0 -0
- {wafer_cli-0.2.9.dist-info → wafer_cli-0.2.10.dist-info}/entry_points.txt +0 -0
- {wafer_cli-0.2.9.dist-info → wafer_cli-0.2.10.dist-info}/top_level.txt +0 -0
wafer/workspaces.py
CHANGED
|
@@ -13,15 +13,7 @@ import httpx
|
|
|
13
13
|
from .api_client import get_api_url
|
|
14
14
|
from .auth import get_auth_headers
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
@dataclass(frozen=True)
|
|
18
|
-
class SSHCredentials:
|
|
19
|
-
"""SSH credentials for workspace access."""
|
|
20
|
-
|
|
21
|
-
host: str
|
|
22
|
-
port: int
|
|
23
|
-
user: str
|
|
24
|
-
key_path: Path
|
|
16
|
+
VALID_STATUSES = {"creating", "running"}
|
|
25
17
|
|
|
26
18
|
|
|
27
19
|
def _get_client() -> tuple[str, dict[str, str]]:
|
|
@@ -72,10 +64,11 @@ def _friendly_error(status_code: int, response_text: str, workspace_id: str) ->
|
|
|
72
64
|
|
|
73
65
|
# Parse common error details from response
|
|
74
66
|
detail = ""
|
|
75
|
-
if "
|
|
67
|
+
if "not running" in response_text.lower() or "not found" in response_text.lower():
|
|
76
68
|
return (
|
|
77
|
-
f"Workspace '{workspace_id}'
|
|
78
|
-
"
|
|
69
|
+
f"Workspace '{workspace_id}' not found or not running.\n"
|
|
70
|
+
" Check status: wafer workspaces list\n"
|
|
71
|
+
" Create new: wafer workspaces create <name>"
|
|
79
72
|
)
|
|
80
73
|
|
|
81
74
|
if "timeout" in response_text.lower():
|
|
@@ -85,6 +78,12 @@ def _friendly_error(status_code: int, response_text: str, workspace_id: str) ->
|
|
|
85
78
|
" Or set default: wafer config set defaults.exec_timeout 600"
|
|
86
79
|
)
|
|
87
80
|
|
|
81
|
+
if "creating" in response_text.lower():
|
|
82
|
+
return (
|
|
83
|
+
f"Workspace '{workspace_id}' is still creating.\n"
|
|
84
|
+
" Check status: wafer workspaces list"
|
|
85
|
+
)
|
|
86
|
+
|
|
88
87
|
# Generic error with response detail
|
|
89
88
|
try:
|
|
90
89
|
import json
|
|
@@ -114,6 +113,14 @@ def _list_workspaces_raw() -> list[dict]:
|
|
|
114
113
|
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
115
114
|
|
|
116
115
|
assert isinstance(workspaces, list), "API must return a list of workspaces"
|
|
116
|
+
|
|
117
|
+
for ws in workspaces:
|
|
118
|
+
status = ws.get("status", "unknown")
|
|
119
|
+
assert status in VALID_STATUSES or status == "unknown", (
|
|
120
|
+
f"Workspace {ws.get('id', 'unknown')} has invalid status '{status}'. "
|
|
121
|
+
f"Valid statuses: {VALID_STATUSES}"
|
|
122
|
+
)
|
|
123
|
+
|
|
117
124
|
return workspaces
|
|
118
125
|
|
|
119
126
|
|
|
@@ -186,9 +193,15 @@ def list_workspaces(json_output: bool = False) -> str:
|
|
|
186
193
|
except httpx.RequestError as e:
|
|
187
194
|
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
188
195
|
|
|
189
|
-
# Validate API response shape
|
|
190
196
|
assert isinstance(workspaces, list), "API must return a list of workspaces"
|
|
191
197
|
|
|
198
|
+
for ws in workspaces:
|
|
199
|
+
status = ws.get("status", "unknown")
|
|
200
|
+
assert status in VALID_STATUSES or status == "unknown", (
|
|
201
|
+
f"Workspace {ws.get('id', 'unknown')} has invalid status '{status}'. "
|
|
202
|
+
f"Valid statuses: {VALID_STATUSES}"
|
|
203
|
+
)
|
|
204
|
+
|
|
192
205
|
if json_output:
|
|
193
206
|
return json.dumps(workspaces, indent=2)
|
|
194
207
|
|
|
@@ -198,10 +211,14 @@ def list_workspaces(json_output: bool = False) -> str:
|
|
|
198
211
|
lines = ["Workspaces:", ""]
|
|
199
212
|
for ws in workspaces:
|
|
200
213
|
status = ws.get("status", "unknown")
|
|
201
|
-
status_icon = {"running": "●", "
|
|
214
|
+
status_icon = {"running": "●", "creating": "◐"}.get(status, "?")
|
|
202
215
|
lines.append(f" {status_icon} {ws['name']} ({ws['id']})")
|
|
203
216
|
lines.append(f" GPU: {ws.get('gpu_type', 'N/A')} | Image: {ws.get('image', 'N/A')}")
|
|
204
217
|
lines.append(f" Status: {status} | Created: {ws.get('created_at', 'N/A')}")
|
|
218
|
+
if status == "running" and ws.get("ssh_host") and ws.get("ssh_port") and ws.get("ssh_user"):
|
|
219
|
+
lines.append(
|
|
220
|
+
f" SSH: ssh -p {ws['ssh_port']} {ws['ssh_user']}@{ws['ssh_host']}"
|
|
221
|
+
)
|
|
205
222
|
lines.append("")
|
|
206
223
|
|
|
207
224
|
return "\n".join(lines)
|
|
@@ -211,6 +228,7 @@ def create_workspace(
|
|
|
211
228
|
name: str,
|
|
212
229
|
gpu_type: str = "B200",
|
|
213
230
|
image: str | None = None,
|
|
231
|
+
wait: bool = False,
|
|
214
232
|
json_output: bool = False,
|
|
215
233
|
) -> str:
|
|
216
234
|
"""Create a new workspace.
|
|
@@ -219,6 +237,7 @@ def create_workspace(
|
|
|
219
237
|
name: Workspace name (must be unique)
|
|
220
238
|
gpu_type: GPU type (default: B200)
|
|
221
239
|
image: Docker image (optional, uses default if not specified)
|
|
240
|
+
wait: If True, stream provisioning progress and return SSH credentials
|
|
222
241
|
json_output: If True, return raw JSON; otherwise return formatted text
|
|
223
242
|
|
|
224
243
|
Returns:
|
|
@@ -276,193 +295,114 @@ def create_workspace(
|
|
|
276
295
|
assert "id" in workspace, "API response must contain workspace id"
|
|
277
296
|
assert "name" in workspace, "API response must contain workspace name"
|
|
278
297
|
|
|
298
|
+
if wait:
|
|
299
|
+
ssh_info = _wait_for_provisioning(workspace["id"])
|
|
300
|
+
if json_output:
|
|
301
|
+
payload = {
|
|
302
|
+
"workspace_id": workspace["id"],
|
|
303
|
+
"ssh_host": ssh_info["ssh_host"],
|
|
304
|
+
"ssh_port": ssh_info["ssh_port"],
|
|
305
|
+
"ssh_user": ssh_info["ssh_user"],
|
|
306
|
+
}
|
|
307
|
+
return json.dumps(payload, indent=2)
|
|
308
|
+
return (
|
|
309
|
+
f"Workspace ready: {workspace['name']} ({workspace['id']})\n"
|
|
310
|
+
f"SSH: ssh -p {ssh_info['ssh_port']} {ssh_info['ssh_user']}@{ssh_info['ssh_host']}"
|
|
311
|
+
)
|
|
312
|
+
|
|
279
313
|
if json_output:
|
|
280
314
|
return json.dumps(workspace, indent=2)
|
|
281
315
|
|
|
282
|
-
return
|
|
283
|
-
|
|
316
|
+
return (
|
|
317
|
+
f"Creating workspace: {workspace['name']} ({workspace['id']})\n"
|
|
318
|
+
"Check status with: wafer workspaces list\n"
|
|
319
|
+
"Estimated time: ~30 seconds"
|
|
320
|
+
)
|
|
284
321
|
|
|
285
|
-
def delete_workspace(workspace_id: str, json_output: bool = False) -> str:
|
|
286
|
-
"""Delete a workspace.
|
|
287
322
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
323
|
+
def _wait_for_provisioning(workspace_id: str) -> dict[str, str | int]:
|
|
324
|
+
"""Wait for workspace provisioning to complete via SSE."""
|
|
325
|
+
import sys
|
|
291
326
|
|
|
292
|
-
Returns:
|
|
293
|
-
Deletion status as string
|
|
294
|
-
"""
|
|
295
327
|
assert workspace_id, "Workspace ID must be non-empty"
|
|
296
|
-
|
|
297
328
|
api_url, headers = _get_client()
|
|
298
329
|
|
|
299
330
|
try:
|
|
300
|
-
with httpx.Client(timeout=
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
except httpx.RequestError as e:
|
|
311
|
-
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
331
|
+
with httpx.Client(timeout=None, headers=headers) as client:
|
|
332
|
+
with client.stream(
|
|
333
|
+
"POST",
|
|
334
|
+
f"{api_url}/v1/workspaces/{workspace_id}/provision-stream",
|
|
335
|
+
) as response:
|
|
336
|
+
if response.status_code != 200:
|
|
337
|
+
error_body = response.read().decode("utf-8", errors="replace")
|
|
338
|
+
raise RuntimeError(
|
|
339
|
+
_friendly_error(response.status_code, error_body, workspace_id)
|
|
340
|
+
)
|
|
312
341
|
|
|
313
|
-
|
|
314
|
-
|
|
342
|
+
ssh_info: dict[str, str | int] | None = None
|
|
343
|
+
for line in response.iter_lines():
|
|
344
|
+
if not line or not line.startswith("data: "):
|
|
345
|
+
continue
|
|
346
|
+
content = line[6:]
|
|
347
|
+
if content.startswith("[STATUS:"):
|
|
348
|
+
status = content[8:-1]
|
|
349
|
+
print(f"[wafer] {status.lower()}...", file=sys.stderr)
|
|
350
|
+
if status == "ERROR":
|
|
351
|
+
raise RuntimeError(
|
|
352
|
+
"Workspace provisioning failed. Check status with: wafer workspaces list"
|
|
353
|
+
)
|
|
354
|
+
elif content.startswith("[SSH:"):
|
|
355
|
+
parts = content[5:-1].split(":")
|
|
356
|
+
if len(parts) != 3:
|
|
357
|
+
raise RuntimeError("Malformed SSH info in provisioning stream")
|
|
358
|
+
ssh_info = {
|
|
359
|
+
"ssh_host": parts[0],
|
|
360
|
+
"ssh_port": int(parts[1]),
|
|
361
|
+
"ssh_user": parts[2],
|
|
362
|
+
}
|
|
363
|
+
break
|
|
315
364
|
|
|
316
|
-
|
|
365
|
+
if ssh_info is None:
|
|
366
|
+
raise RuntimeError("Provisioning did not return SSH credentials")
|
|
367
|
+
return ssh_info
|
|
368
|
+
except httpx.RequestError as e:
|
|
369
|
+
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
317
370
|
|
|
318
371
|
|
|
319
|
-
def
|
|
320
|
-
"""
|
|
372
|
+
def delete_workspace(workspace_id: str, json_output: bool = False) -> str:
|
|
373
|
+
"""Delete a workspace.
|
|
321
374
|
|
|
322
375
|
Args:
|
|
323
|
-
workspace_id: Workspace ID to
|
|
376
|
+
workspace_id: Workspace ID to delete
|
|
324
377
|
json_output: If True, return raw JSON; otherwise return formatted text
|
|
325
378
|
|
|
326
379
|
Returns:
|
|
327
|
-
|
|
380
|
+
Deletion status as string
|
|
328
381
|
"""
|
|
329
382
|
assert workspace_id, "Workspace ID must be non-empty"
|
|
330
383
|
|
|
331
384
|
api_url, headers = _get_client()
|
|
332
385
|
|
|
333
386
|
try:
|
|
334
|
-
with httpx.Client(timeout=
|
|
335
|
-
response = client.
|
|
387
|
+
with httpx.Client(timeout=30.0, headers=headers) as client:
|
|
388
|
+
response = client.delete(f"{api_url}/v1/workspaces/{workspace_id}")
|
|
336
389
|
response.raise_for_status()
|
|
337
|
-
|
|
390
|
+
result = response.json()
|
|
338
391
|
except httpx.HTTPStatusError as e:
|
|
339
392
|
if e.response.status_code == 401:
|
|
340
393
|
raise RuntimeError("Not authenticated. Run: wafer login") from e
|
|
341
394
|
if e.response.status_code == 404:
|
|
342
395
|
raise RuntimeError(f"Workspace not found: {workspace_id}") from e
|
|
343
|
-
if e.response.status_code == 503:
|
|
344
|
-
raise RuntimeError("No GPU available. Please try again later.") from e
|
|
345
396
|
raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") from e
|
|
346
397
|
except httpx.RequestError as e:
|
|
347
398
|
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
348
399
|
|
|
349
|
-
# Validate API response has required SSH fields
|
|
350
|
-
assert "ssh_host" in attach_info, "API response must contain ssh_host"
|
|
351
|
-
assert "ssh_port" in attach_info, "API response must contain ssh_port"
|
|
352
|
-
assert "ssh_user" in attach_info, "API response must contain ssh_user"
|
|
353
|
-
assert "private_key_pem" in attach_info, "API response must contain private_key_pem"
|
|
354
|
-
|
|
355
400
|
if json_output:
|
|
356
|
-
return json.dumps(
|
|
357
|
-
|
|
358
|
-
# Write private key to temp file and generate SSH config
|
|
359
|
-
ssh_host = attach_info["ssh_host"]
|
|
360
|
-
ssh_port = attach_info["ssh_port"]
|
|
361
|
-
ssh_user = attach_info["ssh_user"]
|
|
362
|
-
private_key = attach_info["private_key_pem"]
|
|
363
|
-
|
|
364
|
-
# Validate field values before using them
|
|
365
|
-
assert ssh_host, "ssh_host must be non-empty"
|
|
366
|
-
assert isinstance(ssh_port, int), "ssh_port must be an integer"
|
|
367
|
-
assert ssh_port > 0, "ssh_port must be positive"
|
|
368
|
-
assert ssh_user, "ssh_user must be non-empty"
|
|
369
|
-
assert private_key, "private_key_pem must be non-empty"
|
|
370
|
-
|
|
371
|
-
# Save private key
|
|
372
|
-
key_dir = Path.home() / ".wafer" / "keys"
|
|
373
|
-
key_dir.mkdir(parents=True, exist_ok=True)
|
|
374
|
-
key_path = key_dir / f"{workspace_id}.pem"
|
|
375
|
-
key_path.write_text(private_key)
|
|
376
|
-
key_path.chmod(0o600)
|
|
377
|
-
|
|
378
|
-
# Generate SSH config entry
|
|
379
|
-
config_entry = f"""
|
|
380
|
-
# Wafer workspace: {workspace_id}
|
|
381
|
-
Host wafer-{workspace_id}
|
|
382
|
-
HostName {ssh_host}
|
|
383
|
-
Port {ssh_port}
|
|
384
|
-
User {ssh_user}
|
|
385
|
-
IdentityFile {key_path}
|
|
386
|
-
StrictHostKeyChecking no
|
|
387
|
-
UserKnownHostsFile /dev/null
|
|
388
|
-
"""
|
|
389
|
-
|
|
390
|
-
lines = [
|
|
391
|
-
f"Attached to workspace: {workspace_id}",
|
|
392
|
-
"",
|
|
393
|
-
"SSH Connection:",
|
|
394
|
-
f" ssh -i {key_path} -p {ssh_port} {ssh_user}@{ssh_host}",
|
|
395
|
-
"",
|
|
396
|
-
"Or add to ~/.ssh/config:",
|
|
397
|
-
config_entry,
|
|
398
|
-
f"Then connect with: ssh wafer-{workspace_id}",
|
|
399
|
-
"",
|
|
400
|
-
"To run GPU commands without interactive SSH:",
|
|
401
|
-
f' wafer workspaces exec {workspace_id} "<command>"',
|
|
402
|
-
]
|
|
403
|
-
|
|
404
|
-
return "\n".join(lines)
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def get_ssh_credentials(workspace_id: str) -> tuple[SSHCredentials, str]:
|
|
408
|
-
"""Get SSH credentials for a workspace.
|
|
409
|
-
|
|
410
|
-
Calls attach API, saves key file, returns credentials for SSH.
|
|
411
|
-
|
|
412
|
-
Args:
|
|
413
|
-
workspace_id: Workspace ID or name
|
|
414
|
-
|
|
415
|
-
Returns:
|
|
416
|
-
Tuple of (SSHCredentials, resolved_workspace_id)
|
|
417
|
-
|
|
418
|
-
Raises:
|
|
419
|
-
RuntimeError: If attach fails
|
|
420
|
-
"""
|
|
421
|
-
assert workspace_id, "Workspace ID must be non-empty"
|
|
401
|
+
return json.dumps(result, indent=2)
|
|
422
402
|
|
|
423
|
-
|
|
403
|
+
return f"Deleted workspace: {workspace_id}"
|
|
424
404
|
|
|
425
|
-
try:
|
|
426
|
-
with httpx.Client(timeout=120.0, headers=headers) as client:
|
|
427
|
-
response = client.post(f"{api_url}/v1/workspaces/{workspace_id}/attach")
|
|
428
|
-
response.raise_for_status()
|
|
429
|
-
attach_info = response.json()
|
|
430
|
-
except httpx.HTTPStatusError as e:
|
|
431
|
-
if e.response.status_code == 401:
|
|
432
|
-
raise RuntimeError("Not authenticated. Run: wafer login") from e
|
|
433
|
-
if e.response.status_code == 404:
|
|
434
|
-
raise RuntimeError(f"Workspace not found: {workspace_id}") from e
|
|
435
|
-
if e.response.status_code == 503:
|
|
436
|
-
raise RuntimeError("No GPU available. Please try again later.") from e
|
|
437
|
-
raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") from e
|
|
438
|
-
except httpx.RequestError as e:
|
|
439
|
-
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
440
405
|
|
|
441
|
-
# Validate and extract fields
|
|
442
|
-
resolved_id = attach_info.get("workspace_id", workspace_id)
|
|
443
|
-
ssh_host = attach_info.get("ssh_host")
|
|
444
|
-
ssh_port = attach_info.get("ssh_port")
|
|
445
|
-
ssh_user = attach_info.get("ssh_user")
|
|
446
|
-
private_key = attach_info.get("private_key_pem")
|
|
447
|
-
|
|
448
|
-
assert ssh_host, "API response must contain ssh_host"
|
|
449
|
-
assert isinstance(ssh_port, int) and ssh_port > 0, "ssh_port must be positive integer"
|
|
450
|
-
assert ssh_user, "API response must contain ssh_user"
|
|
451
|
-
assert private_key, "API response must contain private_key_pem"
|
|
452
|
-
|
|
453
|
-
# Save private key using resolved ID
|
|
454
|
-
key_dir = Path.home() / ".wafer" / "keys"
|
|
455
|
-
key_dir.mkdir(parents=True, exist_ok=True)
|
|
456
|
-
key_path = key_dir / f"{resolved_id}.pem"
|
|
457
|
-
key_path.write_text(private_key)
|
|
458
|
-
key_path.chmod(0o600)
|
|
459
|
-
|
|
460
|
-
return SSHCredentials(
|
|
461
|
-
host=ssh_host,
|
|
462
|
-
port=ssh_port,
|
|
463
|
-
user=ssh_user,
|
|
464
|
-
key_path=key_path,
|
|
465
|
-
), resolved_id
|
|
466
406
|
|
|
467
407
|
|
|
468
408
|
def sync_files(
|
|
@@ -495,9 +435,23 @@ def sync_files(
|
|
|
495
435
|
assert workspace_id, "Workspace ID must be non-empty"
|
|
496
436
|
assert local_path.exists(), f"Path not found: {local_path}"
|
|
497
437
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
438
|
+
ws = get_workspace_raw(workspace_id)
|
|
439
|
+
resolved_id = ws["id"]
|
|
440
|
+
workspace_status = ws.get("status")
|
|
441
|
+
assert workspace_status in VALID_STATUSES, (
|
|
442
|
+
f"Workspace {workspace_id} has invalid status '{workspace_status}'. "
|
|
443
|
+
f"Valid statuses: {VALID_STATUSES}"
|
|
444
|
+
)
|
|
445
|
+
if workspace_status != "running":
|
|
446
|
+
raise RuntimeError(
|
|
447
|
+
f"Workspace is {workspace_status}. Wait for it to be running before syncing."
|
|
448
|
+
)
|
|
449
|
+
ssh_host = ws.get("ssh_host")
|
|
450
|
+
ssh_port = ws.get("ssh_port")
|
|
451
|
+
ssh_user = ws.get("ssh_user")
|
|
452
|
+
assert ssh_host, "Workspace missing ssh_host"
|
|
453
|
+
assert isinstance(ssh_port, int) and ssh_port > 0, "Workspace missing valid ssh_port"
|
|
454
|
+
assert ssh_user, "Workspace missing ssh_user"
|
|
501
455
|
|
|
502
456
|
# Build rsync command
|
|
503
457
|
# -a: archive mode (preserves permissions, etc.)
|
|
@@ -510,13 +464,17 @@ def sync_files(
|
|
|
510
464
|
# Single file: sync the file itself
|
|
511
465
|
source = str(local_path)
|
|
512
466
|
|
|
467
|
+
# Build SSH command for rsync
|
|
468
|
+
# If key_path is None (BYOK model), SSH will use default key from ~/.ssh/
|
|
469
|
+
ssh_opts = f"-p {ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
470
|
+
|
|
513
471
|
rsync_cmd = [
|
|
514
472
|
"rsync",
|
|
515
473
|
"-avz",
|
|
516
474
|
"-e",
|
|
517
|
-
f"ssh
|
|
475
|
+
f"ssh {ssh_opts}",
|
|
518
476
|
source,
|
|
519
|
-
f"{
|
|
477
|
+
f"{ssh_user}@{ssh_host}:/workspace/",
|
|
520
478
|
]
|
|
521
479
|
|
|
522
480
|
try:
|
|
@@ -592,16 +550,8 @@ def _init_sync_state(workspace_id: str) -> str | None:
|
|
|
592
550
|
return None
|
|
593
551
|
|
|
594
552
|
|
|
595
|
-
def
|
|
596
|
-
"""Get details
|
|
597
|
-
|
|
598
|
-
Args:
|
|
599
|
-
workspace_id: Workspace ID to get
|
|
600
|
-
json_output: If True, return raw JSON; otherwise return formatted text
|
|
601
|
-
|
|
602
|
-
Returns:
|
|
603
|
-
Workspace details as string
|
|
604
|
-
"""
|
|
553
|
+
def get_workspace_raw(workspace_id: str) -> dict:
|
|
554
|
+
"""Get workspace details as raw JSON dict."""
|
|
605
555
|
assert workspace_id, "Workspace ID must be non-empty"
|
|
606
556
|
|
|
607
557
|
api_url, headers = _get_client()
|
|
@@ -620,9 +570,29 @@ def get_workspace(workspace_id: str, json_output: bool = False) -> str:
|
|
|
620
570
|
except httpx.RequestError as e:
|
|
621
571
|
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
622
572
|
|
|
623
|
-
# Validate API response has required fields
|
|
624
573
|
assert "id" in workspace, "API response must contain workspace id"
|
|
625
574
|
assert "name" in workspace, "API response must contain workspace name"
|
|
575
|
+
|
|
576
|
+
status = workspace.get("status", "unknown")
|
|
577
|
+
assert status in VALID_STATUSES or status == "unknown", (
|
|
578
|
+
f"Workspace {workspace['id']} has invalid status '{status}'. "
|
|
579
|
+
f"Valid statuses: {VALID_STATUSES}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
return workspace
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def get_workspace(workspace_id: str, json_output: bool = False) -> str:
|
|
586
|
+
"""Get details of a specific workspace.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
workspace_id: Workspace ID to get
|
|
590
|
+
json_output: If True, return raw JSON; otherwise return formatted text
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
Workspace details as string
|
|
594
|
+
"""
|
|
595
|
+
workspace = get_workspace_raw(workspace_id)
|
|
626
596
|
|
|
627
597
|
if json_output:
|
|
628
598
|
return json.dumps(workspace, indent=2)
|
|
@@ -646,11 +616,8 @@ def get_workspace(workspace_id: str, json_output: bool = False) -> str:
|
|
|
646
616
|
f" Port: {workspace.get('ssh_port', 22)}",
|
|
647
617
|
f" User: {workspace.get('ssh_user', 'root')}",
|
|
648
618
|
])
|
|
649
|
-
elif status
|
|
650
|
-
lines.extend([
|
|
651
|
-
"",
|
|
652
|
-
"SSH: Run 'wafer workspaces attach' to get SSH credentials",
|
|
653
|
-
])
|
|
619
|
+
elif status == "creating":
|
|
620
|
+
lines.extend(["", "SSH: available once workspace is running"])
|
|
654
621
|
|
|
655
622
|
return "\n".join(lines)
|
|
656
623
|
|
|
@@ -730,6 +697,7 @@ def exec_command(
|
|
|
730
697
|
command: str,
|
|
731
698
|
timeout_seconds: int | None = None,
|
|
732
699
|
routing: str | None = None,
|
|
700
|
+
pull_image: bool = False,
|
|
733
701
|
) -> int:
|
|
734
702
|
"""Execute a command in workspace, streaming output.
|
|
735
703
|
|
|
@@ -753,7 +721,7 @@ def exec_command(
|
|
|
753
721
|
# Base64 encode command to avoid escaping issues
|
|
754
722
|
command_b64 = base64.b64encode(command.encode("utf-8")).decode("utf-8")
|
|
755
723
|
|
|
756
|
-
request_body: dict = {"command_b64": command_b64}
|
|
724
|
+
request_body: dict = {"command_b64": command_b64, "pull_image": pull_image}
|
|
757
725
|
if timeout_seconds:
|
|
758
726
|
request_body["timeout_seconds"] = timeout_seconds
|
|
759
727
|
|
|
@@ -803,3 +771,83 @@ def exec_command(
|
|
|
803
771
|
) from e
|
|
804
772
|
except httpx.RequestError as e:
|
|
805
773
|
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def exec_command_capture(
|
|
777
|
+
workspace_id: str,
|
|
778
|
+
command: str,
|
|
779
|
+
timeout_seconds: int | None = None,
|
|
780
|
+
routing: str | None = None,
|
|
781
|
+
pull_image: bool = False,
|
|
782
|
+
) -> tuple[int, str]:
|
|
783
|
+
"""Execute a command in workspace and capture output.
|
|
784
|
+
|
|
785
|
+
Similar to exec_command but returns output as string instead of printing.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
workspace_id: Workspace ID or name
|
|
789
|
+
command: Command to execute
|
|
790
|
+
timeout_seconds: Execution timeout (default: 300)
|
|
791
|
+
routing: Routing hint - "auto", "gpu", "cpu", or "baremetal"
|
|
792
|
+
|
|
793
|
+
Returns:
|
|
794
|
+
Tuple of (exit_code, output_string)
|
|
795
|
+
"""
|
|
796
|
+
import base64
|
|
797
|
+
|
|
798
|
+
assert workspace_id, "Workspace ID must be non-empty"
|
|
799
|
+
assert command, "Command must be non-empty"
|
|
800
|
+
|
|
801
|
+
api_url, headers = _get_client()
|
|
802
|
+
|
|
803
|
+
# Base64 encode command to avoid escaping issues
|
|
804
|
+
command_b64 = base64.b64encode(command.encode("utf-8")).decode("utf-8")
|
|
805
|
+
|
|
806
|
+
request_body: dict = {"command_b64": command_b64, "pull_image": pull_image}
|
|
807
|
+
if timeout_seconds:
|
|
808
|
+
request_body["timeout_seconds"] = timeout_seconds
|
|
809
|
+
|
|
810
|
+
if routing:
|
|
811
|
+
request_body["requirements"] = {"routing": routing}
|
|
812
|
+
|
|
813
|
+
output_lines: list[str] = []
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
with httpx.Client(timeout=None, headers=headers) as client:
|
|
817
|
+
with client.stream(
|
|
818
|
+
"POST",
|
|
819
|
+
f"{api_url}/v1/workspaces/{workspace_id}/exec",
|
|
820
|
+
json=request_body,
|
|
821
|
+
) as response:
|
|
822
|
+
if response.status_code != 200:
|
|
823
|
+
error_body = response.read().decode("utf-8", errors="replace")
|
|
824
|
+
raise RuntimeError(
|
|
825
|
+
_friendly_error(response.status_code, error_body, workspace_id)
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
exit_code = 0
|
|
829
|
+
for line in response.iter_lines():
|
|
830
|
+
if not line or not line.startswith("data: "):
|
|
831
|
+
continue
|
|
832
|
+
|
|
833
|
+
event = _parse_sse_content(line[6:])
|
|
834
|
+
|
|
835
|
+
# Skip sync events
|
|
836
|
+
if event.sync_event:
|
|
837
|
+
continue
|
|
838
|
+
|
|
839
|
+
if event.output is not None:
|
|
840
|
+
output_lines.append(event.output)
|
|
841
|
+
|
|
842
|
+
if event.exit_code is not None:
|
|
843
|
+
exit_code = event.exit_code
|
|
844
|
+
break
|
|
845
|
+
|
|
846
|
+
return exit_code, "\n".join(output_lines)
|
|
847
|
+
|
|
848
|
+
except httpx.HTTPStatusError as e:
|
|
849
|
+
raise RuntimeError(
|
|
850
|
+
_friendly_error(e.response.status_code, e.response.text, workspace_id)
|
|
851
|
+
) from e
|
|
852
|
+
except httpx.RequestError as e:
|
|
853
|
+
raise RuntimeError(f"Could not reach API: {e}") from e
|
|
@@ -1,36 +1,40 @@
|
|
|
1
|
-
wafer/GUIDE.md,sha256=
|
|
1
|
+
wafer/GUIDE.md,sha256=G6P4aFZslEXiHmVjtTB3_OIpGK5d1tSiqxtawASVUZg,3588
|
|
2
2
|
wafer/__init__.py,sha256=kBM_ONCpU6UUMBOH8Tmg4A88sNFnbaD59o61cJs-uYM,90
|
|
3
3
|
wafer/analytics.py,sha256=Xxw3bbY3XLgedSJPwzIOBJIjyycIiornWCpjoWbTKYU,8190
|
|
4
|
-
wafer/api_client.py,sha256=
|
|
4
|
+
wafer/api_client.py,sha256=i_Az2b2llC3DSW8yOL-BKqa7LSKuxOr8hSN40s-oQXY,6313
|
|
5
5
|
wafer/auth.py,sha256=acBVOz-3la6avztDGjtLRopdjNRIqbrV4tRMM1FAmHI,13682
|
|
6
6
|
wafer/autotuner.py,sha256=6gH0Ho7T58EFerMQcHQxshWe3DF4qU7fb5xthAh5SPM,44364
|
|
7
7
|
wafer/billing.py,sha256=jbLB2lI4_9f2KD8uEFDi_ixLlowe5hasC0TIZJyIXRg,7163
|
|
8
|
-
wafer/cli.py,sha256=
|
|
8
|
+
wafer/cli.py,sha256=c7IEKZ-Ge-w4LQ1GVAqvuKkodBYtT6NnxNeGf6me9pc,252787
|
|
9
9
|
wafer/config.py,sha256=h5Eo9_yfWqWGoPNdVQikI9GoZVUeysunSYiixf1mKcw,3411
|
|
10
|
-
wafer/corpus.py,sha256=
|
|
11
|
-
wafer/evaluate.py,sha256=
|
|
10
|
+
wafer/corpus.py,sha256=x5aFhCsTSAtgzFG9AMFpqq92Ej63mXofL-vvvpjj1sM,12913
|
|
11
|
+
wafer/evaluate.py,sha256=Lf_H6Afrdo4k9JOnI27wchFwEteqy73gKt5gLQgaXSE,172671
|
|
12
12
|
wafer/global_config.py,sha256=fhaR_RU3ufMksDmOohH1OLeQ0JT0SDW1hEip_zaP75k,11345
|
|
13
13
|
wafer/gpu_run.py,sha256=TwqXy72T7f2I7e6n5WWod3xgxCPnDhU0BgLsB4CUoQY,9716
|
|
14
14
|
wafer/inference.py,sha256=tZCO5i05FKY27ewis3CSBHFBeFbXY3xwj0DSjdoMY9s,4314
|
|
15
|
-
wafer/kernel_scope.py,sha256=
|
|
15
|
+
wafer/kernel_scope.py,sha256=lQDSTx_IBIhjUOkBCOohB13MV7HjkH_dLcvJRs02DHE,20850
|
|
16
16
|
wafer/ncu_analyze.py,sha256=rAWzKQRZEY6E_CL3gAWUaW3uZ4kvQVZskVCPDpsFJuE,24633
|
|
17
|
-
wafer/nsys_analyze.py,sha256=
|
|
17
|
+
wafer/nsys_analyze.py,sha256=AhNcjPaapB0QCbqiHRXvyy-ccjevvVwEyxes84D28JU,36124
|
|
18
|
+
wafer/nsys_profile.py,sha256=JNB6EgMlKbLnF0vw0av97BSfZRvT8Zrj2QbwbaUKtdw,15497
|
|
19
|
+
wafer/output.py,sha256=SL8f6AccacGY486bHHxc_zHkFNiqPFWJPycSFGUGWHc,8002
|
|
18
20
|
wafer/problems.py,sha256=ce2sy10A1nnNUG3VGsseTS8jL7LZsku4dE8zVf9JHQ4,11296
|
|
19
21
|
wafer/rocprof_compute.py,sha256=Tu16Vb05b2grvheFWi1XLGlAr6m48NEDeZoDyw_4Uzw,19885
|
|
20
22
|
wafer/rocprof_sdk.py,sha256=fAYCxpfJa5BZTTkIMBOXg4KsYK4i_wNOKrJJn1ZfypM,10086
|
|
21
23
|
wafer/rocprof_systems.py,sha256=4IWbMcbYk1x_8iS7P3FC_u5sgH6EXADCtR2lV9id80M,18629
|
|
24
|
+
wafer/ssh_keys.py,sha256=9kSdhV_dg9T6pQu2JmNQptarkkwGtN9rLyRkI1bW4i4,8094
|
|
22
25
|
wafer/target_lock.py,sha256=SDKhNzv2N7gsphGflcNni9FE5YYuAMuEthngAJEo4Gs,7809
|
|
23
26
|
wafer/targets.py,sha256=9r-iRWoKSH5cQl1LcamaX-T7cNVOg99ngIm_hlRk-qU,26922
|
|
27
|
+
wafer/targets_ops.py,sha256=FJQhlQ4MfOMN5ZNaVfqUvrkRwGjOXI22cNTIEVSKeSE,21488
|
|
24
28
|
wafer/tracelens.py,sha256=g9ZIeFyNojZn4uTd3skPqIrRiL7aMJOz_-GOd3aiyy4,7998
|
|
25
|
-
wafer/wevin_cli.py,sha256=
|
|
26
|
-
wafer/workspaces.py,sha256=
|
|
27
|
-
wafer/skills/wafer-guide/SKILL.md,sha256=
|
|
29
|
+
wafer/wevin_cli.py,sha256=4cZ05GFCGBq11ekVQH_AgaqnITVq6IUfwwHo6CeHFN4,22179
|
|
30
|
+
wafer/workspaces.py,sha256=kN8-XFffodFflI9cuIllqd6VQGFnlV1h-Z28oBh4Lms,30100
|
|
31
|
+
wafer/skills/wafer-guide/SKILL.md,sha256=KWetJw2TVTbz11_nzqazqOJWWRlbHRFShs4sOoreiWo,3255
|
|
28
32
|
wafer/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
33
|
wafer/templates/ask_docs.py,sha256=Lxs-faz9v5m4Qa4NjF2X_lE8KwM9ES9MNJkxo7ep56o,2256
|
|
30
34
|
wafer/templates/optimize_kernel.py,sha256=u6AL7Q3uttqlnBLzcoFdsiPq5lV2TV3bgqwCYYlK9gk,2357
|
|
31
35
|
wafer/templates/trace_analyze.py,sha256=XE1VqzVkIUsZbXF8EzQdDYgg-AZEYAOFpr6B_vnRELc,2880
|
|
32
|
-
wafer_cli-0.2.
|
|
33
|
-
wafer_cli-0.2.
|
|
34
|
-
wafer_cli-0.2.
|
|
35
|
-
wafer_cli-0.2.
|
|
36
|
-
wafer_cli-0.2.
|
|
36
|
+
wafer_cli-0.2.10.dist-info/METADATA,sha256=4sLkPJrutlDH8Y8YdEEflIg4ujqboKdZVb_vlGW1QO8,560
|
|
37
|
+
wafer_cli-0.2.10.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
38
|
+
wafer_cli-0.2.10.dist-info/entry_points.txt,sha256=WqB7hB__WhtPY8y1cO2sZiUz7fCq6Ik-usAigpeFvWE,41
|
|
39
|
+
wafer_cli-0.2.10.dist-info/top_level.txt,sha256=2MK1IVMWfpLL8BZCQ3E9aG6L6L666gSA_teYlwan4fs,6
|
|
40
|
+
wafer_cli-0.2.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|