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.
Files changed (73) hide show
  1. {qbraid_cli-0.16.11/qbraid_cli.egg-info → qbraid_cli-0.16.12}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/app.py +3 -0
  4. qbraid_cli-0.16.12/qbraid_cli/agents/queue.py +702 -0
  5. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12/qbraid_cli.egg-info}/PKG-INFO +4 -4
  6. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/SOURCES.txt +1 -0
  7. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/requires.txt +3 -3
  8. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/LICENSE +0 -0
  9. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/README.md +0 -0
  10. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/__init__.py +0 -0
  11. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/_build_info.py +0 -0
  12. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/_version.py +0 -0
  13. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/account/__init__.py +0 -0
  14. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/account/app.py +0 -0
  15. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/__init__.py +0 -0
  16. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/app.py +0 -0
  17. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/headers.py +0 -0
  18. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/admin/validation.py +0 -0
  19. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/__init__.py +0 -0
  20. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/permission_response.py +0 -0
  21. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/agents/work.py +0 -0
  22. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/ai/__init__.py +0 -0
  23. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/ai/app.py +0 -0
  24. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/ai/token.py +0 -0
  25. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/comms/__init__.py +0 -0
  26. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/comms/app.py +0 -0
  27. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/__init__.py +0 -0
  28. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/app.py +0 -0
  29. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/instances.py +0 -0
  30. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/profiles.py +0 -0
  31. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/server.py +0 -0
  32. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/compute/ssh.py +0 -0
  33. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/configure/__init__.py +0 -0
  34. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/configure/actions.py +0 -0
  35. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/configure/app.py +0 -0
  36. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/devices/__init__.py +0 -0
  37. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/devices/app.py +0 -0
  38. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/devices/validation.py +0 -0
  39. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/__init__.py +0 -0
  40. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/activate.py +0 -0
  41. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/app.py +0 -0
  42. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/create.py +0 -0
  43. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/data_handling.py +0 -0
  44. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/groups.py +0 -0
  45. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/publish.py +0 -0
  46. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/registry.py +0 -0
  47. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/envs/share.py +0 -0
  48. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/exceptions.py +0 -0
  49. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/handlers.py +0 -0
  50. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/jobs/__init__.py +0 -0
  51. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/jobs/app.py +0 -0
  52. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/jobs/groups.py +0 -0
  53. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/kernels/__init__.py +0 -0
  54. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/kernels/app.py +0 -0
  55. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/lab/__init__.py +0 -0
  56. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/lab/app.py +0 -0
  57. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/main.py +0 -0
  58. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/pip/__init__.py +0 -0
  59. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/pip/app.py +0 -0
  60. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/pip/hooks.py +0 -0
  61. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/projects/__init__.py +0 -0
  62. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/projects/app.py +0 -0
  63. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/py.typed +0 -0
  64. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/skills/__init__.py +0 -0
  65. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/skills/app.py +0 -0
  66. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/troubleshoot/__init__.py +0 -0
  67. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli/troubleshoot/app.py +0 -0
  68. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  69. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/entry_points.txt +0 -0
  70. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/qbraid_cli.egg-info/top_level.txt +0 -0
  71. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/setup.cfg +0 -0
  72. {qbraid_cli-0.16.11 → qbraid_cli-0.16.12}/tests/test_cli_e2e.py +0 -0
  73. {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.11
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.29
33
+ Requires-Dist: qbraid-core>=0.6.30
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.29; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.30; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.29; extra == "envs"
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.11"
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.29",
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.29"]
47
- envs = ["qbraid-core[environments]>=0.6.29"]
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.11
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.29
33
+ Requires-Dist: qbraid-core>=0.6.30
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.29; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.30; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.29; extra == "envs"
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"
@@ -23,6 +23,7 @@ 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/queue.py
26
27
  qbraid_cli/agents/work.py
27
28
  qbraid_cli/ai/__init__.py
28
29
  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.29
5
+ qbraid-core>=0.6.30
6
6
 
7
7
  [compute]
8
- qbraid-core[compute]>=0.6.29
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.29
18
+ qbraid-core[environments]>=0.6.30
File without changes
File without changes
File without changes