hexaqueue-cli 0.1.0__tar.gz → 0.2.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 (19) hide show
  1. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/PKG-INFO +6 -5
  2. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/pyproject.toml +9 -5
  3. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/pyproject.toml.orig +7 -5
  4. hexaqueue_cli-0.2.0/src/hexaqueue_cli/commands/__init__.py +13 -0
  5. hexaqueue_cli-0.2.0/src/hexaqueue_cli/commands/workflow.py +355 -0
  6. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/main.py +2 -0
  7. hexaqueue_cli-0.1.0/src/hexaqueue_cli/commands/__init__.py +0 -7
  8. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/README.md +0 -0
  9. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/__init__.py +0 -0
  10. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/adapters/__init__.py +0 -0
  11. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/adapters/local.py +0 -0
  12. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/commands/run.py +0 -0
  13. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/domain/__init__.py +0 -0
  14. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/domain/parser.py +0 -0
  15. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/domain/session.py +0 -0
  16. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/infra/__init__.py +0 -0
  17. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/infra/bootstrap.py +0 -0
  18. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.0}/src/hexaqueue_cli/ports/__init__.py +0 -0
  19. {hexaqueue_cli-0.1.0 → hexaqueue_cli-0.2.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.2.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.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
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.2.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.5.0",
14
+ "hexastack-core>=0.5.0",
15
+ "hexastack-cqrs>=0.5.0",
16
+ "hexastack-grpc>=0.5.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.2.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.5.0",
17
+ "hexastack-core>=0.5.0",
18
+ "hexastack-cqrs>=0.5.0",
19
+ "hexastack-grpc>=0.5.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 }
@@ -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
+ ]
@@ -0,0 +1,355 @@
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.domain.state import StepStatus, WorkflowStatus
19
+ from hexaflow.dsl.builder import Workflow
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from rich.table import Table
23
+
24
+ from hexaqueue_workflow.adapters.engines.distributed import HexaqueueDistributedEngine
25
+ from hexaqueue_workflow.domain.models import ArtifactReference
26
+
27
+ app = typer.Typer(
28
+ name="workflow",
29
+ help="Hexaflow distributed workflow orchestration and cluster scheduling commands.",
30
+ no_args_is_help=True,
31
+ )
32
+ console = Console()
33
+
34
+
35
+ def load_workflow_from_target(target: str) -> WorkflowDefinition:
36
+ """Dynamically load a Workflow or WorkflowDefinition from a file target.
37
+
38
+ Supports 'path/to/file.py:workflow_name' or 'path/to/file.py' looking for
39
+ the 'workflow' variable or an instance of Workflow/WorkflowDefinition.
40
+
41
+ Args:
42
+ target: String path or module identifier (e.g. 'flows/pipeline.py:main_flow').
43
+
44
+ Returns:
45
+ Compiled WorkflowDefinition.
46
+
47
+ Raises:
48
+ typer.BadParameter: If file or workflow variable cannot be loaded.
49
+
50
+ Notes/Architectural Intent:
51
+ Enables seamless loading of user-defined DAG workflows without requiring
52
+ prior compilation or separate registration steps.
53
+ """
54
+ if ":" in target:
55
+ file_part, attr_name = target.split(":", 1)
56
+ else:
57
+ file_part, attr_name = target, None
58
+
59
+ file_path = Path(file_part).resolve()
60
+ if not file_path.exists():
61
+ msg = f"Workflow file '{file_path}' does not exist"
62
+ raise typer.BadParameter(msg)
63
+
64
+ spec = importlib.util.spec_from_file_location("dynamic_workflow_module", file_path)
65
+ if not spec or not spec.loader:
66
+ msg = f"Cannot load Python module from '{file_path}'"
67
+ raise typer.BadParameter(msg)
68
+
69
+ module = importlib.util.module_from_spec(spec)
70
+ spec.loader.exec_module(module)
71
+
72
+ wf_candidate: Any = None
73
+ if attr_name:
74
+ if not hasattr(module, attr_name):
75
+ msg = f"Module '{file_path}' does not define attribute '{attr_name}'"
76
+ raise typer.BadParameter(msg)
77
+ wf_candidate = getattr(module, attr_name)
78
+ else:
79
+ if hasattr(module, "workflow"):
80
+ wf_candidate = module.workflow
81
+ else:
82
+ for item in vars(module).values():
83
+ if isinstance(item, (Workflow, WorkflowDefinition)):
84
+ wf_candidate = item
85
+ break
86
+
87
+ if wf_candidate is None:
88
+ msg = f"No Workflow or WorkflowDefinition instance found in '{file_path}'"
89
+ raise typer.BadParameter(msg)
90
+
91
+ if isinstance(wf_candidate, Workflow):
92
+ return wf_candidate.compile()
93
+ if isinstance(wf_candidate, WorkflowDefinition):
94
+ return wf_candidate
95
+
96
+ msg = f"Target object '{wf_candidate}' is not a Workflow or WorkflowDefinition"
97
+ raise typer.BadParameter(msg)
98
+
99
+
100
+ def parse_inputs_arg(inputs: str | None) -> dict[str, Any]:
101
+ """Parse JSON string or key=val pairs into a dictionary.
102
+
103
+ Args:
104
+ inputs: Raw CLI inputs argument string.
105
+
106
+ Returns:
107
+ Dictionary of input arguments.
108
+
109
+ Raises:
110
+ typer.BadParameter: If JSON decoding fails.
111
+ """
112
+ if not inputs:
113
+ return {}
114
+ try:
115
+ parsed = json.loads(inputs)
116
+ if isinstance(parsed, dict):
117
+ return parsed
118
+ msg = "Input JSON must be an object/dict"
119
+ raise ValueError(msg)
120
+ except Exception as e:
121
+ msg = f"Invalid JSON inputs string: {e}"
122
+ raise typer.BadParameter(msg) from e
123
+
124
+
125
+ @app.command("submit")
126
+ def submit_cmd(
127
+ target: str = typer.Argument(
128
+ ...,
129
+ help="Path to workflow file, e.g. 'pipeline.py' or 'pipeline.py:my_flow'",
130
+ ),
131
+ inputs: str | None = typer.Option(
132
+ None, "--inputs", "-i", help="JSON dictionary of initial inputs"
133
+ ),
134
+ db_path: str = typer.Option(
135
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
136
+ ),
137
+ watch: bool = typer.Option(
138
+ False, "--watch", "-w", help="Watch workflow execution until completion"
139
+ ),
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
+ """
149
+ workflow = load_workflow_from_target(target)
150
+ initial_inputs = parse_inputs_arg(inputs)
151
+
152
+ store = SqliteStateStore(db_path=db_path)
153
+ engine = HexaqueueDistributedEngine(state_store=store)
154
+
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
+ )
159
+
160
+ async def _execute() -> None:
161
+ state = await engine.run_async(workflow, initial_inputs=initial_inputs)
162
+ _render_workflow_state(state, store)
163
+
164
+ asyncio.run(_execute())
165
+
166
+
167
+ @app.command("status")
168
+ def status_cmd(
169
+ run_id: str = typer.Argument(
170
+ ..., help="Workflow execution run identifier to inspect"
171
+ ),
172
+ db_path: str = typer.Option(
173
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
174
+ ),
175
+ ) -> None:
176
+ """Inspect status, checkpoints, and staged artifacts for a workflow run.
177
+
178
+ Args:
179
+ run_id: Run identifier to query.
180
+ db_path: SQLite state store database path.
181
+ """
182
+ store = SqliteStateStore(db_path=db_path)
183
+ state = store.get_run(run_id)
184
+
185
+ if not state:
186
+ console.print(
187
+ f"[bold red]Error:[/] Workflow run '{run_id}' not found in database '{db_path}'."
188
+ )
189
+ raise typer.Exit(code=1)
190
+
191
+ _render_workflow_state(state, store)
192
+
193
+
194
+ @app.command("resume")
195
+ def resume_cmd(
196
+ run_id: str = typer.Argument(..., help="Suspended workflow run identifier"),
197
+ target: str = typer.Argument(
198
+ ...,
199
+ help="Path to workflow file, e.g. 'pipeline.py' or 'pipeline.py:my_flow'",
200
+ ),
201
+ inputs: str | None = typer.Option(
202
+ None, "--inputs", "-i", help="Patch inputs for resuming step frontier"
203
+ ),
204
+ skip_steps: list[str] = typer.Option(
205
+ None, "--skip", help="Step names to explicitly skip"
206
+ ),
207
+ db_path: str = typer.Option(
208
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
209
+ ),
210
+ ) -> None:
211
+ """Resume execution of a suspended workflow run from its latest checkpoints.
212
+
213
+ Args:
214
+ run_id: Suspended run identifier.
215
+ target: Target workflow file specifier.
216
+ inputs: Optional patch inputs.
217
+ skip_steps: Optional step names to skip.
218
+ db_path: SQLite state database path.
219
+ """
220
+ workflow = load_workflow_from_target(target)
221
+ patch_inputs = parse_inputs_arg(inputs)
222
+
223
+ store = SqliteStateStore(db_path=db_path)
224
+ engine = HexaqueueDistributedEngine(state_store=store)
225
+
226
+ console.print(f"[bold yellow]Resuming workflow run:[/] [cyan]{run_id}[/]")
227
+
228
+ async def _resume() -> None:
229
+ state = await engine.resume_async(
230
+ run_id=run_id,
231
+ workflow=workflow,
232
+ patch_inputs=patch_inputs,
233
+ skip_steps=set(skip_steps or ()),
234
+ )
235
+ _render_workflow_state(state, store)
236
+
237
+ asyncio.run(_resume())
238
+
239
+
240
+ @app.command("abort")
241
+ def abort_cmd(
242
+ run_id: str = typer.Argument(..., help="Workflow run identifier to abort"),
243
+ target: str = typer.Argument(..., help="Path to workflow file"),
244
+ db_path: str = typer.Option(
245
+ ".hexaflow/state.db", "--db", help="Path to SQLite state database"
246
+ ),
247
+ ) -> None:
248
+ """Abort an active or suspended workflow, unwinding step compensations in reverse order.
249
+
250
+ Args:
251
+ run_id: Run identifier to abort.
252
+ target: Workflow file specifier containing compensations.
253
+ db_path: SQLite state database path.
254
+ """
255
+ workflow = load_workflow_from_target(target)
256
+ store = SqliteStateStore(db_path=db_path)
257
+ engine = HexaqueueDistributedEngine(state_store=store)
258
+
259
+ console.print(f"[bold red]Aborting workflow run:[/] [cyan]{run_id}[/]")
260
+
261
+ async def _abort() -> None:
262
+ state = await engine.abort_async(run_id=run_id, workflow=workflow)
263
+ _render_workflow_state(state, store)
264
+
265
+ asyncio.run(_abort())
266
+
267
+
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
+
304
+ 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)
345
+
346
+
347
+ __all__ = [
348
+ "abort_cmd",
349
+ "app",
350
+ "load_workflow_from_target",
351
+ "parse_inputs_arg",
352
+ "resume_cmd",
353
+ "status_cmd",
354
+ "submit_cmd",
355
+ ]
@@ -12,6 +12,7 @@ from rich.table import Table
12
12
 
13
13
  from hexaqueue_cli.adapters.local import LocalClientAdapter
14
14
  from hexaqueue_cli.commands.run import app as run_app
15
+ from hexaqueue_cli.commands.workflow import app as workflow_app
15
16
 
16
17
  app = typer.Typer(
17
18
  name="hq",
@@ -19,6 +20,7 @@ app = typer.Typer(
19
20
  no_args_is_help=True,
20
21
  )
21
22
  app.add_typer(run_app, name="run")
23
+ app.add_typer(workflow_app, name="workflow")
22
24
 
23
25
  console = Console()
24
26
 
@@ -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
- ]
File without changes