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