hexaqueue-cli 0.3.0__tar.gz → 0.5.0__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 (31) hide show
  1. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/PKG-INFO +10 -5
  2. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/README.md +5 -0
  3. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/pyproject.toml +5 -5
  4. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/pyproject.toml.orig +5 -5
  5. hexaqueue_cli-0.5.0/src/hexaqueue_cli/adapters/local.py +320 -0
  6. hexaqueue_cli-0.5.0/src/hexaqueue_cli/adapters/presenter.py +846 -0
  7. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/commands/__init__.py +6 -0
  8. hexaqueue_cli-0.5.0/src/hexaqueue_cli/commands/collateral.py +124 -0
  9. hexaqueue_cli-0.5.0/src/hexaqueue_cli/commands/node.py +100 -0
  10. hexaqueue_cli-0.5.0/src/hexaqueue_cli/commands/run.py +180 -0
  11. hexaqueue_cli-0.5.0/src/hexaqueue_cli/commands/suite.py +93 -0
  12. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/commands/workflow.py +58 -3
  13. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/domain/__init__.py +2 -0
  14. hexaqueue_cli-0.5.0/src/hexaqueue_cli/domain/models.py +11 -0
  15. hexaqueue_cli-0.5.0/src/hexaqueue_cli/domain/session.py +108 -0
  16. hexaqueue_cli-0.5.0/src/hexaqueue_cli/main.py +642 -0
  17. hexaqueue_cli-0.5.0/src/hexaqueue_cli/ports/client.py +148 -0
  18. hexaqueue_cli-0.3.0/src/hexaqueue_cli/adapters/local.py +0 -46
  19. hexaqueue_cli-0.3.0/src/hexaqueue_cli/adapters/presenter.py +0 -371
  20. hexaqueue_cli-0.3.0/src/hexaqueue_cli/commands/run.py +0 -78
  21. hexaqueue_cli-0.3.0/src/hexaqueue_cli/domain/session.py +0 -63
  22. hexaqueue_cli-0.3.0/src/hexaqueue_cli/main.py +0 -97
  23. hexaqueue_cli-0.3.0/src/hexaqueue_cli/ports/client.py +0 -40
  24. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/__init__.py +0 -0
  25. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/adapters/__init__.py +0 -0
  26. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/domain/options.py +0 -0
  27. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/domain/parser.py +0 -0
  28. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/infra/__init__.py +0 -0
  29. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/infra/bootstrap.py +0 -0
  30. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/infra/options.py +0 -0
  31. {hexaqueue_cli-0.3.0 → hexaqueue_cli-0.5.0}/src/hexaqueue_cli/ports/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexaqueue-cli
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Hexaqueue CLI - Modern developer and operator terminal interface
5
5
  Author: Richard West
6
6
  Author-email: Richard West <dopplereffect.us@gmail.com>
@@ -9,10 +9,10 @@ Requires-Dist: hexaqueue-core
9
9
  Requires-Dist: hexaqueue-server
10
10
  Requires-Dist: hexaqueue-worker
11
11
  Requires-Dist: hexaqueue-workflow
12
- Requires-Dist: hexastack-cli>=0.6.0
13
- Requires-Dist: hexastack-core>=0.6.0
14
- Requires-Dist: hexastack-cqrs>=0.6.0
15
- Requires-Dist: hexastack-grpc>=0.6.0
12
+ Requires-Dist: hexastack-cli>=0.8.0
13
+ Requires-Dist: hexastack-core>=0.8.0
14
+ Requires-Dist: hexastack-cqrs>=0.8.0
15
+ Requires-Dist: hexastack-grpc>=0.8.0
16
16
  Requires-Dist: pyyaml>=6.0.2
17
17
  Requires-Dist: rich>=13.8.0
18
18
  Requires-Dist: typer>=0.15.0
@@ -26,8 +26,13 @@ Description-Content-Type: text/markdown
26
26
  [![PyPI: hexaqueue-cli](https://img.shields.io/pypi/v/hexaqueue-cli.svg)](https://pypi.org/project/hexaqueue-cli/)
27
27
  [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
28
28
  [![Coverage](https://codecov.io/github/TheTrueSCU/hexaqueue/graph/badge.svg?component=hexaqueue_cli)](https://codecov.io/github/TheTrueSCU/hexaqueue)
29
+ [![Part of Hexaqueue](https://img.shields.io/badge/part%20of-hexaqueue-blue.svg)](https://dopplereffect.us/hexaqueue/)
30
+ [![Built with Hexastack](https://img.shields.io/badge/built%20with-hexastack-blueviolet.svg)](https://dopplereffect.us/hexastack/)
31
+ [![Governed by Hexaqual](https://img.shields.io/badge/governed%20by-hexaqual-10b981.svg)](https://dopplereffect.us/hexaqual/)
29
32
  [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
30
33
 
34
+ > Part of the [**Hexaqueue Scheduler**](https://dopplereffect.us/hexaqueue/) · Built on [**Hexastack**](https://dopplereffect.us/hexastack/) · Governed by [**Hexaqual**](https://dopplereffect.us/hexaqual/).
35
+
31
36
  ---
32
37
 
33
38
  ## 🏗️ Architecture & Dependencies
@@ -5,8 +5,13 @@
5
5
  [![PyPI: hexaqueue-cli](https://img.shields.io/pypi/v/hexaqueue-cli.svg)](https://pypi.org/project/hexaqueue-cli/)
6
6
  [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/)
7
7
  [![Coverage](https://codecov.io/github/TheTrueSCU/hexaqueue/graph/badge.svg?component=hexaqueue_cli)](https://codecov.io/github/TheTrueSCU/hexaqueue)
8
+ [![Part of Hexaqueue](https://img.shields.io/badge/part%20of-hexaqueue-blue.svg)](https://dopplereffect.us/hexaqueue/)
9
+ [![Built with Hexastack](https://img.shields.io/badge/built%20with-hexastack-blueviolet.svg)](https://dopplereffect.us/hexastack/)
10
+ [![Governed by Hexaqual](https://img.shields.io/badge/governed%20by-hexaqual-10b981.svg)](https://dopplereffect.us/hexaqual/)
8
11
  [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
9
12
 
13
+ > Part of the [**Hexaqueue Scheduler**](https://dopplereffect.us/hexaqueue/) · Built on [**Hexastack**](https://dopplereffect.us/hexastack/) · Governed by [**Hexaqual**](https://dopplereffect.us/hexaqual/).
14
+
10
15
  ---
11
16
 
12
17
  ## 🏗️ Architecture & Dependencies
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexaqueue-cli"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Hexaqueue CLI - Modern developer and operator terminal interface"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -10,10 +10,10 @@ dependencies = [
10
10
  "hexaqueue-server",
11
11
  "hexaqueue-worker",
12
12
  "hexaqueue-workflow",
13
- "hexastack-cli>=0.6.0",
14
- "hexastack-core>=0.6.0",
15
- "hexastack-cqrs>=0.6.0",
16
- "hexastack-grpc>=0.6.0",
13
+ "hexastack-cli>=0.8.0",
14
+ "hexastack-core>=0.8.0",
15
+ "hexastack-cqrs>=0.8.0",
16
+ "hexastack-grpc>=0.8.0",
17
17
  "pyyaml>=6.0.2",
18
18
  "rich>=13.8.0",
19
19
  "typer>=0.15.0",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexaqueue-cli"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Hexaqueue CLI - Modern developer and operator terminal interface"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -13,10 +13,10 @@ dependencies = [
13
13
  "hexaqueue-server",
14
14
  "hexaqueue-worker",
15
15
  "hexaqueue-workflow",
16
- "hexastack-cli>=0.6.0",
17
- "hexastack-core>=0.6.0",
18
- "hexastack-cqrs>=0.6.0",
19
- "hexastack-grpc>=0.6.0",
16
+ "hexastack-cli>=0.8.0",
17
+ "hexastack-core>=0.8.0",
18
+ "hexastack-cqrs>=0.8.0",
19
+ "hexastack-grpc>=0.8.0",
20
20
  "pyyaml>=6.0.2",
21
21
  "rich>=13.8.0",
22
22
  "typer>=0.15.0",
@@ -0,0 +1,320 @@
1
+ """In-process local client adapter for hq CLI.
2
+
3
+ Notes/Architectural Intent:
4
+ Implements ClientPort by routing directly to in-process session controller
5
+ and log stream port without network transport overhead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import AsyncIterator
11
+ from pathlib import Path
12
+ from uuid import uuid4
13
+
14
+ from hexaqueue_cli.domain.models import ClusterStatsReport
15
+ from hexaqueue_cli.domain.session import LocalCliSession, get_default_session
16
+ from hexaqueue_cli.ports.client import ClientPort
17
+ from hexaqueue_core.domain.collateral import (
18
+ CollateralBundle,
19
+ CollateralKind,
20
+ CollateralState,
21
+ CollateralTier,
22
+ )
23
+ from hexaqueue_core.domain.exceptions import PermissionDeniedError
24
+ from hexaqueue_core.domain.explainability import (
25
+ FairShareTreeReport,
26
+ SchedulingDecisionReport,
27
+ )
28
+ from hexaqueue_core.domain.job import JobSpec
29
+ from hexaqueue_core.domain.run import RunSpec
30
+ from hexaqueue_core.domain.suite import SuiteCompiler, SuiteSpec
31
+ from hexaqueue_core.ports.logging import LogChunk
32
+ from hexaqueue_server.domain.models import RunStatusReport, RunSubmission
33
+ from hexaqueue_worker.domain.pty import PtySessionInfo, PtySessionRequest
34
+ from hexaqueue_worker.domain.telemetry import NodeTelemetryPulse
35
+
36
+
37
+ def _extract_job_owner(job: JobSpec) -> str:
38
+ """Extract owner tag or env from job specification."""
39
+ for tag in job.tags:
40
+ if tag.startswith("owner:"):
41
+ return tag.split(":", 1)[1]
42
+ return job.env.get("HEXAQUEUE_OWNER", "default")
43
+
44
+
45
+ def _check_job_mutation_permission(
46
+ job: JobSpec, user_id: str, elevate: bool, action_name: str
47
+ ) -> None:
48
+ """Verify that caller has permission to mutate the job."""
49
+ owner = _extract_job_owner(job)
50
+ if owner != user_id and not elevate:
51
+ msg = (
52
+ f"Permission denied: You are not the owner of job '{job.id}' (owned by '{owner}'). "
53
+ f"To {action_name} this job, explicit administrative elevation (--admin / elevate=true) is required."
54
+ )
55
+ raise PermissionDeniedError(msg)
56
+
57
+
58
+ class LocalClientAdapter(ClientPort):
59
+ """In-process local client adapter."""
60
+
61
+ def __init__(self, session: LocalCliSession | None = None) -> None:
62
+ self._session = session or get_default_session()
63
+
64
+ async def submit_run(self, submission: RunSubmission) -> RunStatusReport:
65
+ """Submit a pipeline run to local in-process controller."""
66
+ return await self._session.controller.submit_run(submission)
67
+
68
+ async def submit_suite(
69
+ self,
70
+ suite: SuiteSpec,
71
+ user_id: str = "default",
72
+ elevate: bool = False,
73
+ ) -> RunStatusReport:
74
+ """Submit a hierarchical suite pipeline run to local controller."""
75
+ compiler = SuiteCompiler()
76
+ result = compiler.compile(suite, run_id=suite.id)
77
+ run_spec = RunSpec(
78
+ id=suite.id,
79
+ name=suite.name or suite.id,
80
+ tags=[f"owner:{user_id}"],
81
+ )
82
+ dependencies = {
83
+ child_id: [dep.parent_job_id for dep in deps]
84
+ for child_id, deps in result.dependencies.items()
85
+ }
86
+ submission = RunSubmission(
87
+ run_spec=run_spec,
88
+ jobs=result.jobs,
89
+ dependencies=dependencies,
90
+ )
91
+ return await self._session.controller.submit_run(submission)
92
+
93
+ async def get_run_status(self, run_id: str) -> RunStatusReport:
94
+ """Get run status report from local in-process controller."""
95
+ return await self._session.controller.get_run_status(run_id)
96
+
97
+ async def get_job(self, job_id: str) -> JobSpec:
98
+ """Get job metadata from local in-process controller."""
99
+ return await self._session.controller.get_job(job_id)
100
+
101
+ async def list_jobs(self) -> list[JobSpec]:
102
+ """List all registered jobs across runs."""
103
+ return await self._session.controller.list_jobs()
104
+
105
+ async def cancel_run(
106
+ self, run_id: str, user_id: str = "default", elevate: bool = False
107
+ ) -> RunStatusReport:
108
+ """Cancel run in local in-process controller."""
109
+ if not elevate:
110
+ jobs = await self._session.controller.list_jobs()
111
+ run_jobs = [j for j in jobs if j.run_id == run_id]
112
+ for j in run_jobs:
113
+ _check_job_mutation_permission(j, user_id, elevate, "cancel run")
114
+ return await self._session.controller.cancel_run(run_id)
115
+
116
+ async def cancel_job(
117
+ self, job_id: str, user_id: str = "default", elevate: bool = False
118
+ ) -> JobSpec:
119
+ """Cancel an individual job."""
120
+ job = await self._session.controller.get_job(job_id)
121
+ _check_job_mutation_permission(job, user_id, elevate, "cancel")
122
+ return await self._session.controller.cancel_job(job_id)
123
+
124
+ async def hold_job(
125
+ self, job_id: str, user_id: str = "default", elevate: bool = False
126
+ ) -> JobSpec:
127
+ """Place an administrative hold on a job."""
128
+ job = await self._session.controller.get_job(job_id)
129
+ _check_job_mutation_permission(job, user_id, elevate, "hold")
130
+ return await self._session.controller.hold_job(job_id)
131
+
132
+ async def release_job(
133
+ self, job_id: str, user_id: str = "default", elevate: bool = False
134
+ ) -> JobSpec:
135
+ """Release an administrative hold on a job."""
136
+ job = await self._session.controller.get_job(job_id)
137
+ _check_job_mutation_permission(job, user_id, elevate, "release")
138
+ return await self._session.controller.release_job(job_id)
139
+
140
+ async def get_logs(self, job_id: str) -> list[LogChunk]:
141
+ """Fetch captured logs from in-memory stream adapter."""
142
+ return [
143
+ c async for c in self._session.log_stream.stream_logs(job_id, follow=False)
144
+ ]
145
+
146
+ async def stream_logs(
147
+ self, job_id: str, follow: bool = False, tail: int | None = None
148
+ ) -> AsyncIterator[LogChunk]:
149
+ """Stream logs for a job with optional real-time tail follow."""
150
+ async for chunk in self._session.log_stream.stream_logs(
151
+ job_id, follow=follow, tail=tail
152
+ ):
153
+ yield chunk
154
+
155
+ async def get_cluster_stats(self) -> ClusterStatsReport:
156
+ """Retrieve high-level cluster state and backlog statistics."""
157
+ from hexaqueue_core.domain.lifecycle import JobState
158
+
159
+ all_jobs = await self._session.controller.list_jobs()
160
+ running_cnt = sum(1 for j in all_jobs if j.state == JobState.RUNNING)
161
+ pending_cnt = sum(1 for j in all_jobs if j.state == JobState.PENDING)
162
+ blocked_cnt = sum(1 for j in all_jobs if j.state == JobState.BLOCKED)
163
+ completed_cnt = sum(
164
+ 1
165
+ for j in all_jobs
166
+ if j.state == JobState.DONE and str(j.outcome) == "COMPLETED"
167
+ )
168
+ failed_cnt = sum(
169
+ 1
170
+ for j in all_jobs
171
+ if j.state == JobState.DONE and str(j.outcome) != "COMPLETED"
172
+ )
173
+
174
+ total_runs = len(getattr(self._session.controller, "_runs", {}))
175
+ active_workers = 1 if getattr(self._session.worker, "_running", False) else 0
176
+
177
+ return ClusterStatsReport(
178
+ total_runs=total_runs,
179
+ total_jobs=len(all_jobs),
180
+ running_jobs=running_cnt,
181
+ pending_jobs=pending_cnt,
182
+ blocked_jobs=blocked_cnt,
183
+ completed_jobs=completed_cnt,
184
+ failed_jobs=failed_cnt,
185
+ active_workers=active_workers,
186
+ )
187
+
188
+ async def get_nodes(self) -> list[NodeTelemetryPulse]:
189
+ """Retrieve telemetry pulses for registered compute worker nodes."""
190
+ worker = self._session.worker
191
+ active_jobs = len(getattr(worker, "_active_jobs", set()))
192
+ worker_id = getattr(worker._config, "worker_id", "local-worker")
193
+ pulse = self._session.telemetry.collect_pulse(
194
+ worker_id=worker_id, active_jobs=active_jobs
195
+ )
196
+ return [pulse]
197
+
198
+ async def create_pty_session(
199
+ self, request: PtySessionRequest, job_owner: str = "default"
200
+ ) -> PtySessionInfo:
201
+ """Create an interactive terminal PTY session inside a running job."""
202
+ return await self._session.pty.create_session(request, job_owner=job_owner)
203
+
204
+ async def explain_job(
205
+ self,
206
+ job_id: str,
207
+ requesting_user: str = "default",
208
+ is_admin: bool = False,
209
+ ) -> SchedulingDecisionReport:
210
+ """Generate explainability report for a job in the local session."""
211
+ from datetime import UTC, datetime
212
+
213
+ from hexaqueue_core.domain.explainability import SchedulerExplainabilityEngine
214
+ from hexaqueue_core.domain.redaction import MultiTenantRedactionFilter
215
+ from hexaqueue_core.domain.scheduling import ResourceSlotPool
216
+
217
+ engine = SchedulerExplainabilityEngine()
218
+ redaction = MultiTenantRedactionFilter()
219
+ all_jobs = await self._session.controller.list_jobs()
220
+ pool = ResourceSlotPool(total_slots=4)
221
+ report = engine.explain_job(
222
+ job_id=job_id,
223
+ all_jobs=all_jobs,
224
+ pool=pool,
225
+ current_timestamp=datetime.now(UTC).timestamp(),
226
+ )
227
+ return redaction.redact_decision_report(
228
+ report, requesting_user=requesting_user, is_admin=is_admin
229
+ )
230
+
231
+ async def get_fairshare_tree(
232
+ self,
233
+ requesting_user: str = "default",
234
+ is_admin: bool = False,
235
+ ) -> FairShareTreeReport:
236
+ """Generate fair-share tree report for the local session."""
237
+ from datetime import UTC, datetime
238
+
239
+ from hexaqueue_core.domain.explainability import (
240
+ SchedulerExplainabilityEngine,
241
+ )
242
+ from hexaqueue_core.domain.redaction import MultiTenantRedactionFilter
243
+
244
+ engine = SchedulerExplainabilityEngine()
245
+ redaction = MultiTenantRedactionFilter()
246
+ report = engine.explain_fairshare(datetime.now(UTC).timestamp())
247
+ return redaction.redact_fairshare_tree(
248
+ report, requesting_user=requesting_user, is_admin=is_admin
249
+ )
250
+
251
+ async def register_collateral(
252
+ self,
253
+ name: str,
254
+ size_bytes: int,
255
+ checksum_sha256: str,
256
+ tier: CollateralTier = CollateralTier.TEMPORARY,
257
+ kind: CollateralKind = CollateralKind.BUNDLE,
258
+ target_path: str = "",
259
+ user_id: str = "default",
260
+ elevate: bool = False,
261
+ ) -> CollateralBundle:
262
+ """Register and stage a collateral bundle."""
263
+ checksum = (
264
+ checksum_sha256 if len(checksum_sha256) == 64 else checksum_sha256.zfill(64)
265
+ )
266
+ staging_uri = f"memory://staging/collateral/{name}"
267
+ if target_path and Path(target_path).is_file():
268
+ if ".." in name or "/" in name or "\\" in name or Path(name).is_absolute():
269
+ raise ValueError(
270
+ f"Collateral name '{name}' contains illegal path components."
271
+ )
272
+ content = Path(target_path).read_bytes()
273
+ alloc = await self._session.storage.allocate_scratch(
274
+ job_id="collateral", size_mb=max(1, len(content) // (1024 * 1024))
275
+ )
276
+ mount_path = Path(alloc.mount_path).resolve()
277
+ dest = (mount_path / name).resolve()
278
+ if not dest.is_relative_to(mount_path):
279
+ raise ValueError("Path traversal detected in collateral destination.")
280
+ dest.write_bytes(content)
281
+ staging_uri = f"file://{dest}"
282
+ return CollateralBundle(
283
+ id=f"col-{uuid4().hex[:8]}",
284
+ job_id="global",
285
+ filename=name,
286
+ size_bytes=size_bytes,
287
+ sha256_checksum=checksum,
288
+ tier=tier,
289
+ kind=kind,
290
+ state=CollateralState.REGISTERED,
291
+ staging_uri=staging_uri,
292
+ )
293
+
294
+ async def create_bastion_session(
295
+ self,
296
+ node_id: str,
297
+ session_id: str = "",
298
+ user_id: str = "default",
299
+ elevate: bool = False,
300
+ ) -> PtySessionInfo:
301
+ """Create administrative bastion session (requires elevation)."""
302
+ if not elevate:
303
+ msg = (
304
+ f"Permission denied: Bastion shell access on node '{node_id}' "
305
+ "requires explicit administrative elevation (--admin)."
306
+ )
307
+ raise PermissionDeniedError(msg)
308
+ req = PtySessionRequest(
309
+ session_id=session_id or f"bastion-{uuid4().hex[:8]}",
310
+ job_id=f"bastion-{node_id}",
311
+ user_id=user_id,
312
+ roles=["admin", "operator"],
313
+ command=["/bin/sh"],
314
+ )
315
+ return await self._session.pty.create_session(req, job_owner=user_id)
316
+
317
+
318
+ __all__ = [
319
+ "LocalClientAdapter",
320
+ ]