wafer-cli 0.2.16__tar.gz → 0.2.18__tar.gz
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_cli-0.2.16 → wafer_cli-0.2.18}/PKG-INFO +1 -1
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/pyproject.toml +1 -1
- wafer_cli-0.2.18/tests/test_auth.py +193 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/auth.py +7 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/evaluate.py +2 -1
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/workspaces.py +59 -8
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/PKG-INFO +1 -1
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/SOURCES.txt +1 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/README.md +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/setup.cfg +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_analytics.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_billing.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_cli_coverage.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_cli_parity_integration.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_config_integration.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_file_operations_integration.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_kernel_scope_cli.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_nsys_analyze.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_nsys_profile.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_output.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_rocprof_compute_integration.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_skill_commands.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_ssh_integration.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_targets_ops.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_wevin_cli.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/tests/test_workflow_integration.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/GUIDE.md +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/__init__.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/analytics.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/api_client.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/autotuner.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/billing.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/cli.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/config.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/corpus.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/global_config.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/gpu_run.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/inference.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/kernel_scope.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/ncu_analyze.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/nsys_analyze.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/nsys_profile.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/output.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/problems.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/rocprof_compute.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/rocprof_sdk.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/rocprof_systems.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/skills/wafer-guide/SKILL.md +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/ssh_keys.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/target_lock.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/targets.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/targets_ops.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/__init__.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/ask_docs.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/optimize_kernel.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/optimize_kernelbench.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/templates/trace_analyze.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/tracelens.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer/wevin_cli.py +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/dependency_links.txt +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/entry_points.txt +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/requires.txt +0 -0
- {wafer_cli-0.2.16 → wafer_cli-0.2.18}/wafer_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Tests for authentication and credential management.
|
|
2
|
+
|
|
3
|
+
Tests the auth module's token verification and network error handling.
|
|
4
|
+
|
|
5
|
+
Run with: PYTHONPATH=. uv run pytest tests/test_auth.py -v
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from unittest.mock import MagicMock, patch
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# Unit Tests for get_valid_token Network Error Handling
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestGetValidTokenNetworkErrors:
|
|
19
|
+
"""Test that get_valid_token handles network errors gracefully."""
|
|
20
|
+
|
|
21
|
+
def test_returns_none_on_read_timeout(self) -> None:
|
|
22
|
+
"""get_valid_token returns None when verify_token times out."""
|
|
23
|
+
from wafer.auth import Credentials, get_valid_token
|
|
24
|
+
|
|
25
|
+
mock_creds = Credentials(
|
|
26
|
+
access_token="test_token",
|
|
27
|
+
refresh_token="test_refresh",
|
|
28
|
+
email="test@example.com",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
32
|
+
with patch("wafer.auth.verify_token", side_effect=httpx.ReadTimeout("timeout")):
|
|
33
|
+
result = get_valid_token()
|
|
34
|
+
|
|
35
|
+
assert result is None
|
|
36
|
+
|
|
37
|
+
def test_returns_none_on_connect_timeout(self) -> None:
|
|
38
|
+
"""get_valid_token returns None when connection times out."""
|
|
39
|
+
from wafer.auth import Credentials, get_valid_token
|
|
40
|
+
|
|
41
|
+
mock_creds = Credentials(
|
|
42
|
+
access_token="test_token",
|
|
43
|
+
refresh_token="test_refresh",
|
|
44
|
+
email="test@example.com",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
48
|
+
with patch("wafer.auth.verify_token", side_effect=httpx.ConnectTimeout("timeout")):
|
|
49
|
+
result = get_valid_token()
|
|
50
|
+
|
|
51
|
+
assert result is None
|
|
52
|
+
|
|
53
|
+
def test_returns_none_on_connection_error(self) -> None:
|
|
54
|
+
"""get_valid_token returns None when connection is refused."""
|
|
55
|
+
from wafer.auth import Credentials, get_valid_token
|
|
56
|
+
|
|
57
|
+
mock_creds = Credentials(
|
|
58
|
+
access_token="test_token",
|
|
59
|
+
refresh_token="test_refresh",
|
|
60
|
+
email="test@example.com",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
64
|
+
with patch("wafer.auth.verify_token", side_effect=httpx.ConnectError("refused")):
|
|
65
|
+
result = get_valid_token()
|
|
66
|
+
|
|
67
|
+
assert result is None
|
|
68
|
+
|
|
69
|
+
def test_returns_none_on_refresh_timeout(self) -> None:
|
|
70
|
+
"""get_valid_token returns None when refresh token request times out."""
|
|
71
|
+
from wafer.auth import Credentials, get_valid_token
|
|
72
|
+
|
|
73
|
+
mock_creds = Credentials(
|
|
74
|
+
access_token="expired_token",
|
|
75
|
+
refresh_token="test_refresh",
|
|
76
|
+
email="test@example.com",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Create a mock 401 response for verify_token
|
|
80
|
+
mock_response = MagicMock()
|
|
81
|
+
mock_response.status_code = 401
|
|
82
|
+
http_error = httpx.HTTPStatusError("401", request=MagicMock(), response=mock_response)
|
|
83
|
+
|
|
84
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
85
|
+
with patch("wafer.auth.verify_token", side_effect=http_error):
|
|
86
|
+
with patch("wafer.auth.refresh_access_token", side_effect=httpx.ReadTimeout("timeout")):
|
|
87
|
+
result = get_valid_token()
|
|
88
|
+
|
|
89
|
+
assert result is None
|
|
90
|
+
|
|
91
|
+
def test_returns_token_when_verify_succeeds(self) -> None:
|
|
92
|
+
"""get_valid_token returns token when verification succeeds."""
|
|
93
|
+
from wafer.auth import Credentials, UserInfo, get_valid_token
|
|
94
|
+
|
|
95
|
+
mock_creds = Credentials(
|
|
96
|
+
access_token="valid_token",
|
|
97
|
+
refresh_token="test_refresh",
|
|
98
|
+
email="test@example.com",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
mock_user_info = UserInfo(user_id="user123", email="test@example.com")
|
|
102
|
+
|
|
103
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
104
|
+
with patch("wafer.auth.verify_token", return_value=mock_user_info):
|
|
105
|
+
result = get_valid_token()
|
|
106
|
+
|
|
107
|
+
assert result == "valid_token"
|
|
108
|
+
|
|
109
|
+
def test_returns_none_when_no_credentials(self) -> None:
|
|
110
|
+
"""get_valid_token returns None when no credentials are stored."""
|
|
111
|
+
from wafer.auth import get_valid_token
|
|
112
|
+
|
|
113
|
+
with patch("wafer.auth.load_credentials", return_value=None):
|
|
114
|
+
result = get_valid_token()
|
|
115
|
+
|
|
116
|
+
assert result is None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestVerifyTokenRaisesOnNetworkError:
|
|
120
|
+
"""Document that verify_token raises network errors (caller must handle)."""
|
|
121
|
+
|
|
122
|
+
def test_verify_token_raises_read_timeout(self) -> None:
|
|
123
|
+
"""verify_token raises ReadTimeout when API times out."""
|
|
124
|
+
from wafer.auth import verify_token
|
|
125
|
+
|
|
126
|
+
with patch("wafer.auth.get_api_url", return_value="https://api.example.com"):
|
|
127
|
+
with patch("httpx.Client") as mock_client_class:
|
|
128
|
+
mock_client = MagicMock()
|
|
129
|
+
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
130
|
+
mock_client.__exit__ = MagicMock(return_value=False)
|
|
131
|
+
mock_client.post.side_effect = httpx.ReadTimeout("timeout")
|
|
132
|
+
mock_client_class.return_value = mock_client
|
|
133
|
+
|
|
134
|
+
with pytest.raises(httpx.ReadTimeout):
|
|
135
|
+
verify_token("test_token")
|
|
136
|
+
|
|
137
|
+
def test_verify_token_raises_connect_error(self) -> None:
|
|
138
|
+
"""verify_token raises ConnectError when API is unreachable."""
|
|
139
|
+
from wafer.auth import verify_token
|
|
140
|
+
|
|
141
|
+
with patch("wafer.auth.get_api_url", return_value="https://api.example.com"):
|
|
142
|
+
with patch("httpx.Client") as mock_client_class:
|
|
143
|
+
mock_client = MagicMock()
|
|
144
|
+
mock_client.__enter__ = MagicMock(return_value=mock_client)
|
|
145
|
+
mock_client.__exit__ = MagicMock(return_value=False)
|
|
146
|
+
mock_client.post.side_effect = httpx.ConnectError("refused")
|
|
147
|
+
mock_client_class.return_value = mock_client
|
|
148
|
+
|
|
149
|
+
with pytest.raises(httpx.ConnectError):
|
|
150
|
+
verify_token("test_token")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# =============================================================================
|
|
154
|
+
# Unit Tests for get_auth_headers
|
|
155
|
+
# =============================================================================
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class TestGetAuthHeaders:
|
|
159
|
+
"""Test get_auth_headers returns empty dict on network errors."""
|
|
160
|
+
|
|
161
|
+
def test_returns_empty_dict_on_network_error(self) -> None:
|
|
162
|
+
"""get_auth_headers returns {} when network error occurs."""
|
|
163
|
+
from wafer.auth import Credentials, get_auth_headers
|
|
164
|
+
|
|
165
|
+
mock_creds = Credentials(
|
|
166
|
+
access_token="test_token",
|
|
167
|
+
refresh_token="test_refresh",
|
|
168
|
+
email="test@example.com",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
172
|
+
with patch("wafer.auth.verify_token", side_effect=httpx.ReadTimeout("timeout")):
|
|
173
|
+
result = get_auth_headers()
|
|
174
|
+
|
|
175
|
+
assert result == {}
|
|
176
|
+
|
|
177
|
+
def test_returns_headers_on_success(self) -> None:
|
|
178
|
+
"""get_auth_headers returns Bearer token when successful."""
|
|
179
|
+
from wafer.auth import Credentials, UserInfo, get_auth_headers
|
|
180
|
+
|
|
181
|
+
mock_creds = Credentials(
|
|
182
|
+
access_token="valid_token",
|
|
183
|
+
refresh_token="test_refresh",
|
|
184
|
+
email="test@example.com",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
mock_user_info = UserInfo(user_id="user123", email="test@example.com")
|
|
188
|
+
|
|
189
|
+
with patch("wafer.auth.load_credentials", return_value=mock_creds):
|
|
190
|
+
with patch("wafer.auth.verify_token", return_value=mock_user_info):
|
|
191
|
+
result = get_auth_headers()
|
|
192
|
+
|
|
193
|
+
assert result == {"Authorization": "Bearer valid_token"}
|
|
@@ -191,6 +191,10 @@ def get_valid_token() -> str | None:
|
|
|
191
191
|
if e.response.status_code != 401:
|
|
192
192
|
# Not an auth error, re-raise
|
|
193
193
|
raise
|
|
194
|
+
except httpx.RequestError:
|
|
195
|
+
# Network error (timeout, connection refused, DNS failure, etc.)
|
|
196
|
+
# Cannot verify token - return None to trigger re-login prompt
|
|
197
|
+
return None
|
|
194
198
|
|
|
195
199
|
# Token expired, try refresh
|
|
196
200
|
if not creds.refresh_token:
|
|
@@ -203,6 +207,9 @@ def get_valid_token() -> str | None:
|
|
|
203
207
|
except httpx.HTTPStatusError:
|
|
204
208
|
# Refresh failed, need to re-login
|
|
205
209
|
return None
|
|
210
|
+
except httpx.RequestError:
|
|
211
|
+
# Network error during refresh - return None to trigger re-login prompt
|
|
212
|
+
return None
|
|
206
213
|
|
|
207
214
|
|
|
208
215
|
def _find_free_port() -> int:
|
|
@@ -354,7 +354,8 @@ def _build_docker_pip_install_cmd(target: BaremetalTarget | VMTarget) -> str:
|
|
|
354
354
|
)
|
|
355
355
|
|
|
356
356
|
# Install uv (fast, reliable) - use pip3 for compatibility
|
|
357
|
-
|
|
357
|
+
# Use --break-system-packages for Python 3.12+ with PEP 668 externally managed environments
|
|
358
|
+
commands.append("pip3 install --break-system-packages uv")
|
|
358
359
|
|
|
359
360
|
# Install torch with custom index if specified (like Modal's two-phase install)
|
|
360
361
|
# Use --system --break-system-packages to install to container's Python
|
|
@@ -13,7 +13,7 @@ import httpx
|
|
|
13
13
|
from .api_client import get_api_url
|
|
14
14
|
from .auth import get_auth_headers
|
|
15
15
|
|
|
16
|
-
VALID_STATUSES = {"creating", "running"}
|
|
16
|
+
VALID_STATUSES = {"creating", "running", "error"}
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def _get_client() -> tuple[str, dict[str, str]]:
|
|
@@ -211,17 +211,39 @@ def list_workspaces(json_output: bool = False) -> str:
|
|
|
211
211
|
lines = ["Workspaces:", ""]
|
|
212
212
|
for ws in workspaces:
|
|
213
213
|
status = ws.get("status", "unknown")
|
|
214
|
-
status_icon = {"running": "●", "creating": "◐"}.get(status, "?")
|
|
214
|
+
status_icon = {"running": "●", "creating": "◐", "error": "✗"}.get(status, "?")
|
|
215
215
|
lines.append(f" {status_icon} {ws['name']} ({ws['id']})")
|
|
216
216
|
lines.append(f" GPU: {ws.get('gpu_type', 'N/A')} | Image: {ws.get('image', 'N/A')}")
|
|
217
|
-
|
|
217
|
+
|
|
218
|
+
if status == "error":
|
|
219
|
+
lines.append(
|
|
220
|
+
f" Status: Provisioning failed. Delete and recreate: wafer workspaces delete {ws['name']}"
|
|
221
|
+
)
|
|
222
|
+
elif ws.get("ssh_host") and ws.get("ssh_port") and ws.get("ssh_user"):
|
|
223
|
+
ssh_line = f" SSH: ssh -p {ws['ssh_port']} {ws['ssh_user']}@{ws['ssh_host']}"
|
|
224
|
+
if status == "creating":
|
|
225
|
+
ssh_line += " (finalizing...)"
|
|
226
|
+
lines.append(ssh_line)
|
|
227
|
+
elif status == "running":
|
|
218
228
|
lines.append(
|
|
219
|
-
f" SSH:
|
|
229
|
+
f" Status: Running but SSH not ready. Try: wafer workspaces delete {ws['name']} && wafer workspaces create {ws['name']} --wait"
|
|
220
230
|
)
|
|
221
231
|
else:
|
|
222
|
-
lines.append(" SSH: Not ready (
|
|
232
|
+
lines.append(" SSH: Not ready (workspace is still creating)")
|
|
223
233
|
lines.append("")
|
|
224
234
|
|
|
235
|
+
# Add SSH tip for users with running workspaces
|
|
236
|
+
has_running_with_ssh = any(
|
|
237
|
+
ws.get("status") == "running" and ws.get("ssh_host")
|
|
238
|
+
for ws in workspaces
|
|
239
|
+
)
|
|
240
|
+
if has_running_with_ssh:
|
|
241
|
+
lines.append("Tip: SSH directly for interactive work. 'exec' is for quick commands only.")
|
|
242
|
+
|
|
243
|
+
has_error = any(ws.get("status") == "error" for ws in workspaces)
|
|
244
|
+
if has_error:
|
|
245
|
+
lines.append("Note: Error workspaces are auto-cleaned after 12 hours.")
|
|
246
|
+
|
|
225
247
|
return "\n".join(lines)
|
|
226
248
|
|
|
227
249
|
|
|
@@ -441,6 +463,12 @@ def sync_files(
|
|
|
441
463
|
f"Workspace {workspace_id} has invalid status '{workspace_status}'. "
|
|
442
464
|
f"Valid statuses: {VALID_STATUSES}"
|
|
443
465
|
)
|
|
466
|
+
if workspace_status == "error":
|
|
467
|
+
raise RuntimeError(
|
|
468
|
+
f"Workspace provisioning failed. Delete and recreate:\n"
|
|
469
|
+
f" wafer workspaces delete {workspace_id}\n"
|
|
470
|
+
f" wafer workspaces create {ws.get('name', workspace_id)} --wait"
|
|
471
|
+
)
|
|
444
472
|
if workspace_status != "running":
|
|
445
473
|
raise RuntimeError(
|
|
446
474
|
f"Workspace is {workspace_status}. Wait for it to be running before syncing."
|
|
@@ -448,9 +476,14 @@ def sync_files(
|
|
|
448
476
|
ssh_host = ws.get("ssh_host")
|
|
449
477
|
ssh_port = ws.get("ssh_port")
|
|
450
478
|
ssh_user = ws.get("ssh_user")
|
|
451
|
-
|
|
479
|
+
if not ssh_host or not ssh_port or not ssh_user:
|
|
480
|
+
# Workspace is running but SSH credentials are missing - unusual state
|
|
481
|
+
raise RuntimeError(
|
|
482
|
+
f"Workspace is running but SSH not ready.\n"
|
|
483
|
+
f" Delete and recreate: wafer workspaces delete {workspace_id}\n"
|
|
484
|
+
f" Then: wafer workspaces create {ws.get('name', workspace_id)} --wait"
|
|
485
|
+
)
|
|
452
486
|
assert isinstance(ssh_port, int) and ssh_port > 0, "Workspace missing valid ssh_port"
|
|
453
|
-
assert ssh_user, "Workspace missing ssh_user"
|
|
454
487
|
|
|
455
488
|
# Build rsync command
|
|
456
489
|
# -a: archive mode (preserves permissions, etc.)
|
|
@@ -607,16 +640,34 @@ def get_workspace(workspace_id: str, json_output: bool = False) -> str:
|
|
|
607
640
|
f" Last Used: {workspace.get('last_used_at', 'N/A')}",
|
|
608
641
|
]
|
|
609
642
|
|
|
610
|
-
if
|
|
643
|
+
if status == "error":
|
|
644
|
+
lines.extend([
|
|
645
|
+
"",
|
|
646
|
+
"Provisioning failed. Delete and recreate:",
|
|
647
|
+
f" wafer workspaces delete {workspace['name']}",
|
|
648
|
+
f" wafer workspaces create {workspace['name']} --wait",
|
|
649
|
+
"",
|
|
650
|
+
"Note: Error workspaces are auto-cleaned after 12 hours.",
|
|
651
|
+
])
|
|
652
|
+
elif workspace.get("ssh_host"):
|
|
611
653
|
lines.extend([
|
|
612
654
|
"",
|
|
613
655
|
"SSH Info:",
|
|
614
656
|
f" Host: {workspace['ssh_host']}",
|
|
615
657
|
f" Port: {workspace.get('ssh_port', 22)}",
|
|
616
658
|
f" User: {workspace.get('ssh_user', 'root')}",
|
|
659
|
+
"",
|
|
660
|
+
"Tip: SSH directly for interactive work. 'exec' is for quick commands only.",
|
|
617
661
|
])
|
|
618
662
|
elif status == "creating":
|
|
619
663
|
lines.extend(["", "SSH: available once workspace is running"])
|
|
664
|
+
elif status == "running":
|
|
665
|
+
# Running but no SSH credentials - unusual state
|
|
666
|
+
lines.extend([
|
|
667
|
+
"",
|
|
668
|
+
"Status: Running but SSH not ready.",
|
|
669
|
+
f" Delete and recreate: wafer workspaces delete {workspace['name']}",
|
|
670
|
+
])
|
|
620
671
|
|
|
621
672
|
return "\n".join(lines)
|
|
622
673
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|