sagex-cli 1.0.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.
sagex/render.py ADDED
@@ -0,0 +1,1046 @@
1
+ """Terminal rendering for the `sagex show` commands (Rich console output).
2
+
3
+ This is the CLI-side presentation layer: it takes the plain data returned by
4
+ `sagex.api.resources` and prints it nicely to stdout. The TUI will render the
5
+ same data as widgets later; only this file is CLI-specific.
6
+ """
7
+
8
+ import difflib
9
+ import sys
10
+ import threading
11
+ import time
12
+ from contextlib import contextmanager
13
+ from datetime import datetime, timezone
14
+
15
+ from rich.cells import cell_len
16
+ from rich.console import Console, Group
17
+ from rich.live import Live
18
+ from rich.panel import Panel
19
+ from rich.syntax import Syntax
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from sagex.formatting import STATUS_ICON, RunActivity, relative_time
24
+
25
+ console = Console()
26
+
27
+ # Live-run chrome (header, script stderr, notes, summary) goes to stderr so that
28
+ # `sagex run ... > out.txt` captures only the script's own stdout.
29
+ err_console = Console(stderr=True, highlight=False)
30
+
31
+
32
+ def note(text: str) -> None:
33
+ """A dim one-line note (e.g. logs unavailable / hint)."""
34
+ console.print(Text(text, style="bright_black"))
35
+
36
+
37
+ # --- live runs (`sagex run`) -----------------------------------------------
38
+
39
+ def run_header(script_name: str, server_name: str, details: list[str],
40
+ inputs: dict, secret_names: set) -> None:
41
+ """'▶ deploy.sh on web-1 · host · vault · key' plus the inputs (secrets masked)."""
42
+ line = Text()
43
+ line.append("▶ ", style="cyan")
44
+ line.append(script_name, style="bold")
45
+ line.append(" on ")
46
+ line.append(server_name, style="bold")
47
+ for detail in details:
48
+ if detail:
49
+ line.append(f" · {detail}", style="bright_black")
50
+ err_console.print(line, soft_wrap=True)
51
+ if inputs:
52
+ shown = Text(" ")
53
+ for i, (name, value) in enumerate(inputs.items()):
54
+ if i:
55
+ shown.append(" ")
56
+ shown.append(f"{name}=", style="bright_black")
57
+ shown.append("*****" if name in secret_names else (value or '""'))
58
+ err_console.print(shown, soft_wrap=True)
59
+
60
+
61
+ def workflow_run_header(name: str, node_count: int, inputs: dict, via: str | None = None) -> None:
62
+ """'▶ Deploy Production · 4 nodes' (+ how it was started) and the parameter values
63
+ (secrets pre-masked)."""
64
+ line = Text()
65
+ line.append("▶ ", style="cyan")
66
+ line.append(name, style="bold")
67
+ line.append(f" · {node_count} node{'s' if node_count != 1 else ''}", style="bright_black")
68
+ if via:
69
+ line.append(f" · {via}", style="bright_black")
70
+ err_console.print(line, soft_wrap=True)
71
+ if inputs:
72
+ shown = Text(" ")
73
+ for i, (label, value) in enumerate(inputs.items()):
74
+ if i:
75
+ shown.append(" ")
76
+ shown.append(f"{label}=", style="bright_black")
77
+ shown.append(value or '""')
78
+ err_console.print(shown, soft_wrap=True)
79
+
80
+
81
+ def workflow_run_graph(view) -> None:
82
+ """Print a run's final graph (its bordered panel) once, after a blank line."""
83
+ err_console.print()
84
+ err_console.print(view)
85
+
86
+
87
+ def workflow_node_start(label: str, kind: str) -> None:
88
+ """'› Check service · script' — printed when a node begins, after a blank line
89
+ so each node's output reads as its own section."""
90
+ err_console.print()
91
+ line = Text()
92
+ line.append("› ", style="cyan")
93
+ line.append(label, style="bold")
94
+ if kind:
95
+ line.append(f" · {kind}", style="bright_black")
96
+ err_console.print(line, soft_wrap=True)
97
+
98
+
99
+ def workflow_node_done(label: str, status: str | None, exit_code=None, duration=None,
100
+ branch: str | None = None) -> None:
101
+ """'✓ Check service · exit 0 · 2.3s' — printed when a node finishes."""
102
+ icon, color = STATUS_ICON.get(status or "", ("•", "white"))
103
+ parts = []
104
+ if status and status not in ("success", "failed"):
105
+ parts.append(status)
106
+ if branch:
107
+ parts.append(f"{branch} branch")
108
+ if exit_code is not None and exit_code != -1:
109
+ parts.append(f"exit {exit_code}")
110
+ if duration and status != "skipped":
111
+ parts.append(_elapsed(float(duration)))
112
+ line = Text()
113
+ line.append(f"{icon} ", style=color)
114
+ line.append(label, style=color if status == "failed" else "")
115
+ if parts:
116
+ line.append(" · " + " · ".join(parts), style="bright_black")
117
+ err_console.print(line, soft_wrap=True)
118
+
119
+
120
+ def run_stdout(text: str) -> None:
121
+ """A line of the script's stdout, written raw (no styling) so it pipes cleanly.
122
+
123
+ While the activity line is showing on the same terminal, it goes through the
124
+ live console instead, which prints it above the activity line.
125
+ """
126
+ if _stdout_through_live:
127
+ err_console.print(Text.from_ansi(text), soft_wrap=True)
128
+ else:
129
+ print(text, flush=True)
130
+
131
+
132
+ _stdout_through_live = False
133
+ # One live display at a time: an explicit run_activity() suppresses the per-request
134
+ # waiting() line (e.g. the parallel lookups that `run script` makes under its own).
135
+ _live_lock = threading.Lock()
136
+ _explicit_active = False
137
+ _wait_depth = 0
138
+ _wait_live: Live | None = None
139
+ _wait_activity: RunActivity | None = None
140
+ _wait_ended = 0.0
141
+
142
+ _WAIT_VERBS = {"GET": "Loading", "DELETE": "Deleting"}
143
+
144
+
145
+ def _activity_live(renderable) -> Live:
146
+ # Live's default stdout redirect would push piped output into stderr; output is
147
+ # routed explicitly instead (see run_stdout()).
148
+ return Live(renderable, console=err_console, refresh_per_second=12, transient=True,
149
+ redirect_stdout=False, redirect_stderr=False)
150
+
151
+
152
+ class _Pinned:
153
+ """A block (e.g. the live workflow graph) pinned above the activity line."""
154
+
155
+ def __init__(self, above, activity: RunActivity) -> None:
156
+ self.above, self.activity = above, activity
157
+
158
+ def __rich__(self):
159
+ # A blank line sets the pinned block off from the log scrolling above it.
160
+ return Group(Text(""), self.above, self.activity)
161
+
162
+
163
+ @contextmanager
164
+ def run_activity(activity: RunActivity, above=None):
165
+ """Show `activity` on stderr while the block runs (no-op when stderr isn't a
166
+ terminal), with `above` (re-rendered every frame) pinned over it. Output printed
167
+ meanwhile scrolls above the live region, which is cleared on exit."""
168
+ global _stdout_through_live, _explicit_active
169
+ if not err_console.is_terminal:
170
+ yield
171
+ return
172
+ with _live_lock:
173
+ _explicit_active = True
174
+ _stdout_through_live = sys.stdout.isatty()
175
+ try:
176
+ with _activity_live(_Pinned(above, activity) if above is not None else activity):
177
+ yield
178
+ finally:
179
+ _stdout_through_live = False
180
+ with _live_lock:
181
+ _explicit_active = False
182
+
183
+
184
+ @contextmanager
185
+ def waiting(method: str):
186
+ """Show the activity line while one API request is in flight.
187
+
188
+ Installed as the API client's wait indicator for CLI subcommands. The line is
189
+ cleared as soon as the request returns, so it never overlaps the command's own
190
+ output or prompts. Back-to-back requests keep one running timer; a pause longer
191
+ than a second (e.g. a confirmation prompt) starts a fresh one.
192
+ """
193
+ global _wait_depth, _wait_live, _wait_activity, _wait_ended
194
+ with _live_lock:
195
+ if not _explicit_active and err_console.is_terminal:
196
+ if _wait_depth == 0:
197
+ verb = _WAIT_VERBS.get(method.upper(), "Saving")
198
+ if _wait_activity is None or time.monotonic() - _wait_ended > 1.0:
199
+ _wait_activity = RunActivity(verb, "")
200
+ else:
201
+ _wait_activity.set(verb)
202
+ _wait_live = _activity_live(_wait_activity)
203
+ _wait_live.start()
204
+ _wait_depth += 1
205
+ counted = True
206
+ else:
207
+ counted = False
208
+ try:
209
+ yield
210
+ finally:
211
+ if counted:
212
+ with _live_lock:
213
+ _wait_depth -= 1
214
+ if _wait_depth == 0 and _wait_live is not None:
215
+ _wait_live.stop()
216
+ _wait_live = None
217
+ _wait_ended = time.monotonic()
218
+
219
+
220
+ def run_stderr(text: str) -> None:
221
+ """A line of the script's stderr (or a run error), in red on stderr."""
222
+ err_console.print(Text(text, style="red"), soft_wrap=True)
223
+
224
+
225
+ def run_note(text: str) -> None:
226
+ """A dim status line on stderr (worker log lines, stop/detach notices)."""
227
+ err_console.print(Text(text, style="bright_black"), soft_wrap=True)
228
+
229
+
230
+ def _elapsed(seconds: float) -> str:
231
+ if seconds < 60:
232
+ return f"{seconds:.1f}s"
233
+ return f"{int(seconds // 60)}m {int(seconds % 60)}s"
234
+
235
+
236
+ def run_summary(status: str | None, exit_code, seconds: float, execution_id: str | None,
237
+ noun: str = "execution") -> None:
238
+ """'✓ completed · exit 0 · 3.4s · execution <id>' (noun 'run' for workflows)."""
239
+ icon, color = STATUS_ICON.get(status or "", ("•", "white"))
240
+ parts = []
241
+ if exit_code is not None and exit_code != -1:
242
+ parts.append(f"exit {exit_code}")
243
+ parts.append(_elapsed(seconds))
244
+ if execution_id:
245
+ parts.append(f"{noun} {execution_id}")
246
+ line = Text()
247
+ line.append(f"{icon} {status or 'unknown'}", style=f"bold {color}")
248
+ line.append(" · " + " · ".join(parts), style="bright_black")
249
+ err_console.print(line, soft_wrap=True)
250
+
251
+
252
+ # --- shared helpers --------------------------------------------------------
253
+
254
+ def print_json(data) -> None:
255
+ """Dump a record as pretty JSON (for the --json flag / piping)."""
256
+ console.print_json(data=data)
257
+
258
+
259
+ def ambiguous(exc) -> None:
260
+ """Print the matches for an ambiguous name and how to disambiguate."""
261
+ console.print(
262
+ f"[yellow]Multiple {exc.kind}s match [bold]{exc.ref}[/bold]. "
263
+ f"Re-run with an id:[/yellow]"
264
+ )
265
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
266
+ table.add_column("id", style="cyan", no_wrap=True)
267
+ table.add_column("name")
268
+ table.add_column("", style="bright_black") # hint column
269
+ for m in exc.matches:
270
+ table.add_row(str(m.get("id")), m.get("name") or "(unnamed)", m.get("hint") or "")
271
+ console.print(table)
272
+
273
+
274
+ def _kv(label: str, value) -> Text:
275
+ """One 'Label: value' line with a dim label."""
276
+ line = Text()
277
+ line.append(f"{label}: ", style="bright_black")
278
+ line.append("" if value is None else str(value))
279
+ return line
280
+
281
+
282
+ # --- workflow --------------------------------------------------------------
283
+
284
+ def workflow(wf: dict) -> None:
285
+ """Show a workflow: header panel + a table of its nodes."""
286
+ nodes = wf.get("nodes") or []
287
+ edges = wf.get("edges") or []
288
+
289
+ header = Text()
290
+ header.append(_kv("id", wf.get("id"))); header.append("\n")
291
+ header.append(_kv("description", wf.get("description") or "—")); header.append("\n")
292
+ header.append(_kv("nodes", f"{len(nodes)} edges: {len(edges)}")); header.append("\n")
293
+ header.append(_kv("created", wf.get("created_at"))); header.append("\n")
294
+ header.append(_kv("modified", wf.get("modified_at")))
295
+ console.print(Panel(header, title=wf.get("name") or "(unnamed workflow)", title_align="left"))
296
+
297
+ if nodes:
298
+ table = Table(title="Nodes", show_header=True, header_style="bold", title_justify="left")
299
+ table.add_column("id", style="cyan", no_wrap=True)
300
+ table.add_column("type")
301
+ table.add_column("label")
302
+ for n in nodes:
303
+ data = n.get("data") if isinstance(n.get("data"), dict) else {}
304
+ label = data.get("label") or n.get("label") or ""
305
+ table.add_row(str(n.get("id") or ""), str(n.get("type") or ""), str(label))
306
+ console.print(table)
307
+
308
+
309
+ # --- workflow graph (text visualization) -----------------------------------
310
+
311
+ # icon + colour + short kind label, keyed off node type (and action's data.type).
312
+ def _node_style(node: dict) -> tuple[str, str, str]:
313
+ ntype = node.get("type")
314
+ data = node.get("data") if isinstance(node.get("data"), dict) else {}
315
+ dtype = data.get("type")
316
+ if ntype == "trigger":
317
+ return "▸", "cyan", f"trigger:{dtype or 'manual'}"
318
+ if ntype == "decision":
319
+ return "◆", "yellow", "decision"
320
+ if ntype == "action":
321
+ if dtype == "email":
322
+ return "■", "blue", "action:email"
323
+ if dtype == "script":
324
+ return "●", "green", "action:script"
325
+ return "•", "white", f"action:{dtype or '?'}"
326
+ return "•", "white", str(ntype or "?")
327
+
328
+
329
+ def _node_box(node: dict | None, node_id) -> tuple[list[Text], int]:
330
+ """Draw a node as a rounded rectangle. Returns (lines, box_width_in_cells).
331
+
332
+ The icon glyphs (⚡⚙✉◆) are counted as one cell each — Rich's cell_len calls
333
+ some of them width-2 (emoji), but terminals render them width-1, so we measure
334
+ the head line as icon(1) + space + label to keep the right border aligned.
335
+ """
336
+ if node is None:
337
+ icon, color, kind = "•", "red", "missing node"
338
+ label = str(node_id)
339
+ else:
340
+ icon, color, kind = _node_style(node)
341
+ data = node.get("data") if isinstance(node.get("data"), dict) else {}
342
+ label = str(data.get("label") or node.get("label") or node_id)
343
+ if len(label) > 42:
344
+ label = label[:41] + "…"
345
+
346
+ meta = f"{kind} {node_id}" if str(node_id) != label else kind
347
+ head_w = 2 + cell_len(label) # icon(1) + space(1) + label
348
+ inner = max(head_w, cell_len(meta))
349
+
350
+ def _row(prefix_runs, width, close=True):
351
+ row = Text("│ ", style=color)
352
+ for text, style in prefix_runs:
353
+ row.append(text, style=style)
354
+ row.append(" " * (inner - width) + " ", style=color)
355
+ row.append("│", style=color)
356
+ return row
357
+
358
+ return (
359
+ [
360
+ Text("╭" + "─" * (inner + 2) + "╮", style=color),
361
+ _row([(f"{icon} ", color), (label, color)], head_w),
362
+ _row([(meta, "bright_black")], cell_len(meta)),
363
+ Text("╰" + "─" * (inner + 2) + "╯", style=color),
364
+ ],
365
+ inner + 4,
366
+ )
367
+
368
+
369
+ def _prefix(lines: list[Text], first: str, rest: str, style: str = "bright_black") -> list[Text]:
370
+ """Left-attach a connector to a block: `first` on line 0, `rest` on the others."""
371
+ out = []
372
+ for i, line in enumerate(lines):
373
+ row = Text(first if i == 0 else rest, style=style)
374
+ row.append(line)
375
+ out.append(row)
376
+ return out
377
+
378
+
379
+ def workflow_graph(wf: dict) -> None:
380
+ """Render a workflow as boxed nodes joined by flow edges.
381
+
382
+ Linear steps stack vertically under a centered spine (│▼); a decision fans out
383
+ to labelled elbow connectors (├─ true ▶ / └─ false ▶). A node reached by two
384
+ paths is drawn once and shown later as a back-reference (↑), keeping it finite.
385
+ """
386
+ nodes = wf.get("nodes") or []
387
+ edges = wf.get("edges") or []
388
+ if not nodes:
389
+ note("(workflow has no nodes)")
390
+ return
391
+
392
+ by_id = {n.get("id"): n for n in nodes}
393
+ children: dict = {}
394
+ indeg = {n.get("id"): 0 for n in nodes}
395
+ for e in edges:
396
+ children.setdefault(e.get("source"), []).append(e)
397
+ if e.get("target") in indeg:
398
+ indeg[e.get("target")] += 1
399
+
400
+ roots = [n.get("id") for n in nodes if n.get("type") == "trigger"]
401
+ if not roots:
402
+ roots = [nid for nid, d in indeg.items() if d == 0] or [nodes[0].get("id")]
403
+
404
+ visited: set = set()
405
+
406
+ def block(node_id) -> list[Text]:
407
+ """The subtree rooted at node_id, box top-left anchored at column 0."""
408
+ lines, width = _node_box(by_id.get(node_id), node_id)
409
+ visited.add(node_id)
410
+ kids = children.get(node_id, [])
411
+
412
+ if len(kids) == 1 and (by_id.get(node_id) or {}).get("type") != "decision":
413
+ spine = width // 2
414
+ lines.append(Text(" " * spine + "│", style="bright_black"))
415
+ lines.append(Text(" " * spine + "▼", style="bright_black"))
416
+ lines += _child(kids[0])
417
+ return lines
418
+
419
+ for i, e in enumerate(kids):
420
+ last = i == len(kids) - 1
421
+ handle = e.get("sourceHandle")
422
+ tag = f"{handle} " if handle else ""
423
+ first = ("└─ " if last else "├─ ") + f"{tag}▶ "
424
+ rest = (" " if last else "│") + " " * (len(first) - 1)
425
+ lines += _prefix(_child(e), first, rest)
426
+ return lines
427
+
428
+ def _child(edge) -> list[Text]:
429
+ target = edge.get("target")
430
+ if target in visited:
431
+ label = (by_id.get(target) or {}).get("data", {})
432
+ name = label.get("label") if isinstance(label, dict) else None
433
+ return [Text(f"↑ {name or target} (shown above)", style="bright_black")]
434
+ return block(target)
435
+
436
+ title = Text(wf.get("name") or "(workflow)", style="bold")
437
+ title.append(f" {len(nodes)} nodes · {len(edges)} edges", style="bright_black")
438
+ console.print(title)
439
+
440
+ for r in roots:
441
+ for line in block(r):
442
+ console.print(line)
443
+
444
+ leftover = [n.get("id") for n in nodes if n.get("id") not in visited]
445
+ if leftover:
446
+ console.print(Text("(not reachable from a trigger)", style="bright_black"))
447
+ for nid in leftover:
448
+ if nid not in visited:
449
+ for line in block(nid):
450
+ console.print(line)
451
+
452
+ console.print(Text("▸ trigger ● script ■ email ◆ decision", style="bright_black"))
453
+
454
+
455
+ # --- script ----------------------------------------------------------------
456
+
457
+ _EXT_LEXER = {
458
+ "py": "python", "js": "javascript", "ts": "typescript", "sh": "bash",
459
+ "bash": "bash", "ps1": "powershell", "rb": "ruby", "go": "go",
460
+ "json": "json", "yml": "yaml", "yaml": "yaml", "sql": "sql",
461
+ }
462
+
463
+
464
+ def _lexer_for(name: str, content_type: str) -> str:
465
+ """Best-effort syntax lexer from the file extension, then the MIME type."""
466
+ if name and "." in name:
467
+ ext = name.rsplit(".", 1)[-1].lower()
468
+ if ext in _EXT_LEXER:
469
+ return _EXT_LEXER[ext]
470
+ ct = (content_type or "").lower()
471
+ if "python" in ct:
472
+ return "python"
473
+ if "javascript" in ct:
474
+ return "javascript"
475
+ if "powershell" in ct:
476
+ return "powershell"
477
+ if "sh" in ct or "bash" in ct:
478
+ return "bash"
479
+ return "text"
480
+
481
+
482
+ def script(meta: dict, content: dict | None) -> None:
483
+ """Show a script: metadata panel + syntax-highlighted code (if fetched)."""
484
+ header = Text()
485
+ header.append(_kv("id", meta.get("id"))); header.append("\n")
486
+ header.append(_kv("pathname", meta.get("pathname") or "—")); header.append("\n")
487
+ header.append(_kv("type", meta.get("content_type"))); header.append("\n")
488
+ header.append(_kv("size", f"{meta.get('file_size')} bytes · v{meta.get('version')}")); header.append("\n")
489
+ header.append(_kv("updated", meta.get("updated_at")))
490
+ console.print(Panel(header, title=meta.get("name") or "(unnamed script)", title_align="left"))
491
+
492
+ if content is not None:
493
+ code = content.get("content") or ""
494
+ lexer = _lexer_for(content.get("name") or meta.get("name") or "",
495
+ content.get("content_type") or meta.get("content_type") or "")
496
+ console.print(Syntax(code, lexer, theme="ansi_dark", line_numbers=True, word_wrap=True))
497
+
498
+
499
+ def script_diff(name: str, server_code: str, local_code: str) -> bool:
500
+ """Print a unified diff of server→local script code. Returns True if they differ.
501
+
502
+ `-` lines are the current server version (about to be overwritten), `+` lines are
503
+ the local file. Used by `push script` to preview an update before it bumps the
504
+ version. Line-based; a difference in trailing newline alone is treated as no change.
505
+ """
506
+ diff = list(difflib.unified_diff(
507
+ server_code.splitlines(), local_code.splitlines(),
508
+ fromfile=f"server:{name}", tofile=f"local:{name}", lineterm="",
509
+ ))
510
+ if not diff:
511
+ return False
512
+ for line in diff:
513
+ if line.startswith(("+++", "---")):
514
+ console.print(Text(line, style="bold"))
515
+ elif line.startswith("@@"):
516
+ console.print(Text(line, style="cyan"))
517
+ elif line.startswith("+"):
518
+ console.print(Text(line, style="green"))
519
+ elif line.startswith("-"):
520
+ console.print(Text(line, style="red"))
521
+ else:
522
+ console.print(Text(line, style="bright_black"))
523
+ return True
524
+
525
+
526
+ # --- run -------------------------------------------------------------------
527
+
528
+ def _duration(started, finished) -> str | None:
529
+ """Human 'finished - started' duration, or None if not computable."""
530
+ if not started or not finished:
531
+ return None
532
+ try:
533
+ secs = (datetime.fromisoformat(finished) - datetime.fromisoformat(started)).total_seconds()
534
+ except ValueError:
535
+ return None
536
+ if secs < 0:
537
+ return None
538
+ if secs < 60:
539
+ return f"{secs:.1f}s"
540
+ minutes, sec = divmod(int(secs), 60)
541
+ if minutes < 60:
542
+ return f"{minutes}m {sec}s"
543
+ hours, minutes = divmod(minutes, 60)
544
+ return f"{hours}h {minutes}m"
545
+
546
+
547
+ def run(r: dict, nodes: list[dict]) -> None:
548
+ """Show a run: header panel + a per-node results table."""
549
+ status = r.get("status") or "unknown"
550
+ icon, color = STATUS_ICON.get(status, ("•", "white"))
551
+
552
+ header = Text()
553
+ header.append(_kv("id", r.get("id"))); header.append("\n")
554
+ st = Text(); st.append("status: ", style="bright_black"); st.append(f"{icon} {status}", style=color)
555
+ header.append(st); header.append("\n")
556
+ header.append(_kv("created", r.get("created_at"))); header.append("\n")
557
+ header.append(_kv("started", r.get("started_at") or "—")); header.append("\n")
558
+ header.append(_kv("finished", r.get("finished_at") or "—")); header.append("\n")
559
+ header.append(_kv("duration", _duration(r.get("started_at"), r.get("finished_at")) or "—"))
560
+ if r.get("inputs"):
561
+ header.append("\n"); header.append(_kv("inputs", r.get("inputs")))
562
+ if r.get("error_message"):
563
+ header.append("\n")
564
+ err = Text(); err.append("error: ", style="bright_black"); err.append(str(r.get("error_message")), style="red")
565
+ header.append(err)
566
+ console.print(Panel(header, title=r.get("workflow_name") or "(run)", title_align="left"))
567
+
568
+ if nodes:
569
+ ordered = sorted(nodes, key=lambda n: n.get("execution_order") or 0)
570
+ table = Table(title="Nodes", show_header=True, header_style="bold", title_justify="left")
571
+ table.add_column("#", style="bright_black", no_wrap=True)
572
+ table.add_column("node")
573
+ table.add_column("status")
574
+ table.add_column("exit", justify="right")
575
+ table.add_column("error")
576
+ for n in ordered:
577
+ nstatus = n.get("status") or ""
578
+ _, ncolor = STATUS_ICON.get(nstatus, ("•", "white"))
579
+ code = n.get("exit_code")
580
+ table.add_row(
581
+ str(n.get("execution_order") if n.get("execution_order") is not None else ""),
582
+ str(n.get("node_label") or n.get("node_id") or ""),
583
+ Text(nstatus, style=ncolor),
584
+ "" if code is None else str(code),
585
+ str(n.get("error_message") or ""),
586
+ )
587
+ console.print(table)
588
+
589
+
590
+ def run_logs(entries: list[tuple[str, str, str]]) -> None:
591
+ """Dump collected log bodies: entries are (node_label, stream, text)."""
592
+ if not entries:
593
+ note("(no log output)")
594
+ return
595
+ for label, stream, text in entries:
596
+ body = Text(text.rstrip() or "(empty)")
597
+ console.print(Panel(body, title=f"{label} · {stream}", title_align="left", border_style="bright_black"))
598
+
599
+
600
+ # --- trigger ---------------------------------------------------------------
601
+
602
+ def trigger(kind: str, t: dict) -> None:
603
+ """Show a trigger's detail. Only ever displays the last 4 of a secret."""
604
+ header = Text()
605
+ header.append(_kv("kind", kind)); header.append("\n")
606
+ header.append(_kv("node", t.get("node_id"))); header.append("\n")
607
+ st = Text(); st.append("status: ", style="bright_black")
608
+ if t.get("is_active"):
609
+ st.append("● enabled", style="green")
610
+ else:
611
+ st.append("○ disabled", style="bright_black")
612
+ header.append(st); header.append("\n")
613
+ header.append(_kv("created", t.get("created_at"))); header.append("\n")
614
+ header.append(_kv("last triggered", t.get("last_triggered_at") or "never"))
615
+
616
+ if kind == "http":
617
+ header.append("\n"); header.append(_kv("url", t.get("trigger_url")))
618
+ last4 = t.get("secret_last4")
619
+ header.append("\n"); header.append(_kv("secret", f"••••{last4}" if last4 else "—"))
620
+ if t.get("rotated_at"):
621
+ header.append("\n"); header.append(_kv("rotated", t.get("rotated_at")))
622
+ else:
623
+ header.append("\n"); header.append(_schedule_block(t))
624
+ header.append("\n"); header.append(_kv("timezone", t.get("timezone")))
625
+ if t.get("last_run_id"):
626
+ header.append("\n")
627
+ lr = Text(); lr.append("last run: ", style="bright_black")
628
+ status = t.get("last_run_status")
629
+ if status:
630
+ icon, color = STATUS_ICON.get(status, ("•", "white"))
631
+ lr.append(f"{icon} {status} ", style=color)
632
+ lr.append(str(t.get("last_run_id")))
633
+ header.append(lr)
634
+ if t.get("last_error"):
635
+ header.append("\n")
636
+ err = Text(); err.append("last error: ", style="bright_black"); err.append(str(t.get("last_error")), style="red")
637
+ header.append(err)
638
+
639
+ console.print(Panel(header, title=t.get("workflow_name") or "(trigger)", title_align="left"))
640
+
641
+
642
+ # --- trigger writes (create / regenerate / invoke) -------------------------
643
+
644
+ def _http_invocation(url: str, secret_display: str) -> None:
645
+ """Print a copy-paste 'how to call this HTTP trigger' cheat-sheet."""
646
+ body = '{"inputs": {}}'
647
+ block = Text()
648
+ block.append("POST ", style="bold"); block.append(str(url)); block.append("\n")
649
+ block.append("Headers:\n", style="bright_black")
650
+ block.append(" X-Trigger-Secret: ", style="bright_black"); block.append(secret_display); block.append("\n")
651
+ block.append(" Idempotency-Key: ", style="bright_black"); block.append("<unique per request — required>"); block.append("\n")
652
+ block.append(" Content-Type: ", style="bright_black"); block.append("application/json"); block.append("\n")
653
+ block.append("Body:\n", style="bright_black")
654
+ block.append(f" {body}")
655
+ console.print(Panel(block, title="Call this HTTP trigger", title_align="left", border_style="cyan"))
656
+
657
+ curl = (
658
+ f'curl -X POST "{url}" \\\n'
659
+ f' -H "X-Trigger-Secret: {secret_display}" \\\n'
660
+ f' -H "Idempotency-Key: $(uuidgen)" \\\n'
661
+ f' -H "Content-Type: application/json" \\\n'
662
+ f' -d \'{body}\''
663
+ )
664
+ # soft_wrap so long URLs aren't hard-broken mid-token — keeps the curl copy-pasteable.
665
+ console.print(Text(curl, style="bright_black"), soft_wrap=True)
666
+ note("Response 202 → { workflow_run_id, status, polling_url }")
667
+
668
+
669
+ def http_trigger_result(data: dict) -> None:
670
+ """After create/regenerate: show the plaintext secret ONCE + the invocation cheat-sheet."""
671
+ secret = data.get("secret")
672
+ console.print(Text("✓ HTTP trigger ready.", style="green"))
673
+ if secret:
674
+ console.print(Text("Secret — shown once, copy it now (it can't be retrieved again):", style="bold red"))
675
+ console.print(Text(f" {secret}", style="bold"))
676
+ _http_invocation(data.get("trigger_url"), secret or f"<your secret, ends ••••{data.get('secret_last4')}>")
677
+ note(f"Try it from here: sagex trigger fire {_quoted(data.get('workflow_name'))}")
678
+
679
+
680
+ def http_trigger_invocation(data: dict) -> None:
681
+ """Reprint how to call an existing HTTP trigger (secret not shown — only last4)."""
682
+ if not data.get("is_active", True):
683
+ note("This trigger is disabled — enable it before calling: sagex trigger enable …")
684
+ _http_invocation(data.get("trigger_url"), f"<your secret, ends ••••{data.get('secret_last4')}>")
685
+ note("The secret is stored hashed and can't be shown. Lost it? Run: sagex trigger regenerate …")
686
+ note(f"Try it from here: sagex trigger fire {_quoted(data.get('workflow_name'))}")
687
+
688
+
689
+ def _quoted(name: str | None) -> str:
690
+ return f'"{name}"' if name else "<workflow>"
691
+
692
+
693
+ def describe_cron(expr: str) -> str | None:
694
+ """Human-readable description of a cron expression (e.g. 'At 09:00, on day 1 …').
695
+
696
+ Autosage's scheduler (Celery Beat) needs BOTH day-of-month and day-of-week to
697
+ match when both are set — unlike classic cron's either/or, which is what
698
+ cron_descriptor assumes — so that case is worded explicitly. Best-effort:
699
+ returns None if the expression is empty or unparseable.
700
+ """
701
+ if not expr:
702
+ return None
703
+ try:
704
+ from cron_descriptor import ExpressionDescriptor, get_description
705
+
706
+ fields = expr.split()
707
+ if len(fields) == 5 and fields[2] not in ("*", "?") and fields[4] not in ("*", "?"):
708
+ d = ExpressionDescriptor(expr)
709
+ dow = d.get_day_of_week_description().lstrip(", ")
710
+ for prefix in ("or on ", "only on "):
711
+ dow = dow.removeprefix(prefix)
712
+ return (f"{d.get_time_of_day_description()}{d.get_day_of_month_description()}"
713
+ f"{d.get_month_description()}, only when it falls on {dow}")
714
+ return get_description(expr)
715
+ except Exception:
716
+ return None
717
+
718
+
719
+ def time_until(seconds: float) -> str:
720
+ """'in 12m', 'in 8h 12m', 'in 3d 4h' (or 'now')."""
721
+ s = int(seconds)
722
+ if s < 60:
723
+ return "now" if s <= 0 else "in <1m"
724
+ if s < 3600:
725
+ return f"in {s // 60}m"
726
+ if s < 86400:
727
+ return f"in {s // 3600}h {s % 3600 // 60:02d}m"
728
+ return f"in {s // 86400}d {s % 86400 // 3600}h"
729
+
730
+
731
+ def schedule_time(iso: str) -> Text:
732
+ """'Mon 13 Oct 09:00 UTC · 14:30 local · in 8h 12m' for one upcoming run."""
733
+ try:
734
+ dt = datetime.fromisoformat(iso)
735
+ except (TypeError, ValueError):
736
+ return Text(str(iso))
737
+ if dt.tzinfo is None:
738
+ dt = dt.replace(tzinfo=timezone.utc)
739
+ utc, local = dt.astimezone(timezone.utc), dt.astimezone()
740
+ line = Text(utc.strftime("%a %d %b %H:%M UTC"))
741
+ if local.utcoffset() != utc.utcoffset():
742
+ fmt = "%H:%M" if local.date() == utc.date() else "%a %H:%M"
743
+ line.append(f" · {local.strftime(fmt)} local", style="bright_black")
744
+ line.append(f" · {time_until((utc - datetime.now(timezone.utc)).total_seconds())}", style="bright_black")
745
+ return line
746
+
747
+
748
+ def _schedule_block(data: dict) -> Text:
749
+ """cron / when / next runs — shared by the create result and `show trigger`."""
750
+ body = Text()
751
+ body.append(_kv("cron", data.get("cron_expression")))
752
+ desc = describe_cron(data.get("cron_expression"))
753
+ if desc:
754
+ body.append("\n"); body.append(_kv("when", f"{desc} (UTC)"))
755
+ upcoming = data.get("next_runs") or []
756
+ if upcoming:
757
+ body.append("\n"); body.append(Text("next runs:", style="bright_black"))
758
+ for iso in upcoming:
759
+ body.append("\n "); body.append(schedule_time(iso))
760
+ elif data.get("is_active") is False:
761
+ body.append("\n"); body.append(_kv("next runs", "none — the schedule is disabled"))
762
+ return body
763
+
764
+
765
+ def schedule_trigger_result(data: dict, workflow_name: str | None = None) -> None:
766
+ """Summary after creating/updating a schedule trigger."""
767
+ console.print(Text("✓ Schedule trigger ready.", style="green"))
768
+ body = Text()
769
+ body.append(_kv("node", data.get("node_id"))); body.append("\n")
770
+ body.append(_schedule_block(data)); body.append("\n")
771
+ active = data.get("is_active")
772
+ st = Text(); st.append("active: ", style="bright_black")
773
+ st.append("● yes", style="green") if active else st.append("○ no", style="bright_black")
774
+ body.append(st)
775
+ console.print(body)
776
+ note("Cron is 5 fields (min hour day-of-month month day-of-week), evaluated in UTC.")
777
+ name = _quoted(workflow_name)
778
+ note(f"Fire it now: sagex trigger fire {name}")
779
+ note(f"Watch the next run: sagex trigger watch {name}")
780
+
781
+
782
+ # --- credential (key) ------------------------------------------------------
783
+
784
+ _SECRET_FIELDS = ["username", "password", "ssh_key", "key_passphrase", "cert_pem"]
785
+
786
+
787
+ def credential(c: dict, secrets: dict | None) -> None:
788
+ """Show a credential. Secrets are shown ONLY when `secrets` is provided."""
789
+ header = Text()
790
+ header.append(_kv("id", c.get("id"))); header.append("\n")
791
+ header.append(_kv("type", c.get("credential_type"))); header.append("\n")
792
+ header.append(_kv("vault", c.get("vault_name") or c.get("vault"))); header.append("\n")
793
+ header.append(_kv("created", c.get("created_at"))); header.append("\n")
794
+ header.append(_kv("modified", c.get("modified_at")))
795
+ console.print(Panel(header, title=c.get("name") or "(credential)", title_align="left"))
796
+
797
+ if secrets is None:
798
+ note("Secrets hidden — pass --reveal to display them (asks first).")
799
+ return
800
+
801
+ console.print(Text("⚠ Revealed secrets — now visible in your terminal/scrollback:", style="bold red"))
802
+ table = Table(show_header=False, box=None, pad_edge=False)
803
+ table.add_column("field", style="bright_black", no_wrap=True)
804
+ table.add_column("value")
805
+ shown = False
806
+ for field in _SECRET_FIELDS:
807
+ value = secrets.get(field)
808
+ if value:
809
+ table.add_row(field, Text(str(value)))
810
+ shown = True
811
+ console.print(table) if shown else note("(no secret values set)")
812
+
813
+
814
+ # --- server ----------------------------------------------------------------
815
+
816
+ def server(s: dict) -> None:
817
+ """Show a vault server, incl. its linked credential name and vault name."""
818
+ host = s.get("host") or "—"
819
+ port = s.get("port")
820
+ cred = s.get("credential_details") if isinstance(s.get("credential_details"), dict) else {}
821
+
822
+ header = Text()
823
+ header.append(_kv("id", s.get("id"))); header.append("\n")
824
+ header.append(_kv("host", f"{host}:{port}" if port else host)); header.append("\n")
825
+ header.append(_kv("method", s.get("connection_method"))); header.append("\n")
826
+ header.append(_kv("credential", cred.get("name") or "—")); header.append("\n")
827
+ header.append(_kv("vault", s.get("vault_name") or s.get("vault")));header.append("\n")
828
+ header.append(_kv("created", s.get("created_at"))); header.append("\n")
829
+ header.append(_kv("modified", s.get("modified_at")))
830
+ console.print(Panel(header, title=s.get("name") or "(server)", title_align="left"))
831
+
832
+
833
+ # --- vault -----------------------------------------------------------------
834
+
835
+ def vault(v: dict) -> None:
836
+ """Show a vault: header panel + tables of its credentials and servers."""
837
+ creds = v.get("credentials") or []
838
+ servers = v.get("servers") or []
839
+
840
+ header = Text()
841
+ header.append(_kv("id", v.get("id"))); header.append("\n")
842
+ header.append(_kv("description", v.get("description") or "—")); header.append("\n")
843
+ header.append(_kv("credentials", len(creds))); header.append("\n")
844
+ header.append(_kv("servers", len(servers))); header.append("\n")
845
+ header.append(_kv("created", v.get("created_at"))); header.append("\n")
846
+ header.append(_kv("modified", v.get("modified_at")))
847
+ console.print(Panel(header, title=v.get("name") or "(vault)", title_align="left"))
848
+
849
+ if creds:
850
+ table = Table(title="Credentials", show_header=True, header_style="bold", title_justify="left")
851
+ table.add_column("name")
852
+ table.add_column("type")
853
+ for c in creds:
854
+ table.add_row(str(c.get("name") or ""), str(c.get("credential_type") or ""))
855
+ console.print(table)
856
+
857
+ if servers:
858
+ table = Table(title="Servers", show_header=True, header_style="bold", title_justify="left")
859
+ table.add_column("name")
860
+ table.add_column("host")
861
+ table.add_column("method")
862
+ for s in servers:
863
+ host = s.get("host") or ""
864
+ port = s.get("port")
865
+ table.add_row(str(s.get("name") or ""), f"{host}:{port}" if port else str(host),
866
+ str(s.get("connection_method") or ""))
867
+ console.print(table)
868
+
869
+
870
+ # ---------------------------------------------------------------------------
871
+ # JSON slimming — strip internal/noisy fields before printing --json output.
872
+ # ---------------------------------------------------------------------------
873
+
874
+ _SLIM: dict[str, list[str]] = {
875
+ "workflow": ["id", "name", "description", "modified_at", "created_at"],
876
+ "script": ["id", "name", "content_type", "version", "file_size", "updated_at"],
877
+ "run": ["id", "workflow_name", "status", "created_at", "started_at", "finished_at", "error_message"],
878
+ "trigger": ["workflow_name", "kind", "is_active", "node_id", "workflow_id",
879
+ "cron_expression", "timezone", "trigger_url", "secret_last4"],
880
+ "credential": ["id", "name", "credential_type", "vault_name", "created_at", "modified_at"],
881
+ "server": ["id", "name", "host", "port", "connection_method", "vault_name", "created_at", "modified_at"],
882
+ }
883
+
884
+
885
+ def slim_items(items: list[dict], kind: str) -> list[dict]:
886
+ """Return a copy of `items` with only the display-relevant fields for `kind`."""
887
+ fields = _SLIM[kind]
888
+ return [{k: it[k] for k in fields if k in it} for it in items]
889
+
890
+
891
+ def slim_vaults(items: list[dict]) -> list[dict]:
892
+ """Vault-specific slim: top-level fields + compact nested cred/server lists."""
893
+ out = []
894
+ for v in items:
895
+ d = {k: v[k] for k in ["id", "name", "description", "created_at", "modified_at"] if k in v}
896
+ d["credentials"] = [
897
+ {"id": c.get("id"), "name": c.get("name"), "type": c.get("credential_type")}
898
+ for c in (v.get("credentials") or [])
899
+ ]
900
+ d["servers"] = [
901
+ {"id": s.get("id"), "name": s.get("name"), "host": s.get("host"), "method": s.get("connection_method")}
902
+ for s in (v.get("servers") or [])
903
+ ]
904
+ out.append(d)
905
+ return out
906
+
907
+
908
+ # ---------------------------------------------------------------------------
909
+ # List renderers — one per resource type, for the `sagex list` commands.
910
+ # ---------------------------------------------------------------------------
911
+
912
+
913
+ def list_workflows(items: list[dict]) -> None:
914
+ if not items:
915
+ note("No workflows found.")
916
+ return
917
+ table = Table(show_header=True, header_style="bold")
918
+ table.add_column("name")
919
+ table.add_column("id", style="cyan", no_wrap=True)
920
+ table.add_column("modified", style="bright_black")
921
+ for w in items:
922
+ table.add_row(
923
+ w.get("name") or "(unnamed)",
924
+ str(w.get("id") or ""),
925
+ relative_time(w.get("modified_at")),
926
+ )
927
+ console.print(table)
928
+
929
+
930
+ def list_scripts(items: list[dict]) -> None:
931
+ if not items:
932
+ note("No scripts found.")
933
+ return
934
+ table = Table(show_header=True, header_style="bold")
935
+ table.add_column("name")
936
+ table.add_column("id", style="cyan", no_wrap=True)
937
+ table.add_column("type", style="bright_black")
938
+ table.add_column("ver", style="bright_black")
939
+ for s in items:
940
+ ver = s.get("version")
941
+ table.add_row(
942
+ s.get("name") or "(unnamed)",
943
+ str(s.get("id") or ""),
944
+ s.get("content_type") or "—",
945
+ f"v{ver}" if ver is not None else "—",
946
+ )
947
+ console.print(table)
948
+
949
+
950
+ def list_runs(items: list[dict]) -> None:
951
+ if not items:
952
+ note("No runs found.")
953
+ return
954
+ table = Table(show_header=True, header_style="bold")
955
+ table.add_column("id", style="cyan", no_wrap=True)
956
+ table.add_column("workflow")
957
+ table.add_column("status")
958
+ table.add_column("created", style="bright_black")
959
+ for r in items:
960
+ status = r.get("status") or "unknown"
961
+ icon, color = STATUS_ICON.get(status, ("•", "white"))
962
+ table.add_row(
963
+ str(r.get("id") or "")[:8],
964
+ r.get("workflow_name") or "—",
965
+ Text(f"{icon} {status}", style=color),
966
+ relative_time(r.get("created_at")),
967
+ )
968
+ console.print(table)
969
+
970
+
971
+ def list_triggers(items: list[dict]) -> None:
972
+ if not items:
973
+ note("No triggers found.")
974
+ return
975
+ table = Table(show_header=True, header_style="bold")
976
+ table.add_column("workflow")
977
+ table.add_column("kind")
978
+ table.add_column("active")
979
+ for t in items:
980
+ is_active = t.get("is_active")
981
+ active_text = Text("● yes", style="green") if is_active else Text("○ no", style="bright_black")
982
+ table.add_row(
983
+ t.get("workflow_name") or "—",
984
+ t.get("kind") or "—",
985
+ active_text,
986
+ )
987
+ console.print(table)
988
+
989
+
990
+ def list_credentials(items: list[dict]) -> None:
991
+ if not items:
992
+ note("No credentials found.")
993
+ return
994
+ table = Table(show_header=True, header_style="bold")
995
+ table.add_column("name")
996
+ table.add_column("id", style="cyan", no_wrap=True)
997
+ table.add_column("type", style="bright_black")
998
+ table.add_column("vault", style="bright_black")
999
+ for c in items:
1000
+ table.add_row(
1001
+ c.get("name") or "(unnamed)",
1002
+ str(c.get("id") or ""),
1003
+ c.get("credential_type") or "—",
1004
+ c.get("vault_name") or str(c.get("vault") or "—"),
1005
+ )
1006
+ console.print(table)
1007
+
1008
+
1009
+ def list_servers(items: list[dict]) -> None:
1010
+ if not items:
1011
+ note("No servers found.")
1012
+ return
1013
+ table = Table(show_header=True, header_style="bold")
1014
+ table.add_column("name")
1015
+ table.add_column("host")
1016
+ table.add_column("method", style="bright_black")
1017
+ for s in items:
1018
+ host = s.get("host") or "—"
1019
+ port = s.get("port")
1020
+ table.add_row(
1021
+ s.get("name") or "(unnamed)",
1022
+ f"{host}:{port}" if port else host,
1023
+ s.get("connection_method") or "—",
1024
+ )
1025
+ console.print(table)
1026
+
1027
+
1028
+ def list_vaults(items: list[dict]) -> None:
1029
+ if not items:
1030
+ note("No vaults found.")
1031
+ return
1032
+ table = Table(show_header=True, header_style="bold")
1033
+ table.add_column("name")
1034
+ table.add_column("id", style="cyan", no_wrap=True)
1035
+ table.add_column("#creds", justify="right", style="bright_black")
1036
+ table.add_column("#servers", justify="right", style="bright_black")
1037
+ for v in items:
1038
+ creds = v.get("credentials") or []
1039
+ svrs = v.get("servers") or []
1040
+ table.add_row(
1041
+ v.get("name") or "(unnamed)",
1042
+ str(v.get("id") or ""),
1043
+ str(len(creds)) if isinstance(creds, list) else "—",
1044
+ str(len(svrs)) if isinstance(svrs, list) else "—",
1045
+ )
1046
+ console.print(table)