PyTaskWireCheck 0.1.0__py3-none-any.whl

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 (45) hide show
  1. pytaskwirecheck/__init__.py +32 -0
  2. pytaskwirecheck/api.py +314 -0
  3. pytaskwirecheck/cache/__init__.py +0 -0
  4. pytaskwirecheck/cache/fingerprint.py +176 -0
  5. pytaskwirecheck/cache/policy.py +49 -0
  6. pytaskwirecheck/cache/store.py +367 -0
  7. pytaskwirecheck/cli.py +690 -0
  8. pytaskwirecheck/config/__init__.py +0 -0
  9. pytaskwirecheck/config/loader.py +72 -0
  10. pytaskwirecheck/config/schema.py +170 -0
  11. pytaskwirecheck/config/validation.py +590 -0
  12. pytaskwirecheck/diagnostics/__init__.py +31 -0
  13. pytaskwirecheck/diagnostics/errors.py +86 -0
  14. pytaskwirecheck/engine/__init__.py +0 -0
  15. pytaskwirecheck/engine/cancellation.py +38 -0
  16. pytaskwirecheck/engine/events.py +93 -0
  17. pytaskwirecheck/engine/executor.py +301 -0
  18. pytaskwirecheck/engine/scheduler.py +312 -0
  19. pytaskwirecheck/engine/state.py +123 -0
  20. pytaskwirecheck/graph/__init__.py +0 -0
  21. pytaskwirecheck/graph/builder.py +33 -0
  22. pytaskwirecheck/graph/model.py +104 -0
  23. pytaskwirecheck/graph/planner.py +155 -0
  24. pytaskwirecheck/graph/validator.py +92 -0
  25. pytaskwirecheck/history/__init__.py +0 -0
  26. pytaskwirecheck/history/store.py +164 -0
  27. pytaskwirecheck/logging/__init__.py +0 -0
  28. pytaskwirecheck/logging/artifacts.py +95 -0
  29. pytaskwirecheck/logging/structured.py +83 -0
  30. pytaskwirecheck/notifications/__init__.py +0 -0
  31. pytaskwirecheck/notifications/base.py +42 -0
  32. pytaskwirecheck/notifications/webhook.py +71 -0
  33. pytaskwirecheck/processes/__init__.py +0 -0
  34. pytaskwirecheck/processes/runner.py +265 -0
  35. pytaskwirecheck/processes/signals.py +65 -0
  36. pytaskwirecheck/py.typed +0 -0
  37. pytaskwirecheck/tui/__init__.py +0 -0
  38. pytaskwirecheck/tui/app.py +204 -0
  39. pytaskwirecheck/tui/renderer.py +143 -0
  40. pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
  41. pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
  42. pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
  43. pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
  44. pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. pytaskwirecheck-0.1.0.dist-info/top_level.txt +1 -0
pytaskwirecheck/cli.py ADDED
@@ -0,0 +1,690 @@
1
+ """PyTaskWireCheck command-line interface.
2
+
3
+ Commands are intentionally thin: they load/validate configuration, build and
4
+ validate the graph, and delegate execution to the engine. All user-facing
5
+ errors are reported concisely with a stable exit code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import sys
14
+ import uuid
15
+ from pathlib import Path
16
+ from typing import List, Optional, Sequence, TextIO
17
+
18
+ from pytaskwirecheck import __version__
19
+ from pytaskwirecheck.cache.policy import build_store, decide
20
+ from pytaskwirecheck.config.loader import find_config, load_config
21
+ from pytaskwirecheck.config.schema import WorkflowConfig
22
+ from pytaskwirecheck.diagnostics.errors import (
23
+ ConfigError,
24
+ GraphError,
25
+ HistoryError,
26
+ PyTaskWireCheckError,
27
+ )
28
+ from pytaskwirecheck.engine.cancellation import CancellationToken
29
+ from pytaskwirecheck.engine.events import EventBus
30
+ from pytaskwirecheck.engine.executor import TaskExecutor
31
+ from pytaskwirecheck.engine.scheduler import RunResult, Scheduler
32
+ from pytaskwirecheck.graph.builder import build_graph
33
+ from pytaskwirecheck.graph.model import TaskGraph
34
+ from pytaskwirecheck.graph.planner import build_plan, resolve_targets
35
+ from pytaskwirecheck.graph.validator import validate_graph
36
+ from pytaskwirecheck.history.store import HistoryStore
37
+ from pytaskwirecheck.logging.artifacts import ArtifactWriter
38
+ from pytaskwirecheck.logging.structured import StructuredLogger
39
+ from pytaskwirecheck.notifications.base import RunNotification
40
+ from pytaskwirecheck.notifications.webhook import WebhookNotifier
41
+ from pytaskwirecheck.processes.signals import install_cancel_handlers
42
+ from pytaskwirecheck.tui.app import LiveDashboard
43
+ from pytaskwirecheck.tui.renderer import PlainReporter
44
+
45
+ EXIT_OK = 0
46
+ EXIT_TASK_FAILURE = 1
47
+ EXIT_USAGE = 2
48
+ EXIT_GRAPH = 3
49
+ EXIT_INTERRUPTED = 130
50
+
51
+ _STARTER_CONFIG = """\
52
+ [project]
53
+ name = "example-workflow"
54
+ version = 1
55
+
56
+ [execution]
57
+ max_workers = 4
58
+ default_timeout_seconds = 900
59
+ fail_fast = false
60
+
61
+ [tasks.greet]
62
+ command = ["python", "-c", "print('hello from PyTaskWireCheck')"]
63
+ description = "A trivial, dependency-free first task"
64
+
65
+ [tasks.describe]
66
+ command = ["python", "-c", "print('workspace ready')"]
67
+ depends_on = ["greet"]
68
+ description = "Runs after greet"
69
+ """
70
+
71
+
72
+ def _err(stream: TextIO, message: str) -> None:
73
+ try:
74
+ stream.write("pytaskwirecheck: error: %s\n" % message)
75
+ stream.flush()
76
+ except (BrokenPipeError, ValueError):
77
+ pass
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Argument parser
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ def build_parser() -> argparse.ArgumentParser:
86
+ parser = argparse.ArgumentParser(
87
+ prog="pytaskwirecheck",
88
+ description="Local-first, dependency-aware task orchestration.",
89
+ )
90
+ parser.add_argument(
91
+ "--version", action="version", version="pytaskwirecheck %s" % __version__
92
+ )
93
+ parser.add_argument(
94
+ "-C",
95
+ "--root",
96
+ dest="root",
97
+ default=None,
98
+ help="project root (defaults to the config file's directory)",
99
+ )
100
+ parser.add_argument(
101
+ "--config",
102
+ dest="config",
103
+ default=None,
104
+ help="path to pytaskwirecheck.toml (defaults to nearest file)",
105
+ )
106
+ parser.add_argument(
107
+ "--no-color", action="store_true", help="disable ANSI colour output"
108
+ )
109
+
110
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
111
+ sub.required = True
112
+
113
+ # init
114
+ p_init = sub.add_parser("init", help="create a starter configuration")
115
+ p_init.add_argument(
116
+ "--path", default=".", help="directory to create pytaskwirecheck.toml in"
117
+ )
118
+ p_init.add_argument(
119
+ "--force", action="store_true", help="overwrite an existing file"
120
+ )
121
+ p_init.set_defaults(func=cmd_init)
122
+
123
+ # validate
124
+ p_val = sub.add_parser("validate", help="validate configuration and graph")
125
+ p_val.add_argument(
126
+ "--json", action="store_true", help="emit machine-readable output"
127
+ )
128
+ p_val.set_defaults(func=cmd_validate)
129
+
130
+ # graph
131
+ p_graph = sub.add_parser("graph", help="display the task DAG")
132
+ p_graph.add_argument(
133
+ "--format",
134
+ choices=["text", "dot", "json"],
135
+ default="text",
136
+ help="output format",
137
+ )
138
+ p_graph.set_defaults(func=cmd_graph)
139
+
140
+ # plan
141
+ p_plan = sub.add_parser("plan", help="show the execution plan")
142
+ p_plan.add_argument(
143
+ "tasks", nargs="*", help="restrict to these tasks and their dependencies"
144
+ )
145
+ p_plan.add_argument("--json", action="store_true", help="emit JSON")
146
+ p_plan.set_defaults(func=cmd_plan)
147
+
148
+ # run
149
+ p_run = sub.add_parser("run", help="execute tasks")
150
+ p_run.add_argument("tasks", nargs="*", help="tasks to run (defaults to all)")
151
+ p_run.add_argument(
152
+ "--no-cache", action="store_true", help="disable cache reads and writes"
153
+ )
154
+ p_run.add_argument(
155
+ "--max-workers", type=int, default=None, help="override worker count"
156
+ )
157
+ p_run.add_argument(
158
+ "--fail-fast",
159
+ dest="fail_fast",
160
+ action="store_true",
161
+ default=None,
162
+ help="stop launching new tasks after the first failure",
163
+ )
164
+ p_run.add_argument(
165
+ "--no-fail-fast",
166
+ dest="fail_fast",
167
+ action="store_false",
168
+ help="allow unrelated branches to continue after a failure",
169
+ )
170
+ p_run.add_argument("--json", action="store_true", help="emit a JSON run summary")
171
+ p_run.add_argument(
172
+ "-q", "--quiet", action="store_true", help="suppress progress output"
173
+ )
174
+ p_run.add_argument(
175
+ "--no-output",
176
+ action="store_true",
177
+ help="do not stream task output to the console",
178
+ )
179
+ p_run.add_argument(
180
+ "--tui",
181
+ action="store_true",
182
+ help="show a live dashboard when attached to a TTY",
183
+ )
184
+ p_run.set_defaults(func=cmd_run)
185
+
186
+ # cache
187
+ p_cache = sub.add_parser("cache", help="inspect or clean the cache")
188
+ cache_sub = p_cache.add_subparsers(dest="cache_command", metavar="ACTION")
189
+ cache_sub.required = True
190
+ p_cache_status = cache_sub.add_parser("status", help="show cache statistics")
191
+ p_cache_status.add_argument("--json", action="store_true")
192
+ p_cache_status.set_defaults(func=cmd_cache_status)
193
+ p_cache_clean = cache_sub.add_parser("clean", help="remove cached data")
194
+ p_cache_clean.add_argument(
195
+ "--force", action="store_true", help="do not prompt for confirmation"
196
+ )
197
+ p_cache_clean.add_argument("--json", action="store_true")
198
+ p_cache_clean.set_defaults(func=cmd_cache_clean)
199
+
200
+ # explain
201
+ p_explain = sub.add_parser(
202
+ "explain", help="explain scheduling and cache status for a task"
203
+ )
204
+ p_explain.add_argument("task", help="task identifier")
205
+ p_explain.add_argument("--json", action="store_true")
206
+ p_explain.set_defaults(func=cmd_explain)
207
+
208
+ # history
209
+ p_history = sub.add_parser("history", help="show recent execution summaries")
210
+ p_history.add_argument(
211
+ "--limit", type=int, default=20, help="number of runs to show"
212
+ )
213
+ p_history.add_argument("--json", action="store_true")
214
+ p_history.set_defaults(func=cmd_history)
215
+
216
+ return parser
217
+
218
+
219
+ # ---------------------------------------------------------------------------
220
+ # Shared helpers
221
+ # ---------------------------------------------------------------------------
222
+
223
+
224
+ def _resolve_config_path(args: argparse.Namespace) -> Path:
225
+ if args.config:
226
+ return Path(args.config).resolve()
227
+ found = find_config(Path.cwd())
228
+ if found is None:
229
+ raise ConfigError(
230
+ "no pytaskwirecheck.toml found in %s or any parent directory" % Path.cwd()
231
+ )
232
+ return found
233
+
234
+
235
+ def _load(args: argparse.Namespace) -> WorkflowConfig:
236
+ config_path = _resolve_config_path(args)
237
+ return load_config(config_path, root_override=args.root)
238
+
239
+
240
+ def _prepare_graph(
241
+ config: WorkflowConfig,
242
+ ) -> TaskGraph:
243
+ graph = build_graph(config)
244
+ validate_graph(graph)
245
+ return graph
246
+
247
+
248
+ def _color_enabled(args: argparse.Namespace, stream: TextIO) -> bool:
249
+ if getattr(args, "no_color", False):
250
+ return False
251
+ if os.environ.get("NO_COLOR"):
252
+ return False
253
+ try:
254
+ return bool(stream.isatty())
255
+ except (ValueError, AttributeError):
256
+ return False
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Commands
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def cmd_init(args: argparse.Namespace) -> int:
265
+ target_dir = Path(args.path).resolve()
266
+ target_dir.mkdir(parents=True, exist_ok=True)
267
+ config_path = target_dir / "pytaskwirecheck.toml"
268
+ if config_path.exists() and not args.force:
269
+ _err(sys.stderr, "%s already exists (use --force)" % config_path)
270
+ return EXIT_USAGE
271
+ config_path.write_text(_STARTER_CONFIG, encoding="utf-8")
272
+ print("Created %s" % config_path)
273
+ return EXIT_OK
274
+
275
+
276
+ def cmd_validate(args: argparse.Namespace) -> int:
277
+ config = _load(args)
278
+ graph = _prepare_graph(config)
279
+ plan = build_plan(graph)
280
+ if args.json:
281
+ print(
282
+ json.dumps(
283
+ {
284
+ "ok": True,
285
+ "project": config.project.name,
286
+ "config_version": config.project.version,
287
+ "task_count": len(config.tasks),
288
+ "tasks": sorted(config.tasks.keys()),
289
+ "levels": [list(level) for level in plan.levels],
290
+ },
291
+ indent=2,
292
+ )
293
+ )
294
+ else:
295
+ print(
296
+ "configuration OK: %d task(s), %d level(s)"
297
+ % (len(config.tasks), len(plan.levels))
298
+ )
299
+ return EXIT_OK
300
+
301
+
302
+ def _graph_text(graph: TaskGraph) -> str:
303
+ lines: List[str] = []
304
+ for node in graph:
305
+ if node.depends_on:
306
+ deps = ", ".join(node.depends_on)
307
+ lines.append("%s -> %s" % (deps, node.id))
308
+ else:
309
+ lines.append("%s (no dependencies)" % node.id)
310
+ return "\n".join(lines)
311
+
312
+
313
+ def _graph_dot(graph: TaskGraph, name: str) -> str:
314
+ lines = ["digraph %s {" % name]
315
+ for node in graph:
316
+ lines.append(' "%s";' % node.id)
317
+ for dep in node.depends_on:
318
+ lines.append(' "%s" -> "%s";' % (dep, node.id))
319
+ lines.append("}")
320
+ return "\n".join(lines)
321
+
322
+
323
+ def cmd_graph(args: argparse.Namespace) -> int:
324
+ config = _load(args)
325
+ graph = _prepare_graph(config)
326
+ if args.format == "dot":
327
+ print(_graph_dot(graph, config.project.name.replace("-", "_")))
328
+ elif args.format == "json":
329
+ payload = {
330
+ "tasks": {node.id: {"depends_on": list(node.depends_on)} for node in graph}
331
+ }
332
+ print(json.dumps(payload, indent=2))
333
+ else:
334
+ print(_graph_text(graph))
335
+ return EXIT_OK
336
+
337
+
338
+ def cmd_plan(args: argparse.Namespace) -> int:
339
+ config = _load(args)
340
+ graph = _prepare_graph(config)
341
+ selected = resolve_targets(graph, args.tasks)
342
+ plan = build_plan(graph, selected)
343
+ if args.json:
344
+ payload = {
345
+ "project": config.project.name,
346
+ "order": list(plan.order),
347
+ "levels": [list(level) for level in plan.levels],
348
+ "max_parallelism": plan.max_parallelism,
349
+ "tasks": [
350
+ {
351
+ "id": t.id,
352
+ "dependencies": list(t.dependencies),
353
+ "level": t.level,
354
+ "cache_eligible": t.cache_eligible,
355
+ }
356
+ for t in plan.tasks
357
+ ],
358
+ }
359
+ print(json.dumps(payload, indent=2))
360
+ return EXIT_OK
361
+ print(
362
+ "Plan for %d task(s), max parallelism %d:"
363
+ % (len(plan.order), plan.max_parallelism)
364
+ )
365
+ for index, level in enumerate(plan.levels):
366
+ print(" level %d: %s" % (index, ", ".join(level)))
367
+ print("Order: %s" % " -> ".join(plan.order))
368
+ return EXIT_OK
369
+
370
+
371
+ def cmd_run(args: argparse.Namespace) -> int:
372
+ config = _load(args)
373
+ graph = _prepare_graph(config)
374
+ try:
375
+ selected = resolve_targets(graph, args.tasks)
376
+ except GraphError as exc:
377
+ _err(sys.stderr, str(exc))
378
+ return EXIT_GRAPH
379
+
380
+ execution = config.execution
381
+ if args.max_workers is not None:
382
+ if args.max_workers < 1:
383
+ _err(sys.stderr, "--max-workers must be >= 1")
384
+ return EXIT_USAGE
385
+ execution = type(execution)(
386
+ max_workers=args.max_workers,
387
+ default_timeout_seconds=execution.default_timeout_seconds,
388
+ fail_fast=execution.fail_fast,
389
+ )
390
+ if args.fail_fast is not None:
391
+ execution = type(execution)(
392
+ max_workers=execution.max_workers,
393
+ default_timeout_seconds=execution.default_timeout_seconds,
394
+ fail_fast=args.fail_fast,
395
+ )
396
+
397
+ run_id = uuid.uuid4().hex[:12]
398
+ events = EventBus()
399
+ cancel_token = CancellationToken()
400
+
401
+ use_cache = config.cache.enabled and not args.no_cache
402
+ cache_store = build_store(config) if config.cache.enabled else None
403
+
404
+ artifacts = ArtifactWriter(config.project.root, run_id)
405
+ structured = StructuredLogger(config.project.root, run_id)
406
+ reporter = PlainReporter(
407
+ sys.stdout,
408
+ color=_color_enabled(args, sys.stdout),
409
+ quiet=args.quiet or args.json,
410
+ show_output=not args.no_output,
411
+ )
412
+ events.subscribe(artifacts.handle)
413
+ events.subscribe(structured.handle)
414
+ use_tui = args.tui and not args.json
415
+ dashboard = None
416
+ if use_tui:
417
+ dashboard = LiveDashboard(sys.stdout, color=_color_enabled(args, sys.stdout))
418
+ events.subscribe(dashboard.handle)
419
+ elif not args.json:
420
+ events.subscribe(reporter.handle)
421
+
422
+ executor = TaskExecutor(
423
+ root=config.project.root,
424
+ default_timeout=execution.default_timeout_seconds,
425
+ events=events,
426
+ cancel_token=cancel_token,
427
+ cache=cache_store,
428
+ )
429
+ scheduler = Scheduler(
430
+ graph=graph,
431
+ execution=execution,
432
+ executor=executor,
433
+ events=events,
434
+ cancel_token=cancel_token,
435
+ use_cache=use_cache,
436
+ )
437
+
438
+ result: Optional[RunResult] = None
439
+ try:
440
+ if dashboard is not None:
441
+ dashboard.start()
442
+ with install_cancel_handlers(cancel_token.cancel):
443
+ result = scheduler.run(selected, run_id=run_id)
444
+ except KeyboardInterrupt: # pragma: no cover
445
+ cancel_token.cancel("interrupted")
446
+ return EXIT_INTERRUPTED
447
+ finally:
448
+ if dashboard is not None:
449
+ dashboard.stop()
450
+ artifacts.close()
451
+ structured.close()
452
+
453
+ assert result is not None
454
+
455
+ _record_history(config, result)
456
+ _send_notifications(config, result)
457
+
458
+ if args.json:
459
+ print(json.dumps(result.summary(), indent=2))
460
+ else:
461
+ log_path = artifacts.path_for(next(iter(sorted(selected))))
462
+ if log_path is not None and not args.quiet:
463
+ print("Logs: %s" % log_path.parent)
464
+
465
+ if result.cancelled and not result.succeeded:
466
+ return EXIT_INTERRUPTED
467
+ return EXIT_OK if result.succeeded else EXIT_TASK_FAILURE
468
+
469
+
470
+ # ---------------------------------------------------------------------------
471
+ # Cache and explain commands
472
+ # ---------------------------------------------------------------------------
473
+
474
+
475
+ def cmd_cache_status(args: argparse.Namespace) -> int:
476
+ config = _load(args)
477
+ store = build_store(config)
478
+ status = store.status()
479
+ if args.json:
480
+ print(json.dumps(status, indent=2))
481
+ else:
482
+ print("Cache location: %s" % status["location"])
483
+ print("Entries: %d" % status["entries"])
484
+ print("Size: %.2f KiB" % (status["size_bytes"] / 1024.0))
485
+ if status["corrupted"]:
486
+ print("Corrupted entries: %d" % status["corrupted"])
487
+ return EXIT_OK
488
+
489
+
490
+ def cmd_cache_clean(args: argparse.Namespace) -> int:
491
+ config = _load(args)
492
+ store = build_store(config)
493
+ if not args.force:
494
+ try:
495
+ interactive = sys.stdin.isatty()
496
+ except (ValueError, AttributeError):
497
+ interactive = False
498
+ if not interactive:
499
+ _err(
500
+ sys.stderr,
501
+ "refusing to clean without --force in a non-interactive session",
502
+ )
503
+ return EXIT_USAGE
504
+ answer = input("Remove all cached data under %s? [y/N] " % store.location)
505
+ if answer.strip().lower() not in ("y", "yes"):
506
+ print("Aborted.")
507
+ return EXIT_OK
508
+ result = store.clean()
509
+ if args.json:
510
+ print(json.dumps(result, indent=2))
511
+ else:
512
+ print("Removed %.2f KiB" % (result["removed_bytes"] / 1024.0))
513
+ return EXIT_OK
514
+
515
+
516
+ def cmd_explain(args: argparse.Namespace) -> int:
517
+ config = _load(args)
518
+ graph = _prepare_graph(config)
519
+ if args.task not in graph.nodes:
520
+ _err(sys.stderr, "unknown task: %s" % args.task)
521
+ return EXIT_GRAPH
522
+ node = graph.get(args.task)
523
+ task = node.config
524
+ decision = decide(task, global_enabled=config.cache.enabled, cli_disabled=False)
525
+ info: dict = {
526
+ "task": task.id,
527
+ "description": task.description,
528
+ "command": list(task.command),
529
+ "shell": task.shell,
530
+ "depends_on": list(node.depends_on),
531
+ "dependents": list(node.dependents),
532
+ "inputs": list(task.inputs),
533
+ "outputs": list(task.outputs),
534
+ "timeout_seconds": task.timeout_seconds,
535
+ "retry": {
536
+ "max_attempts": task.retry.max_attempts,
537
+ "delay_seconds": task.retry.delay_seconds,
538
+ "backoff": task.retry.backoff,
539
+ },
540
+ "cache": {"use_cache": decision.use_cache, "reason": decision.reason},
541
+ }
542
+ if decision.use_cache:
543
+ store = build_store(config)
544
+ dependency_keys = {dep: None for dep in node.depends_on}
545
+ detail: dict = store.explain(task, dependency_keys)
546
+ detail["note"] = "dependency cache keys assumed absent; a real run may differ"
547
+ info["cache_detail"] = detail
548
+ if args.json:
549
+ print(json.dumps(info, indent=2))
550
+ return EXIT_OK
551
+
552
+ print("Task: %s" % task.id)
553
+ if task.description:
554
+ print(" %s" % task.description)
555
+ print(" command: %s" % " ".join(task.command))
556
+ print(" depends on: %s" % (", ".join(node.depends_on) or "(none)"))
557
+ print(" inputs: %s" % (", ".join(task.inputs) or "(none)"))
558
+ print(" outputs: %s" % (", ".join(task.outputs) or "(none)"))
559
+ print(" cache: %s (%s)" % (decision.use_cache, decision.reason))
560
+ if decision.use_cache and "cache_detail" in info:
561
+ cache_detail: dict = info["cache_detail"]
562
+ print(
563
+ " cache key: %s"
564
+ % cache_detail.get("key", cache_detail.get("reason", "n/a"))
565
+ )
566
+ print(" cache hit: %s" % cache_detail.get("hit", False))
567
+ return EXIT_OK
568
+
569
+
570
+ # ---------------------------------------------------------------------------
571
+ # Persistence and notifications (best-effort, never change run outcome)
572
+ # ---------------------------------------------------------------------------
573
+
574
+
575
+ def _record_history(config: WorkflowConfig, result: RunResult) -> None:
576
+ if not config.history.enabled:
577
+ return
578
+ store = HistoryStore(config.project.root, max_runs=config.history.max_runs)
579
+ try:
580
+ store.record_run(
581
+ result,
582
+ project=config.project.name,
583
+ config_version=config.project.version,
584
+ )
585
+ except HistoryError as exc:
586
+ # Persistence must never alter the run's exit code or success state.
587
+ _err(sys.stderr, "warning: could not record history: %s" % exc)
588
+ finally:
589
+ store.close()
590
+
591
+
592
+ def _failed_tasks(result: RunResult) -> list:
593
+ failed = []
594
+ for task_id, record in sorted(result.records.items()):
595
+ if record.status.is_failure:
596
+ failed.append(
597
+ {
598
+ "task": task_id,
599
+ "status": record.status.value,
600
+ "exit_code": record.exit_code,
601
+ }
602
+ )
603
+ return failed
604
+
605
+
606
+ def _send_notifications(config: WorkflowConfig, result: RunResult) -> None:
607
+ settings = config.notifications
608
+ if not settings.enabled:
609
+ return
610
+ event = "success" if result.succeeded else "failure"
611
+ notifier = WebhookNotifier(
612
+ settings.url,
613
+ timeout_seconds=settings.timeout_seconds,
614
+ max_attempts=settings.max_attempts,
615
+ events=settings.on,
616
+ )
617
+ if not notifier.wants(event):
618
+ return
619
+ notification = RunNotification(
620
+ event=event,
621
+ run_id=result.run_id,
622
+ project=config.project.name,
623
+ succeeded=result.succeeded,
624
+ cancelled=result.cancelled,
625
+ duration_seconds=result.duration_seconds,
626
+ counts=result.counts,
627
+ failed_tasks=_failed_tasks(result),
628
+ )
629
+ try:
630
+ delivered = notifier.send(notification)
631
+ except Exception as exc: # defensive: never break the run
632
+ _err(sys.stderr, "warning: notification failed: %s" % type(exc).__name__)
633
+ return
634
+ if not delivered:
635
+ _err(sys.stderr, "warning: notification was not delivered")
636
+
637
+
638
+ def cmd_history(args: argparse.Namespace) -> int:
639
+ try:
640
+ config = _load(args)
641
+ root = config.project.root
642
+ max_runs = config.history.max_runs
643
+ except ConfigError:
644
+ root = Path.cwd()
645
+ max_runs = 500
646
+ store = HistoryStore(root, max_runs=max_runs)
647
+ try:
648
+ runs = store.recent(limit=args.limit)
649
+ finally:
650
+ store.close()
651
+ if args.json:
652
+ print(json.dumps(runs, indent=2))
653
+ return EXIT_OK
654
+ if not runs:
655
+ print("No execution history recorded.")
656
+ return EXIT_OK
657
+ for run in runs:
658
+ status = "ok" if run["succeeded"] else "failed"
659
+ print(
660
+ "%s %-6s %6.2fs %s"
661
+ % (run["run_id"], status, run["duration_seconds"], run["project"])
662
+ )
663
+ return EXIT_OK
664
+
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # Entry point
668
+ # ---------------------------------------------------------------------------
669
+
670
+
671
+ def main(argv: Optional[Sequence[str]] = None) -> int:
672
+ parser = build_parser()
673
+ args = parser.parse_args(argv)
674
+ try:
675
+ return int(args.func(args))
676
+ except ConfigError as exc:
677
+ _err(sys.stderr, str(exc))
678
+ return EXIT_USAGE
679
+ except GraphError as exc:
680
+ _err(sys.stderr, str(exc))
681
+ return EXIT_GRAPH
682
+ except PyTaskWireCheckError as exc:
683
+ _err(sys.stderr, str(exc))
684
+ return EXIT_USAGE
685
+ except BrokenPipeError: # pragma: no cover
686
+ return EXIT_OK
687
+
688
+
689
+ if __name__ == "__main__": # pragma: no cover
690
+ raise SystemExit(main())
File without changes