hexaqueue-cli 0.2.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 (22) hide show
  1. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/PKG-INFO +5 -5
  2. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/pyproject.toml +5 -5
  3. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/pyproject.toml.orig +5 -5
  4. {hexaqueue_cli-0.2.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.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/commands/run.py +22 -23
  7. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/commands/workflow.py +33 -90
  8. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/__init__.py +2 -0
  9. hexaqueue_cli-0.3.0/src/hexaqueue_cli/domain/options.py +25 -0
  10. hexaqueue_cli-0.3.0/src/hexaqueue_cli/infra/__init__.py +8 -0
  11. hexaqueue_cli-0.3.0/src/hexaqueue_cli/infra/options.py +73 -0
  12. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/main.py +7 -40
  13. hexaqueue_cli-0.2.0/src/hexaqueue_cli/infra/__init__.py +0 -7
  14. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/README.md +0 -0
  15. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/__init__.py +0 -0
  16. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/adapters/local.py +0 -0
  17. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/commands/__init__.py +0 -0
  18. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/parser.py +0 -0
  19. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/domain/session.py +0 -0
  20. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/infra/bootstrap.py +0 -0
  21. {hexaqueue_cli-0.2.0 → hexaqueue_cli-0.3.0}/src/hexaqueue_cli/ports/__init__.py +0 -0
  22. {hexaqueue_cli-0.2.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.2.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>
@@ -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.3.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.3.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,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)
@@ -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
 
@@ -15,14 +15,12 @@ from typing import Any
15
15
  import typer
16
16
  from hexaflow.adapters.storage.sqlite import SqliteStateStore
17
17
  from hexaflow.domain.models import WorkflowDefinition
18
- from hexaflow.domain.state import StepStatus, WorkflowStatus
19
18
  from hexaflow.dsl.builder import Workflow
20
19
  from rich.console import Console
21
- from rich.panel import Panel
22
- from rich.table import Table
23
20
 
21
+ from hexaqueue_cli.adapters.presenter import CliPresenter
22
+ from hexaqueue_cli.infra.options import format_option, resolve_format
24
23
  from hexaqueue_workflow.adapters.engines.distributed import HexaqueueDistributedEngine
25
- from hexaqueue_workflow.domain.models import ArtifactReference
26
24
 
27
25
  app = typer.Typer(
28
26
  name="workflow",
@@ -30,6 +28,7 @@ app = typer.Typer(
30
28
  no_args_is_help=True,
31
29
  )
32
30
  console = Console()
31
+ presenter = CliPresenter(console=console)
33
32
 
34
33
 
35
34
  def load_workflow_from_target(target: str) -> WorkflowDefinition:
@@ -137,6 +136,7 @@ def submit_cmd(
137
136
  watch: bool = typer.Option(
138
137
  False, "--watch", "-w", help="Watch workflow execution until completion"
139
138
  ),
139
+ format_type: str = format_option(),
140
140
  ) -> None:
141
141
  """Submit a workflow definition for distributed cluster execution.
142
142
 
@@ -145,21 +145,24 @@ def submit_cmd(
145
145
  inputs: Optional JSON inputs string.
146
146
  db_path: SQLite state store database path.
147
147
  watch: Whether to block and watch until terminal completion.
148
+ format_type: Output presentation format.
148
149
  """
149
150
  workflow = load_workflow_from_target(target)
150
151
  initial_inputs = parse_inputs_arg(inputs)
152
+ resolved_fmt = resolve_format(format_type)
151
153
 
152
154
  store = SqliteStateStore(db_path=db_path)
153
155
  engine = HexaqueueDistributedEngine(state_store=store)
154
156
 
155
- console.print(
156
- f"[bold green]Submitting workflow:[/] [cyan]{workflow.name}[/] "
157
- f"({len(workflow.stages)} stages, {sum(len(s.steps) for s in workflow.stages)} steps)"
158
- )
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
+ )
159
162
 
160
163
  async def _execute() -> None:
161
164
  state = await engine.run_async(workflow, initial_inputs=initial_inputs)
162
- _render_workflow_state(state, store)
165
+ _render_workflow_state(state, store, resolved_fmt)
163
166
 
164
167
  asyncio.run(_execute())
165
168
 
@@ -172,13 +175,16 @@ def status_cmd(
172
175
  db_path: str = typer.Option(
173
176
  ".hexaflow/state.db", "--db", help="Path to SQLite state database"
174
177
  ),
178
+ format_type: str = format_option(),
175
179
  ) -> None:
176
180
  """Inspect status, checkpoints, and staged artifacts for a workflow run.
177
181
 
178
182
  Args:
179
183
  run_id: Run identifier to query.
180
184
  db_path: SQLite state store database path.
185
+ format_type: Output presentation format.
181
186
  """
187
+ resolved_fmt = resolve_format(format_type)
182
188
  store = SqliteStateStore(db_path=db_path)
183
189
  state = store.get_run(run_id)
184
190
 
@@ -188,7 +194,7 @@ def status_cmd(
188
194
  )
189
195
  raise typer.Exit(code=1)
190
196
 
191
- _render_workflow_state(state, store)
197
+ _render_workflow_state(state, store, resolved_fmt)
192
198
 
193
199
 
194
200
  @app.command("resume")
@@ -207,6 +213,7 @@ def resume_cmd(
207
213
  db_path: str = typer.Option(
208
214
  ".hexaflow/state.db", "--db", help="Path to SQLite state database"
209
215
  ),
216
+ format_type: str = format_option(),
210
217
  ) -> None:
211
218
  """Resume execution of a suspended workflow run from its latest checkpoints.
212
219
 
@@ -216,14 +223,17 @@ def resume_cmd(
216
223
  inputs: Optional patch inputs.
217
224
  skip_steps: Optional step names to skip.
218
225
  db_path: SQLite state database path.
226
+ format_type: Output presentation format.
219
227
  """
220
228
  workflow = load_workflow_from_target(target)
221
229
  patch_inputs = parse_inputs_arg(inputs)
230
+ resolved_fmt = resolve_format(format_type)
222
231
 
223
232
  store = SqliteStateStore(db_path=db_path)
224
233
  engine = HexaqueueDistributedEngine(state_store=store)
225
234
 
226
- console.print(f"[bold yellow]Resuming workflow run:[/] [cyan]{run_id}[/]")
235
+ if resolved_fmt in ("table", "rich"):
236
+ console.print(f"[bold yellow]Resuming workflow run:[/] [cyan]{run_id}[/]")
227
237
 
228
238
  async def _resume() -> None:
229
239
  state = await engine.resume_async(
@@ -232,7 +242,7 @@ def resume_cmd(
232
242
  patch_inputs=patch_inputs,
233
243
  skip_steps=set(skip_steps or ()),
234
244
  )
235
- _render_workflow_state(state, store)
245
+ _render_workflow_state(state, store, resolved_fmt)
236
246
 
237
247
  asyncio.run(_resume())
238
248
 
@@ -244,6 +254,7 @@ def abort_cmd(
244
254
  db_path: str = typer.Option(
245
255
  ".hexaflow/state.db", "--db", help="Path to SQLite state database"
246
256
  ),
257
+ format_type: str = format_option(),
247
258
  ) -> None:
248
259
  """Abort an active or suspended workflow, unwinding step compensations in reverse order.
249
260
 
@@ -251,97 +262,29 @@ def abort_cmd(
251
262
  run_id: Run identifier to abort.
252
263
  target: Workflow file specifier containing compensations.
253
264
  db_path: SQLite state database path.
265
+ format_type: Output presentation format.
254
266
  """
255
267
  workflow = load_workflow_from_target(target)
268
+ resolved_fmt = resolve_format(format_type)
256
269
  store = SqliteStateStore(db_path=db_path)
257
270
  engine = HexaqueueDistributedEngine(state_store=store)
258
271
 
259
- console.print(f"[bold red]Aborting workflow run:[/] [cyan]{run_id}[/]")
272
+ if resolved_fmt in ("table", "rich"):
273
+ console.print(f"[bold red]Aborting workflow run:[/] [cyan]{run_id}[/]")
260
274
 
261
275
  async def _abort() -> None:
262
276
  state = await engine.abort_async(run_id=run_id, workflow=workflow)
263
- _render_workflow_state(state, store)
277
+ _render_workflow_state(state, store, resolved_fmt)
264
278
 
265
279
  asyncio.run(_abort())
266
280
 
267
281
 
268
- def _render_workflow_state(state: Any, store: SqliteStateStore) -> None:
269
- """Render Rich status summary panel and step checkpoint table."""
270
- status_color = {
271
- WorkflowStatus.COMPLETED: "bold green",
272
- WorkflowStatus.RUNNING: "bold cyan",
273
- WorkflowStatus.SUSPENDED: "bold yellow",
274
- WorkflowStatus.CANCELLED: "bold magenta",
275
- WorkflowStatus.PENDING: "bold white",
276
- }.get(state.status, "white")
277
-
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:", state.run_id)
282
- header_table.add_row("Workflow:", state.workflow_name)
283
- header_table.add_row("Status:", f"[{status_color}]{state.status.value}[/]")
284
- if state.current_stage:
285
- header_table.add_row("Current Stage:", state.current_stage)
286
- if state.error_summary:
287
- header_table.add_row("Error:", f"[bold red]{state.error_summary}[/]")
288
- header_table.add_row(
289
- "Started At:", state.started_at.strftime("%Y-%m-%d %H:%M:%S UTC")
290
- )
291
- if state.finished_at:
292
- header_table.add_row(
293
- "Finished At:", state.finished_at.strftime("%Y-%m-%d %H:%M:%S UTC")
294
- )
295
-
296
- console.print(
297
- Panel(
298
- header_table,
299
- title=f"Workflow Run: {state.workflow_name}",
300
- expand=False,
301
- )
302
- )
303
-
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."""
304
286
  checkpoints = store.get_checkpoints(state.run_id)
305
- if not checkpoints:
306
- console.print("[dim]No step checkpoints recorded yet.[/]")
307
- return
308
-
309
- table = Table(title="Step Checkpoints & Staged Artifacts", expand=True)
310
- table.add_column("Stage", style="cyan")
311
- table.add_column("Step", style="bold")
312
- table.add_column("Status", justify="center")
313
- table.add_column("Attempt", justify="right")
314
- table.add_column("Duration (s)", justify="right")
315
- table.add_column("Artifact / Output", style="dim")
316
-
317
- for chk in checkpoints:
318
- chk_color = {
319
- StepStatus.COMPLETED: "[green]COMPLETED[/]",
320
- StepStatus.RUNNING: "[cyan]RUNNING[/]",
321
- StepStatus.FAILED: "[red]FAILED[/]",
322
- StepStatus.SKIPPED: "[dim]SKIPPED[/]",
323
- StepStatus.PENDING: "[white]PENDING[/]",
324
- }.get(chk.status, str(chk.status))
325
-
326
- artifact_info = "-"
327
- if ArtifactReference.is_artifact_envelope(chk.output_payload):
328
- uri = chk.output_payload.get("storage_uri", "")
329
- size = chk.output_payload.get("size_bytes", 0)
330
- artifact_info = f"[cyan]staged:[/] {uri} ({size}B)"
331
- elif chk.output_payload is not None:
332
- raw_str = str(chk.output_payload)
333
- artifact_info = (raw_str[:30] + "...") if len(raw_str) > 30 else raw_str
334
-
335
- table.add_row(
336
- chk.stage_name,
337
- chk.step_name,
338
- chk_color,
339
- str(chk.attempt_number),
340
- f"{chk.duration_seconds:.3f}",
341
- artifact_info,
342
- )
343
-
344
- console.print(table)
287
+ presenter.render_workflow_state(state, checkpoints, format_type=format_type)
345
288
 
346
289
 
347
290
  __all__ = [
@@ -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,11 +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
15
  from hexaqueue_cli.commands.workflow import app as workflow_app
16
+ from hexaqueue_cli.infra.options import format_option, resolve_format
16
17
 
17
18
  app = typer.Typer(
18
19
  name="hq",
@@ -28,58 +29,24 @@ console = Console()
28
29
  @app.command("status")
29
30
  def status_cmd(
30
31
  target_id: str = typer.Argument(..., help="Run ID or Job ID to inspect"),
32
+ format_type: str = format_option(),
31
33
  ) -> None:
32
34
  """Check status of a run or job."""
35
+ resolved_fmt = resolve_format(format_type)
36
+ presenter = CliPresenter(console=console)
33
37
 
34
38
  async def _status() -> None:
35
39
  client = LocalClientAdapter()
36
40
  try:
37
41
  report = await client.get_run_status(target_id)
38
- table = Table(title=f"Run Status: {report.run_id}")
39
- table.add_column("Run ID", style="cyan")
40
- table.add_column("State", style="magenta")
41
- table.add_column(
42
- "Outcome",
43
- style="green"
44
- if report.outcome in ("COMPLETED", "SUCCEEDED")
45
- else "yellow",
46
- )
47
- table.add_column("Total", justify="center")
48
- table.add_column("Completed", justify="center", style="green")
49
- table.add_column("Failed", justify="center", style="red")
50
- table.add_column("Pending", justify="center", style="yellow")
51
- table.add_row(
52
- report.run_id,
53
- report.state.value
54
- if hasattr(report.state, "value")
55
- else str(report.state),
56
- str(report.outcome) if report.outcome else "-",
57
- str(report.total_jobs),
58
- str(report.completed_jobs),
59
- str(report.failed_jobs),
60
- str(report.pending_jobs),
61
- )
62
- console.print(table)
42
+ presenter.render_run_status(report, resolved_fmt)
63
43
  return
64
44
  except Exception:
65
45
  pass
66
46
 
67
47
  try:
68
48
  job = await client.get_job(target_id)
69
- table = Table(title=f"Job Status: {job.id}")
70
- table.add_column("Job ID", style="cyan")
71
- table.add_column("Run ID", style="blue")
72
- table.add_column("Name")
73
- table.add_column("State", style="magenta")
74
- table.add_column("Outcome")
75
- table.add_row(
76
- job.id,
77
- job.run_id,
78
- job.name,
79
- job.state.value if hasattr(job.state, "value") else str(job.state),
80
- str(job.outcome) if job.outcome else "-",
81
- )
82
- console.print(table)
49
+ presenter.render_job(job, resolved_fmt)
83
50
  except Exception as e:
84
51
  console.print(f"[bold red]Error querying status for '{target_id}':[/] {e}")
85
52
  raise typer.Exit(code=1) from e
@@ -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