virtuai-cli 1.0.0__tar.gz → 1.0.2__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.
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/PKG-INFO +1 -1
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/pyproject.toml +1 -1
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/__init__.py +1 -1
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/pull.py +19 -6
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli.egg-info/SOURCES.txt +1 -0
- virtuai_cli-1.0.2/tests/test_pull_fallback.py +79 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/README.md +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/setup.cfg +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/browser.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/agents.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/tui.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/chat/widgets.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/opencode.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/src/virtuai_cli.egg-info/top_level.txt +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/tests/test_browser.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/tests/test_frame_concurrency.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/tests/test_process_group_kill.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/tests/test_run_single_instance.py +0 -0
- {virtuai_cli-1.0.0 → virtuai_cli-1.0.2}/tests/test_session_isolation.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "1.0.
|
|
2
|
+
__version__ = "1.0.2"
|
|
@@ -35,7 +35,10 @@ logger = logging.getLogger(__name__)
|
|
|
35
35
|
_HEARTBEAT_SECONDS = 5.0
|
|
36
36
|
#: Output is sent this often while there is some, so a stream reads as one.
|
|
37
37
|
_FLUSH_SECONDS = 0.25
|
|
38
|
-
|
|
38
|
+
#: A poll is cheap, and the only thing waiting behind a long backoff is the
|
|
39
|
+
#: agent: capped at 5 s. With (…, 10, 20, 30) a runner came back ~20 s after
|
|
40
|
+
#: the server did on a dev rollout.
|
|
41
|
+
_RETRY_DELAYS = (1, 2, 3, 5)
|
|
39
42
|
#: How long a finished job keeps trying to hand in its result.
|
|
40
43
|
_RESULT_RETRY_SECONDS = 120.0
|
|
41
44
|
#: Past this many jobs in hand, wait for some to finish before asking for more.
|
|
@@ -142,12 +145,20 @@ class _Session:
|
|
|
142
145
|
"ready": await runner._ready_payload(self.workdir),
|
|
143
146
|
}
|
|
144
147
|
resp = await self.http.post("/api/cli/v2/hello", json=body)
|
|
145
|
-
if resp.status_code == 404:
|
|
146
|
-
raise PullUnavailable()
|
|
147
148
|
if resp.status_code == 401:
|
|
148
149
|
raise TokenRejected("the server rejected this runner's token; pair it again")
|
|
149
|
-
resp.
|
|
150
|
-
|
|
150
|
+
if resp.status_code >= 500:
|
|
151
|
+
resp.raise_for_status() # a server in trouble: retried
|
|
152
|
+
# Anything else that is not a v2 hello means this server does not
|
|
153
|
+
# offer pull. Not only 404: a server from before v2 answers a POST
|
|
154
|
+
# here with 405 (its frontend's catch-all route), and some proxies
|
|
155
|
+
# with an HTML page. 1.0.0 retried those forever and never connected.
|
|
156
|
+
try:
|
|
157
|
+
data = resp.json() if resp.status_code == 200 else None
|
|
158
|
+
except ValueError:
|
|
159
|
+
data = None
|
|
160
|
+
if not isinstance(data, dict) or not data.get("runner_id"):
|
|
161
|
+
raise PullUnavailable()
|
|
151
162
|
self.runner_id = data["runner_id"]
|
|
152
163
|
self.wait = int(data.get("wait_seconds", self.wait))
|
|
153
164
|
self.heartbeat = float(data.get("heartbeat_seconds", self.heartbeat))
|
|
@@ -192,7 +203,9 @@ class _Session:
|
|
|
192
203
|
continue
|
|
193
204
|
if resp.status_code == 401:
|
|
194
205
|
raise TokenRejected("the server rejected this runner's token; pair it again")
|
|
195
|
-
if resp.status_code
|
|
206
|
+
if resp.status_code in (404, 405) or (
|
|
207
|
+
resp.status_code == 200
|
|
208
|
+
and not resp.headers.get("content-type", "").startswith("application/json")):
|
|
196
209
|
not_found += 1
|
|
197
210
|
if not_found >= _GONE_AFTER_404S:
|
|
198
211
|
raise PullUnavailable()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""CLI 1.0 against a server that does not offer pull must use the socket.
|
|
2
|
+
|
|
3
|
+
1.0.0 fell back only on a 404. A server from before the v2 API answers a
|
|
4
|
+
POST to /api/cli/v2/hello with 405 — its frontend's catch-all route — and
|
|
5
|
+
1.0.0 retried that forever and never connected. Every answer that is not a
|
|
6
|
+
v2 hello now means "no pull here", except a rejected token (reported) and a
|
|
7
|
+
server error (retried).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[0] / ".." / "src"))
|
|
20
|
+
|
|
21
|
+
from virtuai_cli import pull # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hello_against(response: httpx.Response, tmp_path):
|
|
25
|
+
session = pull._Session("http://x", "t", tmp_path, lambda *a: None, None, "host")
|
|
26
|
+
session.http = httpx.AsyncClient(base_url="http://x",
|
|
27
|
+
transport=httpx.MockTransport(lambda req: response))
|
|
28
|
+
return asyncio.run(session.hello())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("response", [
|
|
32
|
+
httpx.Response(404, json={"detail": "Not Found"}), # v2 code, flag off
|
|
33
|
+
httpx.Response(405, json={"detail": "Method Not Allowed"}), # a server from before v2
|
|
34
|
+
httpx.Response(200, text="<!doctype html><title>VirtuAI</title>", # a catch-all page
|
|
35
|
+
headers={"content-type": "text/html"}),
|
|
36
|
+
httpx.Response(200, json={"ok": True}), # JSON, but not a hello
|
|
37
|
+
httpx.Response(403, json={"detail": "Forbidden"}),
|
|
38
|
+
], ids=["404", "405", "html", "json-without-runner", "403"])
|
|
39
|
+
def test_anything_but_a_v2_hello_means_use_the_socket(response, tmp_path):
|
|
40
|
+
with pytest.raises(pull.PullUnavailable):
|
|
41
|
+
_hello_against(response, tmp_path)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_a_rejected_token_is_reported_not_retried(tmp_path):
|
|
45
|
+
with pytest.raises(pull.TokenRejected):
|
|
46
|
+
_hello_against(httpx.Response(401, json={"detail": "Invalid CLI token"}), tmp_path)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_a_server_error_is_retried_not_taken_as_no_pull(tmp_path):
|
|
50
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
51
|
+
_hello_against(httpx.Response(503, text="unavailable"), tmp_path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_a_real_hello_registers(tmp_path):
|
|
55
|
+
_hello_against(httpx.Response(200, json={"runner_id": "c-1", "wait_seconds": 25,
|
|
56
|
+
"heartbeat_seconds": 5}), tmp_path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_run_forever_falls_back_to_the_socket_on_an_old_server(monkeypatch, tmp_path):
|
|
60
|
+
"""End to end in the runner: hello gets 405, the socket session starts."""
|
|
61
|
+
from virtuai_cli import runner
|
|
62
|
+
|
|
63
|
+
started = []
|
|
64
|
+
|
|
65
|
+
async def fake_session(*a, **kw):
|
|
66
|
+
started.append(True)
|
|
67
|
+
raise SystemExit(0)
|
|
68
|
+
|
|
69
|
+
real_client = httpx.AsyncClient
|
|
70
|
+
|
|
71
|
+
def client(*a, **kw):
|
|
72
|
+
kw["transport"] = httpx.MockTransport(lambda req: httpx.Response(405, json={}))
|
|
73
|
+
return real_client(*a, **kw)
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr(pull.httpx, "AsyncClient", client)
|
|
76
|
+
monkeypatch.setattr(runner, "run_session", fake_session)
|
|
77
|
+
monkeypatch.delenv("VIRTUAI_TRANSPORT", raising=False)
|
|
78
|
+
asyncio.run(runner.run_forever("https://old.example", "t", tmp_path, lambda *a: None, None))
|
|
79
|
+
assert started == [True]
|
|
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
|