qbraid-cli 0.16.11__tar.gz → 0.16.13__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.
- {qbraid_cli-0.16.11/qbraid_cli.egg-info → qbraid_cli-0.16.13}/PKG-INFO +4 -4
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/pyproject.toml +4 -4
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/agents/app.py +34 -4
- qbraid_cli-0.16.13/qbraid_cli/agents/provider_children.py +87 -0
- qbraid_cli-0.16.13/qbraid_cli/agents/queue.py +702 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13/qbraid_cli.egg-info}/PKG-INFO +4 -4
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli.egg-info/SOURCES.txt +2 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli.egg-info/requires.txt +3 -3
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/LICENSE +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/README.md +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/admin/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/admin/validation.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/agents/permission_response.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/agents/work.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/ai/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/ai/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/ai/token.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/comms/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/comms/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/compute/instances.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/lab/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/setup.cfg +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/tests/test_cli_e2e.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.13}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.13
|
|
4
4
|
Summary: Command Line Interface for interacting with all parts of the qBraid platform.
|
|
5
5
|
Author-email: qBraid Development Team <contact@qbraid.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
|
|
|
30
30
|
Requires-Dist: rich>=10.11.0
|
|
31
31
|
Requires-Dist: click<=8.5.0
|
|
32
32
|
Requires-Dist: questionary>=2.0.0
|
|
33
|
-
Requires-Dist: qbraid-core>=0.6.
|
|
33
|
+
Requires-Dist: qbraid-core>=0.6.32
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.32; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.32; extra == "envs"
|
|
38
38
|
Provides-Extra: dev
|
|
39
39
|
Requires-Dist: isort; extra == "dev"
|
|
40
40
|
Requires-Dist: black; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qbraid-cli"
|
|
7
|
-
version = "0.16.
|
|
7
|
+
version = "0.16.13"
|
|
8
8
|
description = "Command Line Interface for interacting with all parts of the qBraid platform."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "qBraid Development Team", email = "contact@qbraid.com" }]
|
|
@@ -32,7 +32,7 @@ dependencies = [
|
|
|
32
32
|
"rich>=10.11.0",
|
|
33
33
|
"click<=8.5.0",
|
|
34
34
|
"questionary>=2.0.0",
|
|
35
|
-
"qbraid-core>=0.6.
|
|
35
|
+
"qbraid-core>=0.6.32",
|
|
36
36
|
]
|
|
37
37
|
requires-python = ">= 3.10"
|
|
38
38
|
|
|
@@ -43,8 +43,8 @@ Documentation = "https://docs.qbraid.com/v2/cli/api-reference/qbraid"
|
|
|
43
43
|
Discord = "https://discord.gg/KugF6Cnncm"
|
|
44
44
|
|
|
45
45
|
[project.optional-dependencies]
|
|
46
|
-
compute = ["qbraid-core[compute]>=0.6.
|
|
47
|
-
envs = ["qbraid-core[environments]>=0.6.
|
|
46
|
+
compute = ["qbraid-core[compute]>=0.6.32"]
|
|
47
|
+
envs = ["qbraid-core[environments]>=0.6.32"]
|
|
48
48
|
dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
|
|
49
49
|
|
|
50
50
|
[project.scripts]
|
|
@@ -23,6 +23,7 @@ from rich.table import Table
|
|
|
23
23
|
|
|
24
24
|
from qbraid_cli.handlers import handle_error, run_progress_task
|
|
25
25
|
|
|
26
|
+
from .queue import interrupt, queue_app
|
|
26
27
|
from .work import register_work_commands
|
|
27
28
|
|
|
28
29
|
agents_app = typer.Typer(
|
|
@@ -54,6 +55,8 @@ hooks_app = typer.Typer(
|
|
|
54
55
|
)
|
|
55
56
|
|
|
56
57
|
agents_app.add_typer(hooks_app, name="hooks")
|
|
58
|
+
agents_app.add_typer(queue_app, name="queue")
|
|
59
|
+
agents_app.command("interrupt")(interrupt)
|
|
57
60
|
|
|
58
61
|
inbox_app = typer.Typer(
|
|
59
62
|
help=(
|
|
@@ -1238,6 +1241,9 @@ def agents_update(
|
|
|
1238
1241
|
console.print("[dim]Use 'qbraid agents list' to see active sessions.[/dim]")
|
|
1239
1242
|
raise typer.Exit(code=1)
|
|
1240
1243
|
except Exception as e:
|
|
1244
|
+
if _is_registry_error(e):
|
|
1245
|
+
console.print(f"[red]Cannot update session: {_plain(e)}[/red]", highlight=False)
|
|
1246
|
+
raise typer.Exit(code=1) from e
|
|
1241
1247
|
handle_error(message=str(e))
|
|
1242
1248
|
raise typer.Exit(code=1)
|
|
1243
1249
|
|
|
@@ -1331,6 +1337,12 @@ def agents_kill(
|
|
|
1331
1337
|
matches = [s for s in matches if s.session_id == selected]
|
|
1332
1338
|
|
|
1333
1339
|
session = matches[0]
|
|
1340
|
+
if getattr(session, "runtime_kind", None) == "provider_child":
|
|
1341
|
+
console.print(
|
|
1342
|
+
"[red]Not stopped:[/red] native provider children are read-only. "
|
|
1343
|
+
"Manage this child from its parent agent."
|
|
1344
|
+
)
|
|
1345
|
+
raise typer.Exit(code=1)
|
|
1334
1346
|
pid = session.pid
|
|
1335
1347
|
|
|
1336
1348
|
# Shown as interrupted (another Lab's row this Lab cannot verify):
|
|
@@ -2845,7 +2857,8 @@ def agents_watch(
|
|
|
2845
2857
|
import subprocess as _sp
|
|
2846
2858
|
|
|
2847
2859
|
remote_flags = f"qbraid agents watch {shlex.quote(session_id)} --stream --lines {lines}"
|
|
2848
|
-
|
|
2860
|
+
if not stream:
|
|
2861
|
+
console.print(f"[dim]Watching {session_id} on {compute}... (Ctrl+C to stop)[/dim]")
|
|
2849
2862
|
|
|
2850
2863
|
proc = _sp.Popen( # pylint: disable=consider-using-with
|
|
2851
2864
|
["ssh", "-o", "ConnectTimeout=10", compute, remote_flags],
|
|
@@ -2868,6 +2881,11 @@ def agents_watch(
|
|
|
2868
2881
|
try:
|
|
2869
2882
|
|
|
2870
2883
|
event = json.loads(line)
|
|
2884
|
+
if event.get("runtime_kind") == "provider_child":
|
|
2885
|
+
from .provider_children import render_child_event
|
|
2886
|
+
|
|
2887
|
+
render_child_event(console, event)
|
|
2888
|
+
continue
|
|
2871
2889
|
entry_type = event.get("type", "unknown")
|
|
2872
2890
|
content = event.get("content", "")
|
|
2873
2891
|
ts = event.get("timestamp")
|
|
@@ -2892,12 +2910,18 @@ def agents_watch(
|
|
|
2892
2910
|
console.print(line_text, highlight=False)
|
|
2893
2911
|
except Exception:
|
|
2894
2912
|
console.print(line, highlight=False)
|
|
2913
|
+
remote_status = proc.wait()
|
|
2914
|
+
if remote_status:
|
|
2915
|
+
raise typer.Exit(code=remote_status if remote_status > 0 else 1)
|
|
2895
2916
|
except KeyboardInterrupt:
|
|
2896
|
-
proc.terminate()
|
|
2897
|
-
console.print("\n[dim]Stopped watching.[/dim]")
|
|
2898
|
-
finally:
|
|
2899
2917
|
proc.terminate()
|
|
2900
2918
|
proc.wait()
|
|
2919
|
+
if not stream:
|
|
2920
|
+
console.print("\n[dim]Stopped watching.[/dim]")
|
|
2921
|
+
finally:
|
|
2922
|
+
if proc.poll() is None:
|
|
2923
|
+
proc.terminate()
|
|
2924
|
+
proc.wait()
|
|
2901
2925
|
return
|
|
2902
2926
|
|
|
2903
2927
|
from qbraid_core.services.agents import get_parser_for_tool, watch_transcript
|
|
@@ -2910,6 +2934,12 @@ def agents_watch(
|
|
|
2910
2934
|
console.print("[dim]Use 'qbraid agents list' to see active sessions.[/dim]")
|
|
2911
2935
|
raise typer.Exit(code=1)
|
|
2912
2936
|
|
|
2937
|
+
if getattr(session, "runtime_kind", None) == "provider_child":
|
|
2938
|
+
from .provider_children import watch_child
|
|
2939
|
+
|
|
2940
|
+
watch_child(registry, session.session_id, lines=lines, stream=stream, console=console)
|
|
2941
|
+
return
|
|
2942
|
+
|
|
2913
2943
|
tool_value = session.tool.value if session.tool else None
|
|
2914
2944
|
|
|
2915
2945
|
# OpenCode uses SQLite DB instead of transcript files
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Copyright (c) 2026, qBraid Development Team
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
|
|
4
|
+
"""Read-only CLI observation of provider-native children through Core."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
import unicodedata
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\ud800-\udfff]")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render_child_event(console, event):
|
|
16
|
+
"""Render provider text and actual message endpoints literally and inertly."""
|
|
17
|
+
label = str(event.get("type") or "unknown")
|
|
18
|
+
if event.get("source_agent"):
|
|
19
|
+
label += f" from {event['source_agent']}"
|
|
20
|
+
if event.get("target_agent"):
|
|
21
|
+
label += f" to {event['target_agent']}"
|
|
22
|
+
if event.get("tool_name"):
|
|
23
|
+
label += f" ({event['tool_name']})"
|
|
24
|
+
text = _CONTROL.sub(" ", f"{label}: {event.get('content') or ''}")
|
|
25
|
+
# Match Jobs' single-line policy without importing another command module.
|
|
26
|
+
text = "".join(
|
|
27
|
+
"\ufffd" if unicodedata.category(character) in {"Cf", "Zl", "Zp"} else character for character in text
|
|
28
|
+
)
|
|
29
|
+
console.print(Text(text), highlight=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def watch_child(registry, session_id, *, lines, stream, console):
|
|
33
|
+
"""Poll only Core's bounded, lineage-validated child reader, without permissions."""
|
|
34
|
+
import json
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from qbraid_core.services.agents.provider_children import read_child_entries
|
|
38
|
+
except ImportError as error:
|
|
39
|
+
raise ValueError("Update qbraid-core to watch native provider children safely.") from error
|
|
40
|
+
|
|
41
|
+
from qbraid_core.services.agents.exceptions import AgentSessionNotFoundError
|
|
42
|
+
|
|
43
|
+
if not stream:
|
|
44
|
+
console.print("Watching read-only native child activity... (Ctrl+C to stop)")
|
|
45
|
+
seen = set()
|
|
46
|
+
initial = True
|
|
47
|
+
try:
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
session = registry.get_session(session_id)
|
|
51
|
+
except AgentSessionNotFoundError as error:
|
|
52
|
+
raise ValueError("Native child is no longer registered.") from error
|
|
53
|
+
if getattr(session, "runtime_kind", None) != "provider_child":
|
|
54
|
+
raise ValueError("Session is no longer a native provider child.")
|
|
55
|
+
entries = read_child_entries(session, registry, limit=200)
|
|
56
|
+
current = {entry.id for entry in entries}
|
|
57
|
+
selected = entries[-min(max(lines, 0), 200) :] if initial and lines > 0 else []
|
|
58
|
+
if not initial:
|
|
59
|
+
selected = [entry for entry in entries if entry.id not in seen]
|
|
60
|
+
for entry in selected:
|
|
61
|
+
event = {
|
|
62
|
+
"runtime_kind": "provider_child",
|
|
63
|
+
"id": entry.id,
|
|
64
|
+
"type": entry.type,
|
|
65
|
+
"timestamp": entry.timestamp,
|
|
66
|
+
"content": entry.content or "",
|
|
67
|
+
"tool_name": entry.tool_name,
|
|
68
|
+
"source_agent": entry.source_agent,
|
|
69
|
+
"target_agent": entry.target_agent,
|
|
70
|
+
"model": entry.model,
|
|
71
|
+
"cost_usd": entry.cost_usd,
|
|
72
|
+
"token_count": entry.token_count,
|
|
73
|
+
}
|
|
74
|
+
if stream:
|
|
75
|
+
print(
|
|
76
|
+
json.dumps({key: value for key, value in event.items() if value is not None}),
|
|
77
|
+
flush=True,
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
render_child_event(console, event)
|
|
81
|
+
if current:
|
|
82
|
+
seen = current
|
|
83
|
+
initial = False
|
|
84
|
+
time.sleep(0.5)
|
|
85
|
+
except KeyboardInterrupt:
|
|
86
|
+
if not stream:
|
|
87
|
+
console.print("\nStopped watching.")
|
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
# Copyright (c) 2026, qBraid Development Team
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
|
|
4
|
+
"""Destination-bound queued follow-ups and turn interruption."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import unicodedata
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
queue_app = typer.Typer(help="Queue follow-ups for a managed runtime.", no_args_is_help=True)
|
|
23
|
+
_MAX_REQUEST = 256 * 1024
|
|
24
|
+
_MAX_RESPONSE = 2 * 1024 * 1024
|
|
25
|
+
_SSH_TIMEOUT = 120
|
|
26
|
+
_STATES = {"queued", "sending", "sent", "uncertain", "cancelled", "stale", "expired", "resolved"}
|
|
27
|
+
_ACTION_STATES = {"queue_edit": "queued", "queue_cancel": "cancelled", "queue_resolve": "resolved"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class QueueCommandError(Exception):
|
|
31
|
+
"""A safe failure with a request identifier for reconciliation."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, code, message, request_id=None):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
self.request_id = request_id
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plain(value, multiline=False):
|
|
40
|
+
text = str(value)
|
|
41
|
+
allowed = {"\n", "\u200c", "\u200d"} if multiline else set()
|
|
42
|
+
if multiline:
|
|
43
|
+
text = text.replace("\r\n", "\n").expandtabs(4)
|
|
44
|
+
return "".join(
|
|
45
|
+
(
|
|
46
|
+
"\ufffd"
|
|
47
|
+
if char not in allowed and unicodedata.category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"}
|
|
48
|
+
else char
|
|
49
|
+
)
|
|
50
|
+
for char in text
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _canonical_uuid(value):
|
|
55
|
+
try:
|
|
56
|
+
return isinstance(value, str) and str(uuid.UUID(value)) == value
|
|
57
|
+
except ValueError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _uuid(value=None):
|
|
62
|
+
if value is None:
|
|
63
|
+
return str(uuid.uuid4())
|
|
64
|
+
if not _canonical_uuid(value):
|
|
65
|
+
raise QueueCommandError("INVALID_ARGUMENT", "Use a canonical UUID for request/message IDs.")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _stamp(value):
|
|
70
|
+
try:
|
|
71
|
+
return type(value) in (int, float) and math.isfinite(value) and value > 0
|
|
72
|
+
except OverflowError:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _valid_runtime(value):
|
|
77
|
+
keys = {"pid", "startedAt", "tool", "terminalId", "serverId", "transcriptPath"}
|
|
78
|
+
if not isinstance(value, dict) or not keys <= value.keys():
|
|
79
|
+
return False
|
|
80
|
+
return all(
|
|
81
|
+
(
|
|
82
|
+
isinstance(value["pid"], int) and not isinstance(value["pid"], bool) and value["pid"] > 0,
|
|
83
|
+
value["startedAt"] is None or _stamp(value["startedAt"]),
|
|
84
|
+
value["tool"] in ("claude", "codex"),
|
|
85
|
+
all(
|
|
86
|
+
value[key] is None or isinstance(value[key], str)
|
|
87
|
+
for key in ("terminalId", "serverId", "transcriptPath")
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _valid_receipt(value, session_id, machine_id):
|
|
94
|
+
"""Validate destination-bound history without rejecting earlier boot receipts."""
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
return False
|
|
97
|
+
return all(
|
|
98
|
+
(
|
|
99
|
+
_canonical_uuid(value.get("id")),
|
|
100
|
+
value.get("sessionId") == session_id,
|
|
101
|
+
value.get("machineId") == machine_id,
|
|
102
|
+
isinstance(value.get("bootId"), str) and bool(value["bootId"]),
|
|
103
|
+
_valid_runtime(value.get("expectedRuntime")),
|
|
104
|
+
isinstance(value.get("text"), str) and bool(value["text"]),
|
|
105
|
+
isinstance(value.get("state"), str) and value["state"] in _STATES,
|
|
106
|
+
isinstance(value.get("revision"), int)
|
|
107
|
+
and not isinstance(value["revision"], bool)
|
|
108
|
+
and value["revision"] > 0,
|
|
109
|
+
all(_stamp(value.get(key)) for key in ("createdAt", "updatedAt", "expiresAt")),
|
|
110
|
+
"reason" not in value or isinstance(value["reason"], str),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _new_protocol():
|
|
116
|
+
try:
|
|
117
|
+
from qbraid_core.services.agents.machine_protocol import MachineProtocol
|
|
118
|
+
except ImportError:
|
|
119
|
+
raise QueueCommandError(
|
|
120
|
+
"UNSUPPORTED_OPERATION", "Update qbraid-core on this destination to enable queue controls."
|
|
121
|
+
) from None
|
|
122
|
+
return MachineProtocol()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ssh_exchange(command, payload):
|
|
126
|
+
"""Bound stdout while the owned SSH child runs, including on Windows pipes."""
|
|
127
|
+
# Only stdin is spooled, and it has already passed the 256 KiB request bound.
|
|
128
|
+
# SSH startup output never touches disk; stderr is never captured or shown.
|
|
129
|
+
with tempfile.TemporaryFile() as incoming:
|
|
130
|
+
incoming.write(payload)
|
|
131
|
+
incoming.seek(0)
|
|
132
|
+
deadline = time.monotonic() + _SSH_TIMEOUT
|
|
133
|
+
# Explicit finally below must kill before waiting when output overflows.
|
|
134
|
+
child = subprocess.Popen( # pylint: disable=consider-using-with
|
|
135
|
+
command, stdin=incoming, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
|
|
136
|
+
)
|
|
137
|
+
output = bytearray()
|
|
138
|
+
failed = []
|
|
139
|
+
done = threading.Event()
|
|
140
|
+
stopped = threading.Event()
|
|
141
|
+
descriptor = child.stdout.fileno()
|
|
142
|
+
|
|
143
|
+
def read_output():
|
|
144
|
+
try:
|
|
145
|
+
while not stopped.is_set() and len(output) <= _MAX_RESPONSE:
|
|
146
|
+
chunk = os.read(descriptor, min(65536, _MAX_RESPONSE + 1 - len(output)))
|
|
147
|
+
if not chunk:
|
|
148
|
+
return
|
|
149
|
+
output.extend(chunk)
|
|
150
|
+
except OSError:
|
|
151
|
+
failed.append(True)
|
|
152
|
+
finally:
|
|
153
|
+
done.set()
|
|
154
|
+
|
|
155
|
+
reader = threading.Thread(target=read_output, daemon=True)
|
|
156
|
+
try:
|
|
157
|
+
reader.start()
|
|
158
|
+
if not done.wait(max(0, deadline - time.monotonic())):
|
|
159
|
+
raise subprocess.TimeoutExpired(command, _SSH_TIMEOUT)
|
|
160
|
+
if len(output) > _MAX_RESPONSE or failed:
|
|
161
|
+
raise OSError("Destination output exceeded its bound or could not be read.")
|
|
162
|
+
code = child.wait(timeout=max(0, deadline - time.monotonic()))
|
|
163
|
+
if code:
|
|
164
|
+
raise OSError("Destination protocol exited unsuccessfully.")
|
|
165
|
+
return bytes(output)
|
|
166
|
+
finally:
|
|
167
|
+
# This is only the SSH child just created by this request. Never
|
|
168
|
+
# kill a destination agent or infer that its operation did not run.
|
|
169
|
+
stopped.set()
|
|
170
|
+
if child.poll() is None:
|
|
171
|
+
child.kill()
|
|
172
|
+
child.wait(timeout=5)
|
|
173
|
+
if reader.ident is not None:
|
|
174
|
+
reader.join(timeout=1)
|
|
175
|
+
child.stdout.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _remote_request(compute, request):
|
|
179
|
+
"""One SSH request, with payload on stdin and no transport replay."""
|
|
180
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.@:-]{0,254}", compute):
|
|
181
|
+
raise QueueCommandError("INVALID_ARGUMENT", "Use a configured SSH alias for --compute.")
|
|
182
|
+
encoded = json.dumps(request, ensure_ascii=True, allow_nan=False).encode() + b"\n"
|
|
183
|
+
if len(encoded) > _MAX_REQUEST:
|
|
184
|
+
raise QueueCommandError("REQUEST_TOO_LARGE", "Request exceeds the 256 KiB protocol limit.")
|
|
185
|
+
try:
|
|
186
|
+
raw = _ssh_exchange(
|
|
187
|
+
[
|
|
188
|
+
"ssh",
|
|
189
|
+
"-T",
|
|
190
|
+
"-o",
|
|
191
|
+
"BatchMode=yes",
|
|
192
|
+
"-o",
|
|
193
|
+
"ConnectTimeout=10",
|
|
194
|
+
compute,
|
|
195
|
+
"qbraid agents machine --once",
|
|
196
|
+
],
|
|
197
|
+
encoded,
|
|
198
|
+
)
|
|
199
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
200
|
+
raise QueueCommandError(
|
|
201
|
+
"UNKNOWN_OUTCOME",
|
|
202
|
+
"Destination reply was unavailable or exceeded its size/time limit. "
|
|
203
|
+
"No request was retried; reconcile its status.",
|
|
204
|
+
request["id"],
|
|
205
|
+
) from None
|
|
206
|
+
try:
|
|
207
|
+
return json.loads(raw)
|
|
208
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
209
|
+
raise QueueCommandError(
|
|
210
|
+
"UNKNOWN_OUTCOME", "Invalid destination reply. No request was retried.", request["id"]
|
|
211
|
+
) from None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Destination:
|
|
215
|
+
"""One capability-checked machine/boot snapshot retained through an action."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, compute=None, capability="message-queue-v1"):
|
|
218
|
+
self.compute = compute
|
|
219
|
+
self.protocol = None if compute else _new_protocol()
|
|
220
|
+
self.binding = {}
|
|
221
|
+
try:
|
|
222
|
+
self.snapshot = self.call("snapshot")
|
|
223
|
+
if not isinstance(self.snapshot.get("capabilities"), list) or not isinstance(
|
|
224
|
+
self.snapshot.get("sessions"), list
|
|
225
|
+
):
|
|
226
|
+
raise QueueCommandError("INVALID_RESPONSE", "Invalid destination snapshot.")
|
|
227
|
+
if capability not in self.snapshot.get("capabilities", []):
|
|
228
|
+
raise QueueCommandError(
|
|
229
|
+
"UNSUPPORTED_OPERATION",
|
|
230
|
+
f"Destination does not support {capability}. "
|
|
231
|
+
"Update its qbraid-core before using this command.",
|
|
232
|
+
)
|
|
233
|
+
if not all(
|
|
234
|
+
isinstance(self.snapshot.get(key), str) and self.snapshot[key]
|
|
235
|
+
for key in ("machineId", "bootId")
|
|
236
|
+
):
|
|
237
|
+
raise QueueCommandError("INVALID_RESPONSE", "Destination identity is missing.")
|
|
238
|
+
self.binding = {
|
|
239
|
+
"expectedMachineId": self.snapshot["machineId"],
|
|
240
|
+
"expectedBootId": self.snapshot["bootId"],
|
|
241
|
+
}
|
|
242
|
+
except BaseException:
|
|
243
|
+
self.close()
|
|
244
|
+
raise
|
|
245
|
+
|
|
246
|
+
def close(self):
|
|
247
|
+
"""Release local display subscriptions without affecting agent processes."""
|
|
248
|
+
if self.protocol is not None:
|
|
249
|
+
self.protocol.close()
|
|
250
|
+
|
|
251
|
+
def call(self, operation, request_id=None, **fields):
|
|
252
|
+
"""Dispatch exactly once; keep the caller's request ID on any failure."""
|
|
253
|
+
rid = _uuid(request_id)
|
|
254
|
+
request = {"id": rid, "op": operation, **self.binding, **fields}
|
|
255
|
+
if len(json.dumps(request, ensure_ascii=True, allow_nan=False).encode()) + 1 > _MAX_REQUEST:
|
|
256
|
+
raise QueueCommandError("REQUEST_TOO_LARGE", "Request exceeds the 256 KiB protocol limit.", rid)
|
|
257
|
+
try:
|
|
258
|
+
response = (
|
|
259
|
+
_remote_request(self.compute, request) if self.compute else self.protocol.handle(request)
|
|
260
|
+
)
|
|
261
|
+
except QueueCommandError:
|
|
262
|
+
raise
|
|
263
|
+
except Exception:
|
|
264
|
+
raise QueueCommandError(
|
|
265
|
+
"UNKNOWN_OUTCOME", "Destination reply failed; inspect request status before retrying.", rid
|
|
266
|
+
) from None
|
|
267
|
+
if (
|
|
268
|
+
not isinstance(response, dict)
|
|
269
|
+
or response.get("id") != rid
|
|
270
|
+
or not isinstance(response.get("ok"), bool)
|
|
271
|
+
):
|
|
272
|
+
raise QueueCommandError(
|
|
273
|
+
"UNKNOWN_OUTCOME", "Unconfirmed destination response; inspect request status.", rid
|
|
274
|
+
)
|
|
275
|
+
if response["ok"] is False:
|
|
276
|
+
error = response.get("error") or {}
|
|
277
|
+
if not isinstance(error, dict) or not all(
|
|
278
|
+
isinstance(error.get(key), str) for key in ("code", "message")
|
|
279
|
+
):
|
|
280
|
+
raise QueueCommandError(
|
|
281
|
+
"UNKNOWN_OUTCOME", "Unconfirmed destination error; inspect request status.", rid
|
|
282
|
+
)
|
|
283
|
+
raise QueueCommandError(
|
|
284
|
+
error.get("code", "OPERATION_FAILED"), error.get("message", "Request failed."), rid
|
|
285
|
+
)
|
|
286
|
+
result = response.get("result")
|
|
287
|
+
if not isinstance(result, dict):
|
|
288
|
+
raise QueueCommandError(
|
|
289
|
+
"UNKNOWN_OUTCOME", "Unconfirmed destination result; inspect request status.", rid
|
|
290
|
+
)
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
def runtime(self, session_id):
|
|
294
|
+
"""Resolve only the exact session ID from the destination's displayed rows."""
|
|
295
|
+
rows = [
|
|
296
|
+
row
|
|
297
|
+
for row in self.snapshot["sessions"]
|
|
298
|
+
if isinstance(row, dict) and row.get("sessionId") == session_id
|
|
299
|
+
]
|
|
300
|
+
if len(rows) != 1:
|
|
301
|
+
raise QueueCommandError(
|
|
302
|
+
"SESSION_NOT_FOUND", "Use the full session ID listed on the selected destination."
|
|
303
|
+
)
|
|
304
|
+
row = rows[0]
|
|
305
|
+
metadata = row.get("metadata") or {}
|
|
306
|
+
if not isinstance(metadata, dict):
|
|
307
|
+
raise QueueCommandError("INVALID_RESPONSE", "Invalid destination runtime metadata.")
|
|
308
|
+
return {
|
|
309
|
+
"pid": row.get("pid"),
|
|
310
|
+
"startedAt": row.get("startedAt"),
|
|
311
|
+
"tool": row.get("tool"),
|
|
312
|
+
"terminalId": metadata.get("jupyter_terminal_id"),
|
|
313
|
+
"serverId": metadata.get("jupyter_server_id"),
|
|
314
|
+
"transcriptPath": row.get("transcriptPath"),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@contextmanager
|
|
319
|
+
def _action(json_output, compute=None, capability="message-queue-v1"):
|
|
320
|
+
destination = None
|
|
321
|
+
try:
|
|
322
|
+
destination = Destination(compute, capability)
|
|
323
|
+
yield destination
|
|
324
|
+
except QueueCommandError as error:
|
|
325
|
+
result = {"ok": False, "error": {"code": error.code, "message": str(error)}}
|
|
326
|
+
if error.request_id:
|
|
327
|
+
result["requestId"] = error.request_id
|
|
328
|
+
if json_output:
|
|
329
|
+
typer.echo(json.dumps(result, ensure_ascii=True))
|
|
330
|
+
else:
|
|
331
|
+
typer.echo(f"{_plain(error.code)}: {_plain(error)}", err=True)
|
|
332
|
+
if error.request_id:
|
|
333
|
+
typer.echo(f"Request ID: {_plain(error.request_id)}.", err=True)
|
|
334
|
+
if error.code in {"UNKNOWN_OUTCOME", "INDETERMINATE", "OPERATION_FAILED"}:
|
|
335
|
+
typer.echo("Inspect queue list and queue request-status before retrying.", err=True)
|
|
336
|
+
raise typer.Exit(1) from None
|
|
337
|
+
except typer.Exit:
|
|
338
|
+
raise
|
|
339
|
+
except Exception:
|
|
340
|
+
if json_output:
|
|
341
|
+
typer.echo(
|
|
342
|
+
json.dumps(
|
|
343
|
+
{
|
|
344
|
+
"ok": False,
|
|
345
|
+
"error": {
|
|
346
|
+
"code": "OPERATION_FAILED",
|
|
347
|
+
"message": "Local queue operation failed. Inspect state before retrying.",
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
else:
|
|
353
|
+
typer.echo("Local queue operation failed. Inspect state before retrying.", err=True)
|
|
354
|
+
raise typer.Exit(1) from None
|
|
355
|
+
finally:
|
|
356
|
+
if destination is not None:
|
|
357
|
+
destination.close()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _text(text, file):
|
|
361
|
+
if (text is None) == (file is None):
|
|
362
|
+
raise QueueCommandError("INVALID_ARGUMENT", "Choose exactly one of --text or --file.")
|
|
363
|
+
if file is not None:
|
|
364
|
+
try:
|
|
365
|
+
with Path(file).open("rb") as stream:
|
|
366
|
+
raw = stream.read(32768 * 4 + 1)
|
|
367
|
+
if len(raw) > 32768 * 4:
|
|
368
|
+
raise QueueCommandError("INVALID_ARGUMENT", "Use 1–32768 characters of plain text.")
|
|
369
|
+
text = raw.decode("utf-8")
|
|
370
|
+
except (OSError, UnicodeError):
|
|
371
|
+
raise QueueCommandError("INVALID_ARGUMENT", "Cannot read the message file as UTF-8.") from None
|
|
372
|
+
if not isinstance(text, str) or not text.strip() or len(text) > 32768:
|
|
373
|
+
raise QueueCommandError("INVALID_ARGUMENT", "Use 1–32768 characters of plain text.")
|
|
374
|
+
return text
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _emit(result, json_output, request_id=None, *, ok=True):
|
|
378
|
+
if json_output:
|
|
379
|
+
envelope = {"ok": ok, "result": result}
|
|
380
|
+
if request_id:
|
|
381
|
+
envelope["requestId"] = request_id
|
|
382
|
+
typer.echo(json.dumps(envelope, ensure_ascii=True))
|
|
383
|
+
elif "messages" in result:
|
|
384
|
+
for row in result["messages"]:
|
|
385
|
+
typer.echo(f"{_plain(row['id'])} {_plain(row['state'])} revision {_plain(row['revision'])}")
|
|
386
|
+
prose = _plain(row["text"], multiline=True).replace("\n", "\n ")
|
|
387
|
+
typer.echo(f" {prose}")
|
|
388
|
+
if row.get("reason"):
|
|
389
|
+
typer.echo(f" {_plain(row['reason'])}")
|
|
390
|
+
if not result["messages"]:
|
|
391
|
+
typer.echo("No queued messages or retained receipts.")
|
|
392
|
+
elif "state" in result and "id" in result:
|
|
393
|
+
status = f"{_plain(result['id'])} {_plain(result['state'])}"
|
|
394
|
+
if "revision" in result:
|
|
395
|
+
status += f" revision {_plain(result['revision'])}"
|
|
396
|
+
typer.echo(status)
|
|
397
|
+
if result.get("reason"):
|
|
398
|
+
typer.echo(_plain(result["reason"]))
|
|
399
|
+
else:
|
|
400
|
+
typer.echo(_plain(json.dumps(result, ensure_ascii=True)))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _mutation(destination, operation, json_output, request_id, **fields):
|
|
404
|
+
rid = _uuid(request_id)
|
|
405
|
+
# Written before crossing the transport, so even Ctrl+C leaves the ID visible.
|
|
406
|
+
typer.echo(f"Request ID: {rid}", err=True)
|
|
407
|
+
result = destination.call(operation, rid, **fields)
|
|
408
|
+
if operation == "interrupt":
|
|
409
|
+
confirmed = (
|
|
410
|
+
result.get("interruptRequested") is True and result.get("sessionId") == fields["sessionId"]
|
|
411
|
+
)
|
|
412
|
+
else:
|
|
413
|
+
expected_id = rid if operation == "queue_enqueue" else fields["messageId"]
|
|
414
|
+
confirmed = (
|
|
415
|
+
_valid_receipt(result, fields["sessionId"], destination.snapshot["machineId"])
|
|
416
|
+
and result["id"] == expected_id
|
|
417
|
+
)
|
|
418
|
+
if confirmed and operation == "queue_enqueue":
|
|
419
|
+
confirmed = (
|
|
420
|
+
result["bootId"] == destination.snapshot["bootId"]
|
|
421
|
+
and result["expectedRuntime"] == fields["expectedRuntime"]
|
|
422
|
+
and (result["revision"] != 1 or result["text"] == fields["text"].replace("\r\n", "\n"))
|
|
423
|
+
)
|
|
424
|
+
elif confirmed:
|
|
425
|
+
# Edits/resolution can address older-boot history. The displayed
|
|
426
|
+
# revision and action bind the receipt without silently refetching.
|
|
427
|
+
confirmed = (
|
|
428
|
+
result["revision"] == fields["revision"] + 1
|
|
429
|
+
and result["state"] == _ACTION_STATES.get(operation)
|
|
430
|
+
and (operation != "queue_edit" or result["text"] == fields["text"].replace("\r\n", "\n"))
|
|
431
|
+
)
|
|
432
|
+
if not confirmed:
|
|
433
|
+
raise QueueCommandError(
|
|
434
|
+
"UNKNOWN_OUTCOME", "No valid operation receipt was confirmed; inspect request status.", rid
|
|
435
|
+
)
|
|
436
|
+
_emit(result, json_output, rid)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@queue_app.command("enqueue")
|
|
440
|
+
def enqueue(
|
|
441
|
+
session_id: str = typer.Argument(..., help="Exact destination session ID."),
|
|
442
|
+
text: Optional[str] = typer.Option(None, "--text", help="Plain follow-up text."),
|
|
443
|
+
file: Optional[Path] = typer.Option(None, "--file", help="UTF-8 text file (at most 32768 characters)."),
|
|
444
|
+
compute: Optional[str] = typer.Option(None, "--compute", help="Destination SSH alias."),
|
|
445
|
+
request_id: Optional[str] = typer.Option(
|
|
446
|
+
None, "--request-id", help="Retain this UUID to reconcile a lost reply."
|
|
447
|
+
),
|
|
448
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
449
|
+
):
|
|
450
|
+
"""Queue a follow-up for this runtime.
|
|
451
|
+
|
|
452
|
+
Delivery waits for a confirmed completed turn and a running watcher.
|
|
453
|
+
"""
|
|
454
|
+
with _action(json_output, compute) as destination:
|
|
455
|
+
_mutation(
|
|
456
|
+
destination,
|
|
457
|
+
"queue_enqueue",
|
|
458
|
+
json_output,
|
|
459
|
+
request_id,
|
|
460
|
+
sessionId=session_id,
|
|
461
|
+
expectedRuntime=destination.runtime(session_id),
|
|
462
|
+
text=_text(text, file),
|
|
463
|
+
)
|
|
464
|
+
if not json_output:
|
|
465
|
+
typer.echo(
|
|
466
|
+
"Queued input needs a running queue watcher on the destination. "
|
|
467
|
+
"Sent means terminal submission, not a model response."
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@queue_app.command("list")
|
|
472
|
+
def list_messages(
|
|
473
|
+
session_id: str = typer.Argument(...),
|
|
474
|
+
compute: Optional[str] = typer.Option(None, "--compute"),
|
|
475
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
476
|
+
):
|
|
477
|
+
"""List retained messages and delivery state.
|
|
478
|
+
|
|
479
|
+
Includes uncertain outcomes and wait reasons across all receipt pages.
|
|
480
|
+
"""
|
|
481
|
+
with _action(json_output, compute) as destination:
|
|
482
|
+
messages: list[dict] = []
|
|
483
|
+
seen, seen_ids = set(), set()
|
|
484
|
+
cursor = None
|
|
485
|
+
for _ in range(257):
|
|
486
|
+
page = destination.call(
|
|
487
|
+
"queue_list", sessionId=session_id, **({"cursor": cursor} if cursor else {})
|
|
488
|
+
)
|
|
489
|
+
if not isinstance(page.get("messages"), list) or "nextCursor" not in page:
|
|
490
|
+
raise QueueCommandError("INVALID_RESPONSE", "Invalid queue listing.")
|
|
491
|
+
for row in page["messages"]:
|
|
492
|
+
if (
|
|
493
|
+
not _valid_receipt(row, session_id, destination.snapshot["machineId"])
|
|
494
|
+
or row["id"] in seen_ids
|
|
495
|
+
or len(messages) >= 256
|
|
496
|
+
):
|
|
497
|
+
raise QueueCommandError("INVALID_RESPONSE", "Invalid or repeated queue receipts.")
|
|
498
|
+
seen_ids.add(row["id"])
|
|
499
|
+
messages.append(row)
|
|
500
|
+
cursor = page["nextCursor"]
|
|
501
|
+
if cursor is None:
|
|
502
|
+
_emit({"messages": messages, "nextCursor": None}, json_output)
|
|
503
|
+
return
|
|
504
|
+
if (
|
|
505
|
+
not _canonical_uuid(cursor)
|
|
506
|
+
or cursor in seen
|
|
507
|
+
or not page["messages"]
|
|
508
|
+
or cursor != page["messages"][-1]["id"]
|
|
509
|
+
):
|
|
510
|
+
break
|
|
511
|
+
seen.add(cursor)
|
|
512
|
+
raise QueueCommandError("QUEUE_CHANGED", "Queue pagination changed; start the list again.")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@queue_app.command("edit")
|
|
516
|
+
def edit(
|
|
517
|
+
session_id: str = typer.Argument(...),
|
|
518
|
+
message_id: str = typer.Argument(...),
|
|
519
|
+
revision: int = typer.Option(..., "--revision", min=1, help="Revision shown by queue list."),
|
|
520
|
+
text: Optional[str] = typer.Option(None, "--text"),
|
|
521
|
+
file: Optional[Path] = typer.Option(None, "--file"),
|
|
522
|
+
compute: Optional[str] = typer.Option(None, "--compute"),
|
|
523
|
+
request_id: Optional[str] = typer.Option(None, "--request-id"),
|
|
524
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
525
|
+
):
|
|
526
|
+
"""Edit a queued message at its revision.
|
|
527
|
+
|
|
528
|
+
A concurrent delivery or edit refuses this operation.
|
|
529
|
+
"""
|
|
530
|
+
with _action(json_output, compute) as destination:
|
|
531
|
+
_mutation(
|
|
532
|
+
destination,
|
|
533
|
+
"queue_edit",
|
|
534
|
+
json_output,
|
|
535
|
+
request_id,
|
|
536
|
+
sessionId=session_id,
|
|
537
|
+
messageId=_uuid(message_id),
|
|
538
|
+
revision=revision,
|
|
539
|
+
text=_text(text, file),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@queue_app.command("cancel")
|
|
544
|
+
def cancel(
|
|
545
|
+
session_id: str = typer.Argument(...),
|
|
546
|
+
message_id: str = typer.Argument(...),
|
|
547
|
+
revision: int = typer.Option(..., "--revision", min=1),
|
|
548
|
+
compute: Optional[str] = typer.Option(None, "--compute"),
|
|
549
|
+
request_id: Optional[str] = typer.Option(None, "--request-id"),
|
|
550
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
551
|
+
):
|
|
552
|
+
"""Cancel a queued message at its revision.
|
|
553
|
+
|
|
554
|
+
This cannot interrupt a message already submitted.
|
|
555
|
+
"""
|
|
556
|
+
with _action(json_output, compute) as destination:
|
|
557
|
+
_mutation(
|
|
558
|
+
destination,
|
|
559
|
+
"queue_cancel",
|
|
560
|
+
json_output,
|
|
561
|
+
request_id,
|
|
562
|
+
sessionId=session_id,
|
|
563
|
+
messageId=_uuid(message_id),
|
|
564
|
+
revision=revision,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@queue_app.command("resolve")
|
|
569
|
+
def resolve(
|
|
570
|
+
session_id: str = typer.Argument(...),
|
|
571
|
+
message_id: str = typer.Argument(...),
|
|
572
|
+
revision: int = typer.Option(..., "--revision", min=1),
|
|
573
|
+
terminal_checked: bool = typer.Option(
|
|
574
|
+
False, "--terminal-checked", help="Confirm you inspected the terminal and cleared partial input."
|
|
575
|
+
),
|
|
576
|
+
compute: Optional[str] = typer.Option(None, "--compute"),
|
|
577
|
+
request_id: Optional[str] = typer.Option(None, "--request-id"),
|
|
578
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
579
|
+
):
|
|
580
|
+
"""Resolve an uncertain delivery.
|
|
581
|
+
|
|
582
|
+
Check the terminal and clear partial input first. This never resends text.
|
|
583
|
+
"""
|
|
584
|
+
with _action(json_output, compute) as destination:
|
|
585
|
+
if not terminal_checked:
|
|
586
|
+
raise QueueCommandError(
|
|
587
|
+
"INVALID_ARGUMENT",
|
|
588
|
+
"Inspect the terminal and clear partial input, then pass --terminal-checked.",
|
|
589
|
+
)
|
|
590
|
+
_mutation(
|
|
591
|
+
destination,
|
|
592
|
+
"queue_resolve",
|
|
593
|
+
json_output,
|
|
594
|
+
request_id,
|
|
595
|
+
sessionId=session_id,
|
|
596
|
+
messageId=_uuid(message_id),
|
|
597
|
+
revision=revision,
|
|
598
|
+
terminalChecked=True,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
@queue_app.command("request-status")
|
|
603
|
+
def request_status(
|
|
604
|
+
request_id: str = typer.Argument(...),
|
|
605
|
+
compute: Optional[str] = typer.Option(None, "--compute"),
|
|
606
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
607
|
+
):
|
|
608
|
+
"""Inspect a request without replaying it.
|
|
609
|
+
|
|
610
|
+
Not-found does not prove non-delivery.
|
|
611
|
+
"""
|
|
612
|
+
with _action(json_output, compute) as destination:
|
|
613
|
+
result = destination.call("request_status", requestId=_uuid(request_id))
|
|
614
|
+
_emit(result, json_output)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@queue_app.command("watch")
|
|
618
|
+
def watch(
|
|
619
|
+
once: bool = typer.Option(False, "--once", help="Run one delivery tick and exit."),
|
|
620
|
+
interval: float = typer.Option(4.0, "--interval", min=1.0, max=60.0),
|
|
621
|
+
json_output: bool = typer.Option(False, "--json", help="Emit one JSON object per tick."),
|
|
622
|
+
):
|
|
623
|
+
"""Host queued delivery on this machine.
|
|
624
|
+
|
|
625
|
+
Run on the destination; Ctrl+C pauses delivery.
|
|
626
|
+
Closing this watcher preserves queued input. A tick sends at most one message
|
|
627
|
+
per session; an uncertain write pauses that runtime until explicitly resolved.
|
|
628
|
+
"""
|
|
629
|
+
with _action(json_output) as destination:
|
|
630
|
+
try:
|
|
631
|
+
from qbraid_core.services.agents.message_queue import MessageQueue
|
|
632
|
+
from qbraid_core.services.agents.runtime_controls import SessionControlError
|
|
633
|
+
except ImportError:
|
|
634
|
+
raise QueueCommandError(
|
|
635
|
+
"UNSUPPORTED_OPERATION", "Update qbraid-core to host queued delivery."
|
|
636
|
+
) from None
|
|
637
|
+
watcher = MessageQueue(destination.protocol)
|
|
638
|
+
if not once and not json_output:
|
|
639
|
+
typer.echo(f"Watching this machine's queue every {interval:g}s. Ctrl+C pauses delivery.")
|
|
640
|
+
try:
|
|
641
|
+
while True:
|
|
642
|
+
try:
|
|
643
|
+
delivered = watcher.poll_once()
|
|
644
|
+
result = {
|
|
645
|
+
"delivered": delivered,
|
|
646
|
+
"machineId": destination.snapshot["machineId"],
|
|
647
|
+
"bootId": destination.snapshot["bootId"],
|
|
648
|
+
}
|
|
649
|
+
except SessionControlError as error:
|
|
650
|
+
if error.code != "BUSY":
|
|
651
|
+
raise QueueCommandError(error.code, str(error)) from error
|
|
652
|
+
result = {"delivered": [], "waiting": "BUSY"}
|
|
653
|
+
_emit(result, json_output)
|
|
654
|
+
if once:
|
|
655
|
+
return
|
|
656
|
+
time.sleep(interval)
|
|
657
|
+
except KeyboardInterrupt:
|
|
658
|
+
if not json_output:
|
|
659
|
+
typer.echo("Watcher stopped; queue preserved. Inspect uncertain receipts before retrying.")
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def interrupt(
|
|
663
|
+
session_id: str = typer.Argument(..., help="Exact destination session ID."),
|
|
664
|
+
preflight: bool = typer.Option(
|
|
665
|
+
False, "--preflight", help="Inspect availability and turn without sending Escape."
|
|
666
|
+
),
|
|
667
|
+
compute: Optional[str] = typer.Option(None, "--compute"),
|
|
668
|
+
request_id: Optional[str] = typer.Option(None, "--request-id"),
|
|
669
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
670
|
+
):
|
|
671
|
+
"""Interrupt one verified active turn.
|
|
672
|
+
|
|
673
|
+
Request one Escape, without ending the agent process.
|
|
674
|
+
"""
|
|
675
|
+
with _action(json_output, compute, "interrupt-v1") as destination:
|
|
676
|
+
runtime = destination.runtime(session_id)
|
|
677
|
+
offer = destination.call("interrupt_preflight", sessionId=session_id, expectedRuntime=runtime)
|
|
678
|
+
if preflight:
|
|
679
|
+
available = offer.get("canInterrupt") is True
|
|
680
|
+
_emit(
|
|
681
|
+
{**offer, "sessionId": session_id, "expectedRuntime": runtime, **destination.binding},
|
|
682
|
+
json_output,
|
|
683
|
+
ok=available,
|
|
684
|
+
)
|
|
685
|
+
if not available:
|
|
686
|
+
raise typer.Exit(1)
|
|
687
|
+
return
|
|
688
|
+
if offer.get("canInterrupt") is not True or not isinstance(offer.get("turn"), str):
|
|
689
|
+
raise QueueCommandError(
|
|
690
|
+
"CONTROL_UNAVAILABLE", offer.get("reason") or "No supported active turn is confirmed."
|
|
691
|
+
)
|
|
692
|
+
_mutation(
|
|
693
|
+
destination,
|
|
694
|
+
"interrupt",
|
|
695
|
+
json_output,
|
|
696
|
+
request_id,
|
|
697
|
+
sessionId=session_id,
|
|
698
|
+
expectedRuntime=runtime,
|
|
699
|
+
expectedTurn=offer["turn"],
|
|
700
|
+
)
|
|
701
|
+
if not json_output:
|
|
702
|
+
typer.echo("Interrupt requested. This acknowledges Escape input, not that the agent has stopped.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.13
|
|
4
4
|
Summary: Command Line Interface for interacting with all parts of the qBraid platform.
|
|
5
5
|
Author-email: qBraid Development Team <contact@qbraid.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
|
|
|
30
30
|
Requires-Dist: rich>=10.11.0
|
|
31
31
|
Requires-Dist: click<=8.5.0
|
|
32
32
|
Requires-Dist: questionary>=2.0.0
|
|
33
|
-
Requires-Dist: qbraid-core>=0.6.
|
|
33
|
+
Requires-Dist: qbraid-core>=0.6.32
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.32; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.32; extra == "envs"
|
|
38
38
|
Provides-Extra: dev
|
|
39
39
|
Requires-Dist: isort; extra == "dev"
|
|
40
40
|
Requires-Dist: black; extra == "dev"
|
|
@@ -23,6 +23,8 @@ qbraid_cli/admin/validation.py
|
|
|
23
23
|
qbraid_cli/agents/__init__.py
|
|
24
24
|
qbraid_cli/agents/app.py
|
|
25
25
|
qbraid_cli/agents/permission_response.py
|
|
26
|
+
qbraid_cli/agents/provider_children.py
|
|
27
|
+
qbraid_cli/agents/queue.py
|
|
26
28
|
qbraid_cli/agents/work.py
|
|
27
29
|
qbraid_cli/ai/__init__.py
|
|
28
30
|
qbraid_cli/ai/app.py
|
|
@@ -2,10 +2,10 @@ typer>=0.12.1
|
|
|
2
2
|
rich>=10.11.0
|
|
3
3
|
click<=8.5.0
|
|
4
4
|
questionary>=2.0.0
|
|
5
|
-
qbraid-core>=0.6.
|
|
5
|
+
qbraid-core>=0.6.32
|
|
6
6
|
|
|
7
7
|
[compute]
|
|
8
|
-
qbraid-core[compute]>=0.6.
|
|
8
|
+
qbraid-core[compute]>=0.6.32
|
|
9
9
|
|
|
10
10
|
[dev]
|
|
11
11
|
isort
|
|
@@ -15,4 +15,4 @@ pytest-cov
|
|
|
15
15
|
pytest-asyncio
|
|
16
16
|
|
|
17
17
|
[envs]
|
|
18
|
-
qbraid-core[environments]>=0.6.
|
|
18
|
+
qbraid-core[environments]>=0.6.32
|
|
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
|
|
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
|