qbraid-cli 0.16.11__tar.gz → 0.16.12__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.12}/PKG-INFO +4 -4
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/pyproject.toml +4 -4
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/app.py +3 -0
- qbraid_cli-0.16.12/qbraid_cli/agents/queue.py +702 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12/qbraid_cli.egg-info}/PKG-INFO +4 -4
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/SOURCES.txt +1 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/requires.txt +3 -3
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/LICENSE +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/README.md +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/validation.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/permission_response.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/work.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/ai/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/ai/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/ai/token.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/comms/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/comms/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/instances.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/lab/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/setup.cfg +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/tests/test_cli_e2e.py +0 -0
- {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/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.12
|
|
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.30
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.30; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.30; 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.12"
|
|
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.30",
|
|
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.30"]
|
|
47
|
+
envs = ["qbraid-core[environments]>=0.6.30"]
|
|
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=(
|
|
@@ -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.12
|
|
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.30
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.30; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.30; extra == "envs"
|
|
38
38
|
Provides-Extra: dev
|
|
39
39
|
Requires-Dist: isort; extra == "dev"
|
|
40
40
|
Requires-Dist: black; extra == "dev"
|
|
@@ -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.30
|
|
6
6
|
|
|
7
7
|
[compute]
|
|
8
|
-
qbraid-core[compute]>=0.6.
|
|
8
|
+
qbraid-core[compute]>=0.6.30
|
|
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.30
|
|
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
|