hexaqueue-cli 0.1.0__tar.gz → 0.3.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 (23) hide show
  1. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/PKG-INFO +6 -5
  2. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/pyproject.toml +9 -5
  3. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/pyproject.toml.orig +7 -5
  4. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/adapters/__init__.py +2 -2
  5. hexaqueue_cli-0.3.0/src/hexaqueue_cli/adapters/presenter.py +371 -0
  6. hexaqueue_cli-0.3.0/src/hexaqueue_cli/commands/__init__.py +13 -0
  7. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/commands/run.py +22 -23
  8. hexaqueue_cli-0.3.0/src/hexaqueue_cli/commands/workflow.py +298 -0
  9. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/__init__.py +2 -0
  10. hexaqueue_cli-0.3.0/src/hexaqueue_cli/domain/options.py +25 -0
  11. hexaqueue_cli-0.3.0/src/hexaqueue_cli/infra/__init__.py +8 -0
  12. hexaqueue_cli-0.3.0/src/hexaqueue_cli/infra/options.py +73 -0
  13. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/main.py +9 -40
  14. hexaqueue_cli-0.1.0/src/hexaqueue_cli/commands/__init__.py +0 -7
  15. hexaqueue_cli-0.1.0/src/hexaqueue_cli/infra/__init__.py +0 -7
  16. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/README.md +0 -0
  17. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/__init__.py +0 -0
  18. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/adapters/local.py +0 -0
  19. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/parser.py +0 -0
  20. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/session.py +0 -0
  21. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/infra/bootstrap.py +0 -0
  22. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/ports/__init__.py +0 -0
  23. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/ports/client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hexaqueue-cli
3
- Version: 0.1.0
3
+ Version: 0.3.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>
@@ -8,10 +8,11 @@ License-Expression: Apache-2.0
8
8
  Requires-Dist: hexaqueue-core
9
9
  Requires-Dist: hexaqueue-server
10
10
  Requires-Dist: hexaqueue-worker
11
- Requires-Dist: hexastack-cli>=0.3.0
12
- Requires-Dist: hexastack-core>=0.3.0
13
- Requires-Dist: hexastack-cqrs>=0.3.0
14
- Requires-Dist: hexastack-grpc>=0.3.0
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
15
16
  Requires-Dist: pyyaml>=6.0.2
16
17
  Requires-Dist: rich>=13.8.0
17
18
  Requires-Dist: typer>=0.15.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexaqueue-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Hexaqueue CLI - Modern developer and operator terminal interface"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -9,10 +9,11 @@ dependencies = [
9
9
  "hexaqueue-core",
10
10
  "hexaqueue-server",
11
11
  "hexaqueue-worker",
12
- "hexastack-cli>=0.3.0",
13
- "hexastack-core>=0.3.0",
14
- "hexastack-cqrs>=0.3.0",
15
- "hexastack-grpc>=0.3.0",
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",
16
17
  "pyyaml>=6.0.2",
17
18
  "rich>=13.8.0",
18
19
  "typer>=0.15.0",
@@ -37,3 +38,6 @@ workspace = true
37
38
 
38
39
  [tool.uv.sources.hexaqueue-worker]
39
40
  workspace = true
41
+
42
+ [tool.uv.sources.hexaqueue-workflow]
43
+ workspace = true
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hexaqueue-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Hexaqueue CLI - Modern developer and operator terminal interface"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -12,10 +12,11 @@ dependencies = [
12
12
  "hexaqueue-core",
13
13
  "hexaqueue-server",
14
14
  "hexaqueue-worker",
15
- "hexastack-cli>=0.3.0",
16
- "hexastack-core>=0.3.0",
17
- "hexastack-cqrs>=0.3.0",
18
- "hexastack-grpc>=0.3.0",
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",
19
20
  "pyyaml>=6.0.2",
20
21
  "rich>=13.8.0",
21
22
  "typer>=0.15.0",
@@ -32,3 +33,4 @@ build-backend = "uv_build"
32
33
  hexaqueue-core = { workspace = true }
33
34
  hexaqueue-server = { workspace = true }
34
35
  hexaqueue-worker = { workspace = true }
36
+ hexaqueue-workflow = { workspace = true }
@@ -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,371 @@
1
+ """Terminal and pipeline output presenters for Hexaqueue CLI commands.
2
+
3
+ Notes/Architectural Intent:
4
+ Provides formatted output rendering across multiple formats (table, json, markdown, plain, rich).
5
+ Ensures seamless piping into downstream Unix utilities when non-interactive, while
6
+ delivering rich visual dashboards for human terminal operators.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from hexaqueue_cli.domain.options import OutputFormat
22
+ from hexaqueue_core.domain.job import JobSpec
23
+ from hexaqueue_server.domain.models import RunStatusReport
24
+
25
+ __all__ = [
26
+ "CliPresenter",
27
+ ]
28
+
29
+
30
+ class CliPresenter:
31
+ """Presenter formatting CLI models and reports across supported output formats.
32
+
33
+ Notes/Architectural Intent:
34
+ Implements rendering for table, json, markdown, plain, and rich.
35
+ Allows commands to dispatch results cleanly without mixing presentation logic
36
+ with orchestration.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ console: Console | None = None,
42
+ stderr_console: Console | None = None,
43
+ ) -> None:
44
+ """Initialize CliPresenter with optional consoles.
45
+
46
+ Args:
47
+ console: Optional Rich console for stdout.
48
+ stderr_console: Optional Rich console for stderr.
49
+ """
50
+ no_color = bool(os.environ.get("NO_COLOR"))
51
+ self._console = console or Console(no_color=no_color, highlight=not no_color)
52
+ self._stderr = stderr_console or Console(
53
+ stderr=True, no_color=no_color, highlight=not no_color
54
+ )
55
+
56
+ def render_run_status(
57
+ self,
58
+ report: RunStatusReport,
59
+ format_type: str | OutputFormat = OutputFormat.TABLE,
60
+ ) -> None:
61
+ """Render a pipeline run status report in the chosen format.
62
+
63
+ Args:
64
+ report: RunStatusReport domain model.
65
+ format_type: Chosen output format string or OutputFormat enum.
66
+ """
67
+ fmt = (
68
+ format_type.value
69
+ if isinstance(format_type, OutputFormat)
70
+ else str(format_type).lower().strip()
71
+ )
72
+
73
+ data = {
74
+ "run_id": report.run_id,
75
+ "state": (
76
+ report.state.value
77
+ if hasattr(report.state, "value")
78
+ else str(report.state)
79
+ ),
80
+ "outcome": (
81
+ report.outcome.value
82
+ if hasattr(report.outcome, "value")
83
+ else (str(report.outcome) if report.outcome else None)
84
+ ),
85
+ "total_jobs": report.total_jobs,
86
+ "completed_jobs": report.completed_jobs,
87
+ "failed_jobs": report.failed_jobs,
88
+ "pending_jobs": report.pending_jobs,
89
+ }
90
+
91
+ if fmt == OutputFormat.JSON.value:
92
+ sys.stdout.write(json.dumps(data, indent=2) + "\n")
93
+ sys.stdout.flush()
94
+ elif fmt == OutputFormat.MARKDOWN.value:
95
+ lines = [
96
+ f"### Run Status: {report.run_id}",
97
+ "",
98
+ "| Field | Value |",
99
+ "|---|---|",
100
+ f"| Run ID | `{report.run_id}` |",
101
+ f"| State | **{data['state']}** |",
102
+ f"| Outcome | {data['outcome'] or '-'} |",
103
+ f"| Total Jobs | {report.total_jobs} |",
104
+ f"| Completed | {report.completed_jobs} |",
105
+ f"| Failed | {report.failed_jobs} |",
106
+ f"| Pending | {report.pending_jobs} |",
107
+ ]
108
+ sys.stdout.write("\n".join(lines) + "\n")
109
+ sys.stdout.flush()
110
+ elif fmt == OutputFormat.PLAIN.value:
111
+ for k, v in data.items():
112
+ sys.stdout.write(f"{k}\t{v}\n")
113
+ sys.stdout.flush()
114
+ else:
115
+ # table or rich
116
+ table = Table(title=f"Run Status: {report.run_id}")
117
+ table.add_column("Run ID", style="cyan")
118
+ table.add_column("State", style="magenta")
119
+ table.add_column(
120
+ "Outcome",
121
+ style="green"
122
+ if str(report.outcome) in ("COMPLETED", "SUCCEEDED")
123
+ else "yellow",
124
+ )
125
+ table.add_column("Total", justify="center")
126
+ table.add_column("Completed", justify="center", style="green")
127
+ table.add_column("Failed", justify="center", style="red")
128
+ table.add_column("Pending", justify="center", style="yellow")
129
+ table.add_row(
130
+ report.run_id,
131
+ data["state"],
132
+ str(report.outcome) if report.outcome else "-",
133
+ str(report.total_jobs),
134
+ str(report.completed_jobs),
135
+ str(report.failed_jobs),
136
+ str(report.pending_jobs),
137
+ )
138
+ self._console.print(table)
139
+
140
+ def render_job(
141
+ self,
142
+ job: JobSpec,
143
+ format_type: str | OutputFormat = OutputFormat.TABLE,
144
+ ) -> None:
145
+ """Render a single JobSpec in the chosen format.
146
+
147
+ Args:
148
+ job: JobSpec domain model.
149
+ format_type: Chosen output format string or OutputFormat enum.
150
+ """
151
+ fmt = (
152
+ format_type.value
153
+ if isinstance(format_type, OutputFormat)
154
+ else str(format_type).lower().strip()
155
+ )
156
+
157
+ state_val = job.state.value if hasattr(job.state, "value") else str(job.state)
158
+ outcome_val = (
159
+ job.outcome.value
160
+ if hasattr(job.outcome, "value")
161
+ else (str(job.outcome) if job.outcome else None)
162
+ )
163
+
164
+ data = {
165
+ "id": job.id,
166
+ "run_id": job.run_id,
167
+ "name": job.name,
168
+ "state": state_val,
169
+ "outcome": outcome_val,
170
+ "command": job.command,
171
+ }
172
+
173
+ if fmt == OutputFormat.JSON.value:
174
+ sys.stdout.write(json.dumps(data, indent=2) + "\n")
175
+ sys.stdout.flush()
176
+ elif fmt == OutputFormat.MARKDOWN.value:
177
+ lines = [
178
+ f"### Job Status: {job.id}",
179
+ "",
180
+ "| Field | Value |",
181
+ "|---|---|",
182
+ f"| Job ID | `{job.id}` |",
183
+ f"| Run ID | `{job.run_id}` |",
184
+ f"| Name | {job.name} |",
185
+ f"| State | **{state_val}** |",
186
+ f"| Outcome | {outcome_val or '-'} |",
187
+ f"| Command | `{job.command}` |",
188
+ ]
189
+ sys.stdout.write("\n".join(lines) + "\n")
190
+ sys.stdout.flush()
191
+ elif fmt == OutputFormat.PLAIN.value:
192
+ for k, v in data.items():
193
+ sys.stdout.write(f"{k}\t{v}\n")
194
+ sys.stdout.flush()
195
+ else:
196
+ table = Table(title=f"Job Status: {job.id}")
197
+ table.add_column("Job ID", style="cyan")
198
+ table.add_column("Run ID", style="blue")
199
+ table.add_column("Name")
200
+ table.add_column("State", style="magenta")
201
+ table.add_column("Outcome")
202
+ table.add_row(
203
+ job.id,
204
+ job.run_id,
205
+ job.name,
206
+ state_val,
207
+ str(job.outcome) if job.outcome else "-",
208
+ )
209
+ self._console.print(table)
210
+
211
+ def _serialize_checkpoints(self, checkpoints: list[Any]) -> list[dict[str, Any]]:
212
+ """Extract serialized dictionary representations of step checkpoints."""
213
+ chk_list: list[dict[str, Any]] = []
214
+ for chk in checkpoints:
215
+ chk_status = (
216
+ chk.status.value if hasattr(chk.status, "value") else str(chk.status)
217
+ )
218
+ payload = (
219
+ chk.output_payload
220
+ if not isinstance(chk.output_payload, BaseModel)
221
+ else chk.output_payload.model_dump()
222
+ )
223
+ chk_list.append(
224
+ {
225
+ "stage_name": chk.stage_name,
226
+ "step_name": chk.step_name,
227
+ "status": chk_status,
228
+ "attempt_number": chk.attempt_number,
229
+ "duration_seconds": round(chk.duration_seconds, 3),
230
+ "output_payload": payload,
231
+ }
232
+ )
233
+ return chk_list
234
+
235
+ def _render_workflow_json(self, data: dict[str, Any]) -> None:
236
+ """Output workflow status report as indented JSON."""
237
+ sys.stdout.write(json.dumps(data, indent=2, default=str) + "\n")
238
+ sys.stdout.flush()
239
+
240
+ def _render_workflow_markdown(self, data: dict[str, Any]) -> None:
241
+ """Output workflow status report as Markdown."""
242
+ lines = [
243
+ f"### Workflow Run: {data['workflow_name']} (`{data['run_id']}`)",
244
+ "",
245
+ f"- **Status**: `{data['status']}`",
246
+ ]
247
+ if data.get("current_stage"):
248
+ lines.append(f"- **Current Stage**: {data['current_stage']}")
249
+ if data.get("error_summary"):
250
+ lines.append(f"- **Error**: {data['error_summary']}")
251
+ lines.extend(
252
+ [
253
+ "",
254
+ "| Stage | Step | Status | Attempt | Duration (s) |",
255
+ "|---|---|---|---|---|",
256
+ ]
257
+ )
258
+ for c in data.get("checkpoints", []):
259
+ lines.append(
260
+ f"| {c['stage_name']} | {c['step_name']} | {c['status']} | {c['attempt_number']} | {c['duration_seconds']} |"
261
+ )
262
+ sys.stdout.write("\n".join(lines) + "\n")
263
+ sys.stdout.flush()
264
+
265
+ def _render_workflow_plain(self, data: dict[str, Any]) -> None:
266
+ """Output workflow status report as plain tab-delimited text."""
267
+ sys.stdout.write(f"run_id\t{data['run_id']}\n")
268
+ sys.stdout.write(f"workflow\t{data['workflow_name']}\n")
269
+ sys.stdout.write(f"status\t{data['status']}\n")
270
+ for c in data.get("checkpoints", []):
271
+ sys.stdout.write(
272
+ f"{c['stage_name']}\t{c['step_name']}\t{c['status']}\t{c['duration_seconds']}\n"
273
+ )
274
+ sys.stdout.flush()
275
+
276
+ def _render_workflow_table(self, data: dict[str, Any]) -> None:
277
+ """Output workflow status report as interactive Rich table and panel."""
278
+ header_table = Table.grid(padding=(0, 2))
279
+ header_table.add_column(style="bold")
280
+ header_table.add_column()
281
+ header_table.add_row("Run ID:", str(data["run_id"]))
282
+ header_table.add_row("Workflow:", str(data["workflow_name"]))
283
+ header_table.add_row("Status:", f"[bold cyan]{data['status']}[/]")
284
+ if data.get("current_stage"):
285
+ header_table.add_row("Current Stage:", str(data["current_stage"]))
286
+ if data.get("error_summary"):
287
+ header_table.add_row("Error:", f"[bold red]{data['error_summary']}[/]")
288
+
289
+ self._console.print(
290
+ Panel(
291
+ header_table,
292
+ title=f"Workflow Run: {data['workflow_name']}",
293
+ expand=False,
294
+ )
295
+ )
296
+
297
+ chk_list = data.get("checkpoints", [])
298
+ if not chk_list:
299
+ self._console.print("[dim]No step checkpoints recorded yet.[/]")
300
+ return
301
+
302
+ table = Table(title="Step Checkpoints & Staged Artifacts", expand=True)
303
+ table.add_column("Stage", style="cyan")
304
+ table.add_column("Step", style="bold")
305
+ table.add_column("Status", justify="center")
306
+ table.add_column("Attempt", justify="right")
307
+ table.add_column("Duration (s)", justify="right")
308
+ table.add_column("Artifact / Output", style="dim")
309
+
310
+ for chk in chk_list:
311
+ chk_color = (
312
+ "[green]COMPLETED[/]"
313
+ if chk["status"] == "COMPLETED"
314
+ else (
315
+ "[red]FAILED[/]"
316
+ if chk["status"] == "FAILED"
317
+ else str(chk["status"])
318
+ )
319
+ )
320
+ payload_repr = str(chk["output_payload"])
321
+ if len(payload_repr) > 30:
322
+ payload_repr = payload_repr[:30] + "..."
323
+
324
+ table.add_row(
325
+ chk["stage_name"],
326
+ chk["step_name"],
327
+ chk_color,
328
+ str(chk["attempt_number"]),
329
+ f"{chk['duration_seconds']:.3f}",
330
+ payload_repr,
331
+ )
332
+ self._console.print(table)
333
+
334
+ def render_workflow_state(
335
+ self,
336
+ state: Any,
337
+ checkpoints: list[Any],
338
+ format_type: str | OutputFormat = OutputFormat.TABLE,
339
+ ) -> None:
340
+ """Render workflow execution state and checkpoints in the chosen format.
341
+
342
+ Args:
343
+ state: WorkflowExecutionState instance.
344
+ checkpoints: List of CheckpointRecord instances.
345
+ format_type: Chosen output format string or OutputFormat enum.
346
+ """
347
+ fmt = (
348
+ format_type.value
349
+ if isinstance(format_type, OutputFormat)
350
+ else str(format_type).lower().strip()
351
+ )
352
+ status_val = (
353
+ state.status.value if hasattr(state.status, "value") else str(state.status)
354
+ )
355
+ data = {
356
+ "run_id": state.run_id,
357
+ "workflow_name": state.workflow_name,
358
+ "status": status_val,
359
+ "current_stage": getattr(state, "current_stage", None),
360
+ "error_summary": getattr(state, "error_summary", None),
361
+ "checkpoints": self._serialize_checkpoints(checkpoints),
362
+ }
363
+
364
+ if fmt == OutputFormat.JSON.value:
365
+ self._render_workflow_json(data)
366
+ elif fmt == OutputFormat.MARKDOWN.value:
367
+ self._render_workflow_markdown(data)
368
+ elif fmt == OutputFormat.PLAIN.value:
369
+ self._render_workflow_plain(data)
370
+ else:
371
+ self._render_workflow_table(data)
@@ -0,0 +1,13 @@
1
+ """Commands package for hexaqueue-cli.
2
+
3
+ Notes/Architectural Intent:
4
+ Exposes Typer subcommand modules for batch run pipelines and distributed workflow execution.
5
+ """
6
+
7
+ from hexaqueue_cli.commands.run import app as run_app
8
+ from hexaqueue_cli.commands.workflow import app as workflow_app
9
+
10
+ __all__ = [
11
+ "run_app",
12
+ "workflow_app",
13
+ ]
@@ -10,11 +10,12 @@ from pathlib import Path
10
10
 
11
11
  import typer
12
12
  from rich.console import Console
13
- from rich.table import Table
14
13
 
15
14
  from hexaqueue_cli.adapters.local import LocalClientAdapter
15
+ from hexaqueue_cli.adapters.presenter import CliPresenter
16
16
  from hexaqueue_cli.domain.parser import parse_run_spec_from_file
17
17
  from hexaqueue_cli.domain.session import get_default_session
18
+ from hexaqueue_cli.infra.options import format_option, resolve_format
18
19
  from hexaqueue_core.domain.lifecycle import RunState
19
20
 
20
21
  app = typer.Typer(help="Pipeline run management commands.")
@@ -27,6 +28,7 @@ def submit_cmd(
27
28
  watch: bool = typer.Option(
28
29
  False, "--watch", "-w", help="Watch run execution until completion"
29
30
  ),
31
+ format_type: str = format_option(),
30
32
  ) -> None:
31
33
  """Submit a DAG pipeline definition file."""
32
34
  try:
@@ -35,41 +37,38 @@ def submit_cmd(
35
37
  console.print(f"[bold red]Error parsing pipeline spec:[/] {e}")
36
38
  raise typer.Exit(code=1) from e
37
39
 
40
+ resolved_fmt = resolve_format(format_type)
41
+ presenter = CliPresenter(console=console)
42
+
38
43
  async def _run() -> None:
39
44
  session = get_default_session()
40
45
  await session.start()
41
46
  client = LocalClientAdapter(session=session)
42
47
 
43
48
  report = await client.submit_run(submission)
44
- console.print(
45
- f"[bold green]✓[/] Run '[bold cyan]{report.run_id}[/]' submitted ({report.total_jobs} jobs)"
46
- )
49
+ if resolved_fmt in ("table", "rich"):
50
+ console.print(
51
+ f"[bold green]✓[/] Run '[bold cyan]{report.run_id}[/]' submitted ({report.total_jobs} jobs)"
52
+ )
53
+ else:
54
+ presenter.render_run_status(report, resolved_fmt)
55
+
56
+ if not watch:
57
+ return
47
58
 
48
- if watch:
49
- with console.status(f"[bold blue]Executing run {report.run_id}...[/]"):
50
- while report.state not in (RunState.DONE, RunState.BLOCKED):
51
- await asyncio.sleep(0.1)
52
- report = await client.get_run_status(report.run_id)
59
+ with console.status(f"[bold blue]Executing run {report.run_id}...[/]"):
60
+ while report.state not in (RunState.DONE, RunState.BLOCKED):
61
+ await asyncio.sleep(0.1)
62
+ report = await client.get_run_status(report.run_id)
53
63
 
64
+ if resolved_fmt in ("table", "rich"):
54
65
  outcome_str = (
55
66
  f"[bold green]{report.outcome}[/]"
56
- if report.outcome == "COMPLETED" or report.outcome == "SUCCEEDED"
67
+ if str(report.outcome) in ("COMPLETED", "SUCCEEDED")
57
68
  else f"[bold red]{report.outcome}[/]"
58
69
  )
59
70
  console.print(f"Run completed with status: {outcome_str}")
60
-
61
- table = Table(title=f"Run Summary: {report.run_id}")
62
- table.add_column("Total", justify="center")
63
- table.add_column("Completed", justify="center", style="green")
64
- table.add_column("Failed", justify="center", style="red")
65
- table.add_column("Pending", justify="center", style="yellow")
66
- table.add_row(
67
- str(report.total_jobs),
68
- str(report.completed_jobs),
69
- str(report.failed_jobs),
70
- str(report.pending_jobs),
71
- )
72
- console.print(table)
71
+ presenter.render_run_status(report, resolved_fmt)
73
72
 
74
73
  asyncio.run(_run())
75
74
 
@@ -0,0 +1,298 @@
1
+ """CLI commands for Hexaflow distributed workflow management in Hexaqueue.
2
+
3
+ Notes/Architectural Intent:
4
+ Provides subcommands for submitting workflow DAG definitions to the cluster,
5
+ inspecting step checkpoints and staged artifact URIs, resuming suspended workflows,
6
+ and aborting active workflows with reverse compensation unwinding.
7
+ """
8
+
9
+ import asyncio
10
+ import importlib.util
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ from hexaflow.adapters.storage.sqlite import SqliteStateStore
17
+ from hexaflow.domain.models import WorkflowDefinition
18
+ from hexaflow.dsl.builder import Workflow
19
+ from rich.console import Console
20
+
21
+ from hexaqueue_cli.adapters.presenter import CliPresenter
22
+ from hexaqueue_cli.infra.options import format_option, resolve_format
23
+ from hexaqueue_workflow.adapters.engines.distributed import HexaqueueDistributedEngine
24
+
25
+ app = typer.Typer(
26
+ name="workflow",
27
+ help="Hexaflow distributed workflow orchestration and cluster scheduling commands.",
28
+ no_args_is_help=True,
29
+ )
30
+ console = Console()
31
+ presenter = CliPresenter(console=console)
32
+
33
+
34
+ def load_workflow_from_target(target: str) -> WorkflowDefinition:
35
+ """Dynamically load a Workflow or WorkflowDefinition from a file target.
36
+
37
+ Supports 'path/to/file.py:workflow_name' or 'path/to/file.py' looking for
38
+ the 'workflow' variable or an instance of Workflow/WorkflowDefinition.
39
+
40
+ Args:
41
+ target: String path or module identifier (e.g. 'flows/pipeline.py:main_flow').
42
+
43
+ Returns:
44
+ Compiled WorkflowDefinition.
45
+
46
+ Raises:
47
+ typer.BadParameter: If file or workflow variable cannot be loaded.
48
+
49
+ Notes/Architectural Intent:
50
+ Enables seamless loading of user-defined DAG workflows without requiring
51
+ prior compilation or separate registration steps.
52
+ """
53
+ if ":" in target:
54
+ file_part, attr_name = target.split(":", 1)
55
+ else:
56
+ file_part, attr_name = target, None
57
+
58
+ file_path = Path(file_part).resolve()
59
+ if not file_path.exists():
60
+ msg = f"Workflow file '{file_path}' does not exist"
61
+ raise typer.BadParameter(msg)
62
+
63
+ spec = importlib.util.spec_from_file_location("dynamic_workflow_module", file_path)
64
+ if not spec or not spec.loader:
65
+ msg = f"Cannot load Python module from '{file_path}'"
66
+ raise typer.BadParameter(msg)
67
+
68
+ module = importlib.util.module_from_spec(spec)
69
+ spec.loader.exec_module(module)
70
+
71
+ wf_candidate: Any = None
72
+ if attr_name:
73
+ if not hasattr(module, attr_name):
74
+ msg = f"Module '{file_path}' does not define attribute '{attr_name}'"
75
+ raise typer.BadParameter(msg)
76
+ wf_candidate = getattr(module, attr_name)
77
+ else:
78
+ if hasattr(module, "workflow"):
79
+ wf_candidate = module.workflow
80
+ else:
81
+ for item in vars(module).values():
82
+ if isinstance(item, (Workflow, WorkflowDefinition)):
83
+ wf_candidate = item
84
+ break
85
+
86
+ if wf_candidate is None:
87
+ msg = f"No Workflow or WorkflowDefinition instance found in '{file_path}'"
88
+ raise typer.BadParameter(msg)
89
+
90
+ if isinstance(wf_candidate, Workflow):
91
+ return wf_candidate.compile()
92
+ if isinstance(wf_candidate, WorkflowDefinition):
93
+ return wf_candidate
94
+
95
+ msg = f"Target object '{wf_candidate}' is not a Workflow or WorkflowDefinition"
96
+ raise typer.BadParameter(msg)
97
+
98
+
99
+ def parse_inputs_arg(inputs: str | None) -> dict[str, Any]:
100
+ """Parse JSON string or key=val pairs into a dictionary.
101
+
102
+ Args:
103
+ inputs: Raw CLI inputs argument string.
104
+
105
+ Returns:
106
+ Dictionary of input arguments.
107
+
108
+ Raises:
109
+ typer.BadParameter: If JSON decoding fails.
110
+ """
111
+ if not inputs:
112
+ return {}
113
+ try:
114
+ parsed = json.loads(inputs)
115
+ if isinstance(parsed, dict):
116
+ return parsed
117
+ msg = "Input JSON must be an object/dict"
118
+ raise ValueError(msg)
119
+ except Exception as e:
120
+ msg = f"Invalid JSON inputs string: {e}"
121
+ raise typer.BadParameter(msg) from e
122
+
123
+
124
+ @app.command("submit")
125
+ def submit_cmd(
126
+ target: str = typer.Argument(
127
+ ...,
128
+ help="Path to workflow file, e.g. 'pipeline.py' or 'pipeline.py:my_flow'",
129
+ ),
130
+ inputs: str | None = typer.Option(
131
+ None, "--inputs", "-i", help="JSON dictionary of initial inputs"
132
+ ),
133
+ db_path: str = typer.Option(
134
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
135
+ ),
136
+ watch: bool = typer.Option(
137
+ False, "--watch", "-w", help="Watch workflow execution until completion"
138
+ ),
139
+ format_type: str = format_option(),
140
+ ) -> None:
141
+ """Submit a workflow definition for distributed cluster execution.
142
+
143
+ Args:
144
+ target: Target file path or specifier.
145
+ inputs: Optional JSON inputs string.
146
+ db_path: SQLite state store database path.
147
+ watch: Whether to block and watch until terminal completion.
148
+ format_type: Output presentation format.
149
+ """
150
+ workflow = load_workflow_from_target(target)
151
+ initial_inputs = parse_inputs_arg(inputs)
152
+ resolved_fmt = resolve_format(format_type)
153
+
154
+ store = SqliteStateStore(db_path=db_path)
155
+ engine = HexaqueueDistributedEngine(state_store=store)
156
+
157
+ if resolved_fmt in ("table", "rich"):
158
+ console.print(
159
+ f"[bold green]Submitting workflow:[/] [cyan]{workflow.name}[/] "
160
+ f"({len(workflow.stages)} stages, {sum(len(s.steps) for s in workflow.stages)} steps)"
161
+ )
162
+
163
+ async def _execute() -> None:
164
+ state = await engine.run_async(workflow, initial_inputs=initial_inputs)
165
+ _render_workflow_state(state, store, resolved_fmt)
166
+
167
+ asyncio.run(_execute())
168
+
169
+
170
+ @app.command("status")
171
+ def status_cmd(
172
+ run_id: str = typer.Argument(
173
+ ..., help="Workflow execution run identifier to inspect"
174
+ ),
175
+ db_path: str = typer.Option(
176
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
177
+ ),
178
+ format_type: str = format_option(),
179
+ ) -> None:
180
+ """Inspect status, checkpoints, and staged artifacts for a workflow run.
181
+
182
+ Args:
183
+ run_id: Run identifier to query.
184
+ db_path: SQLite state store database path.
185
+ format_type: Output presentation format.
186
+ """
187
+ resolved_fmt = resolve_format(format_type)
188
+ store = SqliteStateStore(db_path=db_path)
189
+ state = store.get_run(run_id)
190
+
191
+ if not state:
192
+ console.print(
193
+ f"[bold red]Error:[/] Workflow run '{run_id}' not found in database '{db_path}'."
194
+ )
195
+ raise typer.Exit(code=1)
196
+
197
+ _render_workflow_state(state, store, resolved_fmt)
198
+
199
+
200
+ @app.command("resume")
201
+ def resume_cmd(
202
+ run_id: str = typer.Argument(..., help="Suspended workflow run identifier"),
203
+ target: str = typer.Argument(
204
+ ...,
205
+ help="Path to workflow file, e.g. 'pipeline.py' or 'pipeline.py:my_flow'",
206
+ ),
207
+ inputs: str | None = typer.Option(
208
+ None, "--inputs", "-i", help="Patch inputs for resuming step frontier"
209
+ ),
210
+ skip_steps: list[str] = typer.Option(
211
+ None, "--skip", help="Step names to explicitly skip"
212
+ ),
213
+ db_path: str = typer.Option(
214
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
215
+ ),
216
+ format_type: str = format_option(),
217
+ ) -> None:
218
+ """Resume execution of a suspended workflow run from its latest checkpoints.
219
+
220
+ Args:
221
+ run_id: Suspended run identifier.
222
+ target: Target workflow file specifier.
223
+ inputs: Optional patch inputs.
224
+ skip_steps: Optional step names to skip.
225
+ db_path: SQLite state database path.
226
+ format_type: Output presentation format.
227
+ """
228
+ workflow = load_workflow_from_target(target)
229
+ patch_inputs = parse_inputs_arg(inputs)
230
+ resolved_fmt = resolve_format(format_type)
231
+
232
+ store = SqliteStateStore(db_path=db_path)
233
+ engine = HexaqueueDistributedEngine(state_store=store)
234
+
235
+ if resolved_fmt in ("table", "rich"):
236
+ console.print(f"[bold yellow]Resuming workflow run:[/] [cyan]{run_id}[/]")
237
+
238
+ async def _resume() -> None:
239
+ state = await engine.resume_async(
240
+ run_id=run_id,
241
+ workflow=workflow,
242
+ patch_inputs=patch_inputs,
243
+ skip_steps=set(skip_steps or ()),
244
+ )
245
+ _render_workflow_state(state, store, resolved_fmt)
246
+
247
+ asyncio.run(_resume())
248
+
249
+
250
+ @app.command("abort")
251
+ def abort_cmd(
252
+ run_id: str = typer.Argument(..., help="Workflow run identifier to abort"),
253
+ target: str = typer.Argument(..., help="Path to workflow file"),
254
+ db_path: str = typer.Option(
255
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
256
+ ),
257
+ format_type: str = format_option(),
258
+ ) -> None:
259
+ """Abort an active or suspended workflow, unwinding step compensations in reverse order.
260
+
261
+ Args:
262
+ run_id: Run identifier to abort.
263
+ target: Workflow file specifier containing compensations.
264
+ db_path: SQLite state database path.
265
+ format_type: Output presentation format.
266
+ """
267
+ workflow = load_workflow_from_target(target)
268
+ resolved_fmt = resolve_format(format_type)
269
+ store = SqliteStateStore(db_path=db_path)
270
+ engine = HexaqueueDistributedEngine(state_store=store)
271
+
272
+ if resolved_fmt in ("table", "rich"):
273
+ console.print(f"[bold red]Aborting workflow run:[/] [cyan]{run_id}[/]")
274
+
275
+ async def _abort() -> None:
276
+ state = await engine.abort_async(run_id=run_id, workflow=workflow)
277
+ _render_workflow_state(state, store, resolved_fmt)
278
+
279
+ asyncio.run(_abort())
280
+
281
+
282
+ def _render_workflow_state(
283
+ state: Any, store: SqliteStateStore, format_type: str = "table"
284
+ ) -> None:
285
+ """Render workflow status summary and step checkpoint table."""
286
+ checkpoints = store.get_checkpoints(state.run_id)
287
+ presenter.render_workflow_state(state, checkpoints, format_type=format_type)
288
+
289
+
290
+ __all__ = [
291
+ "abort_cmd",
292
+ "app",
293
+ "load_workflow_from_target",
294
+ "parse_inputs_arg",
295
+ "resume_cmd",
296
+ "status_cmd",
297
+ "submit_cmd",
298
+ ]
@@ -1,5 +1,6 @@
1
1
  """Domain package for hexaqueue-cli."""
2
2
 
3
+ from hexaqueue_cli.domain.options import OutputFormat
3
4
  from hexaqueue_cli.domain.parser import (
4
5
  parse_run_spec_from_dict,
5
6
  parse_run_spec_from_file,
@@ -13,6 +14,7 @@ from hexaqueue_cli.domain.session import (
13
14
  __all__ = [
14
15
  "get_default_session",
15
16
  "LocalCliSession",
17
+ "OutputFormat",
16
18
  "parse_run_spec_from_dict",
17
19
  "parse_run_spec_from_file",
18
20
  "set_default_session",
@@ -0,0 +1,25 @@
1
+ """Domain representations for CLI output presentation formats.
2
+
3
+ Notes/Architectural Intent:
4
+ Defines the canonical output format enumeration shared across CLI presenters,
5
+ command runners, and reporting bridges. Fulfills the cross-ecosystem CLI alignment invariant.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+
12
+ __all__ = [
13
+ "OutputFormat",
14
+ ]
15
+
16
+
17
+ class OutputFormat(StrEnum):
18
+ """Supported output presentation formats for CLI commands."""
19
+
20
+ AUTO = "auto"
21
+ JSON = "json"
22
+ MARKDOWN = "markdown"
23
+ PLAIN = "plain"
24
+ RICH = "rich"
25
+ TABLE = "table"
@@ -0,0 +1,8 @@
1
+ from hexaqueue_cli.infra.bootstrap import CliBootstrapper
2
+ from hexaqueue_cli.infra.options import format_option, resolve_format
3
+
4
+ __all__ = [
5
+ "CliBootstrapper",
6
+ "format_option",
7
+ "resolve_format",
8
+ ]
@@ -0,0 +1,73 @@
1
+ """CLI option factories and formatting resolvers for Hexaqueue CLI applications.
2
+
3
+ Notes/Architectural Intent:
4
+ Provides standardized Typer option factories and stdout pipe-detection routines.
5
+ When format is 'auto', automatically routes to structured JSON when piped into
6
+ downstream Unix utilities (jq, grep) or styled Rich/table output when attached to an
7
+ interactive TTY. Aligns with the Hexa ecosystem CLI standards.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from enum import Enum
14
+ from typing import Any
15
+
16
+ import typer
17
+
18
+ from hexaqueue_cli.domain.options import OutputFormat
19
+
20
+ __all__ = [
21
+ "format_option",
22
+ "resolve_format",
23
+ ]
24
+
25
+
26
+ def resolve_format(
27
+ format_type: str | OutputFormat,
28
+ default_tty: str = "table",
29
+ default_pipe: str = "json",
30
+ ) -> str:
31
+ """Resolve an output format string, handling auto-detection for pipes.
32
+
33
+ Args:
34
+ format_type: The format chosen by user or default ('auto', 'table', 'json', etc.).
35
+ default_tty: Default format when connected to an interactive terminal.
36
+ default_pipe: Default format when stdout is piped or redirected.
37
+
38
+ Returns:
39
+ Normalized lowercase format string (e.g. 'table', 'json', 'rich').
40
+
41
+ Notes/Architectural Intent:
42
+ When format_type is 'auto', inspects sys.stdout.isatty() to choose between
43
+ structured machine-readable format for pipelines or visual format for humans.
44
+ """
45
+ raw = (
46
+ format_type.value
47
+ if isinstance(format_type, Enum)
48
+ else str(format_type).lower().strip()
49
+ )
50
+ if raw == OutputFormat.AUTO.value:
51
+ if not sys.stdout.isatty():
52
+ return default_pipe
53
+ return default_tty
54
+ return raw
55
+
56
+
57
+ def format_option(
58
+ default: str = "table",
59
+ help_text: str = "Output presentation format (table, json, markdown, rich, plain, auto).",
60
+ ) -> Any:
61
+ """Construct a standardized Typer option for format selection.
62
+
63
+ Args:
64
+ default: Default format option value.
65
+ help_text: Help string to render in CLI usage catalogs.
66
+
67
+ Returns:
68
+ Configured Typer Option specification.
69
+
70
+ Notes/Architectural Intent:
71
+ Standardizes '-f' / '--format' flag across all Hexaqueue CLI commands.
72
+ """
73
+ return typer.Option(default, "-f", "--format", help=help_text)
@@ -8,10 +8,12 @@ import asyncio
8
8
 
9
9
  import typer
10
10
  from rich.console import Console
11
- from rich.table import Table
12
11
 
13
12
  from hexaqueue_cli.adapters.local import LocalClientAdapter
13
+ from hexaqueue_cli.adapters.presenter import CliPresenter
14
14
  from hexaqueue_cli.commands.run import app as run_app
15
+ from hexaqueue_cli.commands.workflow import app as workflow_app
16
+ from hexaqueue_cli.infra.options import format_option, resolve_format
15
17
 
16
18
  app = typer.Typer(
17
19
  name="hq",
@@ -19,6 +21,7 @@ app = typer.Typer(
19
21
  no_args_is_help=True,
20
22
  )
21
23
  app.add_typer(run_app, name="run")
24
+ app.add_typer(workflow_app, name="workflow")
22
25
 
23
26
  console = Console()
24
27
 
@@ -26,58 +29,24 @@ console = Console()
26
29
  @app.command("status")
27
30
  def status_cmd(
28
31
  target_id: str = typer.Argument(..., help="Run ID or Job ID to inspect"),
32
+ format_type: str = format_option(),
29
33
  ) -> None:
30
34
  """Check status of a run or job."""
35
+ resolved_fmt = resolve_format(format_type)
36
+ presenter = CliPresenter(console=console)
31
37
 
32
38
  async def _status() -> None:
33
39
  client = LocalClientAdapter()
34
40
  try:
35
41
  report = await client.get_run_status(target_id)
36
- table = Table(title=f"Run Status: {report.run_id}")
37
- table.add_column("Run ID", style="cyan")
38
- table.add_column("State", style="magenta")
39
- table.add_column(
40
- "Outcome",
41
- style="green"
42
- if report.outcome in ("COMPLETED", "SUCCEEDED")
43
- else "yellow",
44
- )
45
- table.add_column("Total", justify="center")
46
- table.add_column("Completed", justify="center", style="green")
47
- table.add_column("Failed", justify="center", style="red")
48
- table.add_column("Pending", justify="center", style="yellow")
49
- table.add_row(
50
- report.run_id,
51
- report.state.value
52
- if hasattr(report.state, "value")
53
- else str(report.state),
54
- str(report.outcome) if report.outcome else "-",
55
- str(report.total_jobs),
56
- str(report.completed_jobs),
57
- str(report.failed_jobs),
58
- str(report.pending_jobs),
59
- )
60
- console.print(table)
42
+ presenter.render_run_status(report, resolved_fmt)
61
43
  return
62
44
  except Exception:
63
45
  pass
64
46
 
65
47
  try:
66
48
  job = await client.get_job(target_id)
67
- table = Table(title=f"Job Status: {job.id}")
68
- table.add_column("Job ID", style="cyan")
69
- table.add_column("Run ID", style="blue")
70
- table.add_column("Name")
71
- table.add_column("State", style="magenta")
72
- table.add_column("Outcome")
73
- table.add_row(
74
- job.id,
75
- job.run_id,
76
- job.name,
77
- job.state.value if hasattr(job.state, "value") else str(job.state),
78
- str(job.outcome) if job.outcome else "-",
79
- )
80
- console.print(table)
49
+ presenter.render_job(job, resolved_fmt)
81
50
  except Exception as e:
82
51
  console.print(f"[bold red]Error querying status for '{target_id}':[/] {e}")
83
52
  raise typer.Exit(code=1) from e
@@ -1,7 +0,0 @@
1
- """Commands package for hexaqueue-cli."""
2
-
3
- from hexaqueue_cli.commands.run import app as run_app
4
-
5
- __all__ = [
6
- "run_app",
7
- ]
@@ -1,7 +0,0 @@
1
- """Infra package for hexaqueue-cli."""
2
-
3
- from hexaqueue_cli.infra.bootstrap import CliBootstrapper
4
-
5
- __all__ = [
6
- "CliBootstrapper",
7
- ]
File without changes