functualize-flow-viz 0.1.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.
@@ -0,0 +1,101 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ *.whl
11
+
12
+ # Agents
13
+ .spec/archive/
14
+ .spec/features/
15
+ .spec/scrutiny-reports/
16
+ .spec/.agentic-coding
17
+ .spec/STATE.md
18
+ .spec/PROJECT.md
19
+ .spec/REQUIREMENTS.md
20
+ .spec/ROADMAP.md
21
+ .opencode/
22
+
23
+
24
+ # Virtual environments
25
+ .venv/
26
+ venv/
27
+ ENV/
28
+
29
+ # Testing
30
+ .coverage
31
+ .pytest_cache/
32
+ htmlcov/
33
+ .hypothesis/
34
+ snapshot_report.html
35
+ _*_result*.txt
36
+ _debug.txt
37
+ _tui_debug.txt
38
+ _tui_eval_debug.txt
39
+
40
+ # IDE
41
+ .idea/
42
+ *.swp
43
+ *.swo
44
+ *~
45
+ *.code-workspace
46
+
47
+ # Coding-agent tooling state (guards — these dirs are not part of the repo)
48
+ .kiro/
49
+ .moai/
50
+
51
+ # OS
52
+ .DS_Store
53
+ Thumbs.db
54
+
55
+ # Environment / secrets
56
+ .env
57
+ .env.*
58
+ !.env.example
59
+
60
+ # Agent scratch space (test output, temp scripts)
61
+ tmp/
62
+
63
+ # Local-only files (not for the repo)
64
+ *.local.md
65
+ *.local.*
66
+
67
+ # Personal notes
68
+ HUMAN_NOTE.md
69
+
70
+ # Distribution
71
+ dist/
72
+
73
+ # Documentation site build output
74
+ site/
75
+
76
+ # uv
77
+ .python-version
78
+ .functualize/cache.json
79
+ .functualize_cache.json
80
+ .todos/
81
+ .sidecar/
82
+ .sidecar-agent
83
+ .sidecar-task
84
+ .sidecar-pr
85
+ .sidecar-start.sh
86
+ .sidecar-base
87
+ .td-root
88
+ .functualize/
89
+ .import_linter_cache/
90
+ .mypy_cache/
91
+ .pytest_cache/
92
+ .ruff_cache/
93
+
94
+ # OmO / OpenCode agent run-continuation scratch state
95
+ .omo/
96
+ .mcp.json
97
+ .agentsroom/handoff-transcript-*.txt
98
+ .agentsroom/handoff-summary-*.md
99
+
100
+ # Internal pre-release audit reports (contain session IDs / local infra notes)
101
+ .release/
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: functualize-flow-viz
3
+ Version: 0.1.0
4
+ Summary: Inline flow visualization plugin for functualize job execution
5
+ Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: functualize<1.0.0,>=0.1.0
15
+ Requires-Dist: textual>=0.40.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
18
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # functualize-flow-viz
22
+
23
+ > **Status: Published** — Independently installable from PyPI.
24
+
25
+ Inline execution tree visualization plugin for functualize. Renders a live job execution tree in the terminal showing step status, durations, and nested invocations updating in real time. Automatically detects TTY capabilities and falls back to structured plain-text output for non-interactive terminals.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install functualize-flow-viz
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ from functualize_flow_viz import FlowVizPlugin
37
+
38
+ # Register the plugin with your functualize app
39
+ plugin = FlowVizPlugin()
40
+ plugin.on_job_start("deploy", {"env": "production"})
41
+ plugin.on_log("info", "Starting deployment...")
42
+ plugin.on_invoke_start("migrate", {"target": "latest"})
43
+ plugin.on_invoke_end("migrate", None)
44
+ plugin.on_job_end("deploy", None)
45
+ ```
46
+
47
+ ## Features
48
+
49
+ - **Live execution tree** — Renders a real-time tree of job execution with status icons (⏳ running, ✓ success, ✗ failure, ○ pending, ⊘ timeout)
50
+ - **Nested invocation tracking** — Shows `rc.invoke()` calls as indented children in the tree, preserving parent-child relationships
51
+ - **Real-time duration refresh** — Background thread updates elapsed durations at ≤1s intervals for running nodes
52
+ - **TTY auto-detection** — Uses ANSI inline rendering on interactive terminals; falls back to plain-text output for piped/CI environments
53
+ - **Custom event handling** — Renders domain-specific visualizations including progress bars for events with progress payloads
54
+ - **Log streaming** — Buffers log messages and prints them above the inline tree widget to avoid visual corruption
55
+
56
+ ## API Reference
57
+
58
+ Public classes and functions exported by this plugin:
59
+
60
+ - `FlowVizPlugin` — Main plugin class implementing the OutputRenderer protocol. Auto-selects between TTY and plain-text rendering. Methods:
61
+ - `on_job_start(job_name, metadata)` — Initialize the execution tree for a job
62
+ - `on_log(level, message)` — Render a log message
63
+ - `on_status_change(old_status, new_status, message)` — Handle status transitions
64
+ - `on_phase_change(step, action)` — Add or update a job phase node
65
+ - `on_invoke_start(child_job_name, kwargs)` — Begin tracking a nested invocation
66
+ - `on_invoke_end(child_job_name, result)` — Complete a nested invocation
67
+ - `on_job_end(job_name, result)` — Finalize the tree and render final state
68
+ - `on_event(event)` — Handle custom structured events
69
+ - `render_log(message, level)` — OutputRenderer protocol: render log
70
+ - `render_phase(phase, status)` — OutputRenderer protocol: render phase update
71
+ - `render_progress(current, total, label)` — OutputRenderer protocol: render progress
72
+
73
+ Internal (non-public) classes used by `FlowVizPlugin`:
74
+
75
+ - `TreeNode` — Dataclass representing a node in the execution tree with label, status, timing, and children
76
+ - `InlineTTYRenderer` — ANSI-based renderer for interactive terminals with background refresh
77
+ - `PlainTextRenderer` — Fallback renderer producing structured plain-text without escape sequences
78
+
79
+ ## Development
80
+
81
+ Run plugin tests:
82
+
83
+ ```bash
84
+ uv run pytest plugins/functualize-flow-viz/tests/ -v
85
+ ```
@@ -0,0 +1,65 @@
1
+ # functualize-flow-viz
2
+
3
+ > **Status: Published** — Independently installable from PyPI.
4
+
5
+ Inline execution tree visualization plugin for functualize. Renders a live job execution tree in the terminal showing step status, durations, and nested invocations updating in real time. Automatically detects TTY capabilities and falls back to structured plain-text output for non-interactive terminals.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install functualize-flow-viz
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ from functualize_flow_viz import FlowVizPlugin
17
+
18
+ # Register the plugin with your functualize app
19
+ plugin = FlowVizPlugin()
20
+ plugin.on_job_start("deploy", {"env": "production"})
21
+ plugin.on_log("info", "Starting deployment...")
22
+ plugin.on_invoke_start("migrate", {"target": "latest"})
23
+ plugin.on_invoke_end("migrate", None)
24
+ plugin.on_job_end("deploy", None)
25
+ ```
26
+
27
+ ## Features
28
+
29
+ - **Live execution tree** — Renders a real-time tree of job execution with status icons (⏳ running, ✓ success, ✗ failure, ○ pending, ⊘ timeout)
30
+ - **Nested invocation tracking** — Shows `rc.invoke()` calls as indented children in the tree, preserving parent-child relationships
31
+ - **Real-time duration refresh** — Background thread updates elapsed durations at ≤1s intervals for running nodes
32
+ - **TTY auto-detection** — Uses ANSI inline rendering on interactive terminals; falls back to plain-text output for piped/CI environments
33
+ - **Custom event handling** — Renders domain-specific visualizations including progress bars for events with progress payloads
34
+ - **Log streaming** — Buffers log messages and prints them above the inline tree widget to avoid visual corruption
35
+
36
+ ## API Reference
37
+
38
+ Public classes and functions exported by this plugin:
39
+
40
+ - `FlowVizPlugin` — Main plugin class implementing the OutputRenderer protocol. Auto-selects between TTY and plain-text rendering. Methods:
41
+ - `on_job_start(job_name, metadata)` — Initialize the execution tree for a job
42
+ - `on_log(level, message)` — Render a log message
43
+ - `on_status_change(old_status, new_status, message)` — Handle status transitions
44
+ - `on_phase_change(step, action)` — Add or update a job phase node
45
+ - `on_invoke_start(child_job_name, kwargs)` — Begin tracking a nested invocation
46
+ - `on_invoke_end(child_job_name, result)` — Complete a nested invocation
47
+ - `on_job_end(job_name, result)` — Finalize the tree and render final state
48
+ - `on_event(event)` — Handle custom structured events
49
+ - `render_log(message, level)` — OutputRenderer protocol: render log
50
+ - `render_phase(phase, status)` — OutputRenderer protocol: render phase update
51
+ - `render_progress(current, total, label)` — OutputRenderer protocol: render progress
52
+
53
+ Internal (non-public) classes used by `FlowVizPlugin`:
54
+
55
+ - `TreeNode` — Dataclass representing a node in the execution tree with label, status, timing, and children
56
+ - `InlineTTYRenderer` — ANSI-based renderer for interactive terminals with background refresh
57
+ - `PlainTextRenderer` — Fallback renderer producing structured plain-text without escape sequences
58
+
59
+ ## Development
60
+
61
+ Run plugin tests:
62
+
63
+ ```bash
64
+ uv run pytest plugins/functualize-flow-viz/tests/ -v
65
+ ```
@@ -0,0 +1,31 @@
1
+ # functualize-flow-viz Examples
2
+
3
+ Live inline execution tree — zero code changes to your jobs. The plugin subscribes to `invoke_start`, `invoke_end`, and `phase_change` events and renders phase status, durations, and nested invocations.
4
+
5
+ | Directory | Demonstrates |
6
+ |-----------|--------------|
7
+ | [`pipeline/`](pipeline/) | A job invoking sub-jobs with phase tracking — the shape flow-viz visualizes |
8
+
9
+ ## See it live
10
+
11
+ ```bash
12
+ cd plugins/functualize-flow-viz/examples/pipeline
13
+ func jobs.py morning_report --city Tokyo
14
+ ```
15
+
16
+ With `functualize-flow-viz` installed, the run renders as a live tree:
17
+
18
+ ```
19
+ ⏳ morning_report
20
+ ├─ ✓ forecast — Forecast retrieved (0.1s)
21
+ ├─ ✓ alerts — Alerts checked (0.1s)
22
+ └─ ✓ morning_report (0.3s)
23
+ ```
24
+
25
+ Uninstall the plugin and the same command prints plain logs — jobs never know the difference.
26
+
27
+ ## Tests
28
+
29
+ ```bash
30
+ uv run pytest plugins/functualize-flow-viz/examples/ -v
31
+ ```
@@ -0,0 +1,42 @@
1
+ """A nested-invocation pipeline for flow-viz to visualize.
2
+
3
+ Run with:
4
+ func jobs.py morning_report --city Tokyo
5
+ """
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from functualize.job import RunContext
10
+ from functualize.types import RunStatus
11
+
12
+
13
+ class ForecastConfig(BaseModel):
14
+ """Configuration for weather jobs."""
15
+
16
+ city: str = Field(description="City to check")
17
+ days: int = Field(default=3, ge=1, le=7, description="Days to forecast")
18
+
19
+
20
+ def forecast(config: ForecastConfig, rc: RunContext) -> str:
21
+ """Fetch the weather forecast for the configured city."""
22
+ rc.log(f"Fetching {config.days}-day forecast for {config.city}...")
23
+ return f"{config.city}: 24°C, sunny"
24
+
25
+
26
+ def alert(config: ForecastConfig, rc: RunContext) -> str:
27
+ """Check forecast and send alerts if needed."""
28
+ rc.log(f"No severe weather for {config.city} — all clear")
29
+ return "all clear"
30
+
31
+
32
+ def morning_report(config: ForecastConfig, rc: RunContext) -> None:
33
+ """Run the full morning weather pipeline (watch the tree render)."""
34
+ rc.track_phase("forecast", "Fetching forecast", RunStatus.RUNNING)
35
+ rc.invoke("forecast", city=config.city, days=config.days)
36
+ rc.track_phase("forecast", "Forecast retrieved", RunStatus.SUCCESS)
37
+
38
+ rc.track_phase("alerts", "Checking alerts", RunStatus.RUNNING)
39
+ rc.invoke("alert", city=config.city)
40
+ rc.track_phase("alerts", "Alerts checked", RunStatus.SUCCESS)
41
+
42
+ rc.log("Morning report complete")
@@ -0,0 +1,17 @@
1
+ """Tests for the pipeline example (the jobs flow-viz visualizes)."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import MagicMock
6
+
7
+ sys.path.insert(0, str(Path(__file__).parent))
8
+
9
+ from jobs import ForecastConfig, morning_report
10
+
11
+
12
+ def test_pipeline_emits_invokes_and_phases():
13
+ rc = MagicMock()
14
+ morning_report(ForecastConfig(city="Tokyo"), rc)
15
+ invoked = [call.args[0] for call in rc.invoke.call_args_list]
16
+ assert invoked == ["forecast", "alert"]
17
+ assert rc.track_phase.call_count == 4
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "functualize-flow-viz"
3
+ version = "0.1.0"
4
+ description = "Inline flow visualization plugin for functualize job execution"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "functualize>=0.1.0,<1.0.0",
13
+ "textual>=0.40.0",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.entry-points."functualize.plugins"]
25
+ flow-viz = "functualize_flow_viz:FlowVizPlugin"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=7.4.0",
30
+ "pytest-cov>=4.1.0",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.uv.sources]
38
+ functualize = { workspace = true }
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/functualize_flow_viz"]
@@ -0,0 +1,5 @@
1
+ """Functualize Flow Viz Plugin - Inline execution tree visualization."""
2
+
3
+ from functualize_flow_viz.plugin import FlowVizConstruct, FlowVizPlugin, TreeNode
4
+
5
+ __all__ = ["FlowVizConstruct", "FlowVizPlugin", "TreeNode"]
@@ -0,0 +1,363 @@
1
+ """Functualize Flow Viz Plugin — inline execution tree visualization.
2
+
3
+ Renders a live job execution tree — status icons, durations, nested
4
+ ``rc.invoke()`` children, custom ``rc.emit`` events — as a hosted
5
+ ``LiveConstruct``.
6
+
7
+ Architecture note: this plugin used to be a self-rendering ``Surface`` with two
8
+ hand-rolled renderers (a non-TTY plain-text printer and a TTY renderer doing
9
+ its own ANSI cursor math on a 0.5s daemon thread). That made it a second writer
10
+ competing for the cursor with whatever else was drawing — the collision class
11
+ the surface architecture exists to remove.
12
+
13
+ Now it is a construct **hosted by a live zone**. The zone (``StdoutSurface`` on
14
+ a direct run, ``PanelLiveZone`` in the TUI) owns the cursor and the refresh
15
+ cadence, and Rich's own ``Live`` handles non-TTY degradation by printing final
16
+ state only. That deletes the daemon thread, the cursor math, and the two-renderer
17
+ split in one move.
18
+
19
+ It registers as an **ambient construct**: it renders by default for jobs that
20
+ invoke children (where a tree has something to show), with no job-author code.
21
+ Users turn it off with ``[flow-viz] enabled = false``, ``[live] suppress``,
22
+ ``@job(suppress_live=["flow-viz"])``, or ``live.suppress("flow-viz")``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ __all__ = ["FlowVizConstruct", "FlowVizPlugin", "TreeNode"]
33
+
34
+
35
+ # ─── Status Icons ─────────────────────────────────────────────────────
36
+
37
+ ICON_RUNNING = "⏳"
38
+ ICON_SUCCESS = "✓"
39
+ ICON_FAILURE = "✗"
40
+ ICON_PENDING = "○"
41
+ ICON_TIMEOUT = "⊘"
42
+
43
+ _STATUS_STYLES = {
44
+ "running": "yellow",
45
+ "success": "green",
46
+ "failure": "red",
47
+ "timeout": "red",
48
+ "pending": "dim",
49
+ }
50
+
51
+
52
+ def _status_icon(status: str) -> str:
53
+ """Return the icon for a status string."""
54
+ return {
55
+ "running": ICON_RUNNING,
56
+ "success": ICON_SUCCESS,
57
+ "failure": ICON_FAILURE,
58
+ "timeout": ICON_TIMEOUT,
59
+ "pending": ICON_PENDING,
60
+ }.get(status, ICON_PENDING)
61
+
62
+
63
+ def _format_duration(duration_ms: float) -> str:
64
+ """Format a duration in ms as a compact human string."""
65
+ if duration_ms < 1000:
66
+ return f"{duration_ms:.0f}ms"
67
+ if duration_ms < 60_000:
68
+ return f"{duration_ms / 1000:.1f}s"
69
+ minutes = int(duration_ms // 60_000)
70
+ seconds = (duration_ms % 60_000) / 1000
71
+ return f"{minutes}m{seconds:.1f}s"
72
+
73
+
74
+ # ─── Tree Node ────────────────────────────────────────────────────────
75
+
76
+
77
+ @dataclass
78
+ class TreeNode:
79
+ """A node in the execution tree representing a job or step."""
80
+
81
+ label: str
82
+ status: str = "pending" # pending, running, success, failure, timeout
83
+ start_time: float | None = None
84
+ end_time: float | None = None
85
+ children: list[TreeNode] = field(default_factory=list)
86
+ events: list[str] = field(default_factory=list)
87
+ depth: int = 0
88
+
89
+ @property
90
+ def duration_ms(self) -> float:
91
+ """Elapsed duration in milliseconds."""
92
+ if self.start_time is None:
93
+ return 0.0
94
+ end = self.end_time if self.end_time is not None else time.time()
95
+ return (end - self.start_time) * 1000
96
+
97
+ def render_line(self) -> str:
98
+ """Render this node as a single line with icon, label, and duration."""
99
+ icon = _status_icon(self.status)
100
+ # `is not None`, not truthiness — matches `duration_ms` above, which
101
+ # already treats 0.0 as a real start time rather than "unstarted".
102
+ duration = (
103
+ _format_duration(self.duration_ms) if self.start_time is not None else ""
104
+ )
105
+ suffix = f" ({duration})" if duration else ""
106
+ return f"{icon} {self.label}{suffix}"
107
+
108
+
109
+ # ─── Live Construct ───────────────────────────────────────────────────
110
+
111
+
112
+ class FlowVizConstruct:
113
+ """A ``LiveConstruct`` that renders the execution tree.
114
+
115
+ ``__rich__()`` builds a ``rich.tree.Tree`` from current state;
116
+ ``handle_event()`` mutates that state from the structured event stream.
117
+ The hosting live zone decides when to repaint, so there is no refresh
118
+ thread and no cursor management here.
119
+ """
120
+
121
+ name: str = "flow-viz"
122
+
123
+ def __init__(self) -> None:
124
+ self._root: TreeNode | None = None
125
+ self._node_stack: list[TreeNode] = []
126
+
127
+ # ─── Rendering ────────────────────────────────────────────────────
128
+
129
+ def __rich__(self) -> Any:
130
+ """Return a ``rich.tree.Tree`` for the current execution state."""
131
+ from rich.tree import Tree
132
+
133
+ if self._root is None:
134
+ return Tree("")
135
+ tree = Tree(self._styled(self._root))
136
+ self._attach(tree, self._root)
137
+ return tree
138
+
139
+ def _styled(self, node: TreeNode) -> str:
140
+ """Render one node's line with a status-appropriate style."""
141
+ style = _STATUS_STYLES.get(node.status, "")
142
+ line = node.render_line()
143
+ return f"[{style}]{line}[/{style}]" if style else line
144
+
145
+ def _attach(self, branch: Any, node: TreeNode) -> None:
146
+ """Recursively attach a node's events and children to a Rich branch."""
147
+ for event in node.events:
148
+ branch.add(f"[dim]◆ {event}[/dim]")
149
+ for child in node.children:
150
+ child_branch = branch.add(self._styled(child))
151
+ self._attach(child_branch, child)
152
+
153
+ # ─── Event handling ───────────────────────────────────────────────
154
+
155
+ @property
156
+ def current_node(self) -> TreeNode | None:
157
+ """The node currently receiving events (deepest open scope)."""
158
+ if self._node_stack:
159
+ return self._node_stack[-1]
160
+ return self._root
161
+
162
+ def handle_event(self, event: Any) -> None:
163
+ """Update tree state from one structured event.
164
+
165
+ The engine's job vocabulary is ``job.execute.start`` /
166
+ ``job.execute.end`` / ``job.execute.error`` (see
167
+ ``_events/_catalog_entries.py``). Nesting is **not** a separate
168
+ ``invoke.*`` event pair — a child started via ``rc.invoke()`` emits the
169
+ same ``job.execute.*`` names carrying an ``invoke_depth`` payload, so
170
+ the tree is built from that depth rather than from an open/close stack.
171
+
172
+ Caveat — the lifecycle branch is currently unreachable: ``job.execute.``
173
+ is one of ``RunContext._FRAMEWORK_EVENT_PREFIXES``, which
174
+ ``_dispatch_to_surfaces`` filters out, so surfaces (and therefore
175
+ hosted constructs) only ever see custom ``rc.emit`` events. The
176
+ handling is kept because it is the correct mapping the moment lifecycle
177
+ events are surfaced, and because it costs nothing meanwhile. See
178
+ ``contributor/architecture/event-vocabulary.md``.
179
+
180
+ Unrecognized events are recorded on the current node rather than
181
+ dropped, so a domain's custom ``rc.emit`` still shows up in the tree.
182
+ """
183
+ event_name = str(getattr(event, "event_name", "") or "")
184
+ payload = getattr(event, "payload", {}) or {}
185
+ resource = getattr(event, "resource", "") or ""
186
+
187
+ if event_name == "job.execute.start":
188
+ self._job_started(event_name, resource, payload)
189
+ elif event_name in ("job.execute.end", "job.execute.error"):
190
+ self._job_ended(event_name, resource, payload)
191
+ else:
192
+ self._record_custom(event_name, resource, payload)
193
+
194
+ # ─── State transitions ────────────────────────────────────────────
195
+
196
+ def _label_for(self, resource: str, payload: dict[str, Any], fallback: str) -> str:
197
+ for key in ("job_name", "name", "step", "child_job_name"):
198
+ value = payload.get(key)
199
+ if value:
200
+ return str(value)
201
+ return resource or fallback
202
+
203
+ def _job_started(
204
+ self, event_name: str, resource: str, payload: dict[str, Any]
205
+ ) -> None:
206
+ """Open a node at the event's ``invoke_depth``.
207
+
208
+ Depth 0 is the top-level job (the root); deeper events are children
209
+ started by ``rc.invoke()``. Unwinding to the right parent by depth is
210
+ what makes nesting work without paired invoke events.
211
+ """
212
+ label = self._label_for(resource, payload, "job")
213
+ depth = _depth_of(payload)
214
+ node = TreeNode(
215
+ label=label, status="running", start_time=time.time(), depth=depth
216
+ )
217
+
218
+ if self._root is None or depth == 0:
219
+ if self._root is None:
220
+ self._root = node
221
+ self._node_stack = [node]
222
+ else:
223
+ # A second depth-0 job in one window (a fresh run against a
224
+ # reused construct): restart rather than graft onto the old
225
+ # tree, which would misreport the previous run as this one.
226
+ self._root = node
227
+ self._node_stack = [node]
228
+ return
229
+
230
+ # Unwind to this node's parent, then attach.
231
+ while self._node_stack and self._node_stack[-1].depth >= depth:
232
+ self._node_stack.pop()
233
+ parent = self._node_stack[-1] if self._node_stack else self._root
234
+ parent.children.append(node)
235
+ self._node_stack.append(node)
236
+
237
+ def _job_ended(
238
+ self, event_name: str, resource: str, payload: dict[str, Any]
239
+ ) -> None:
240
+ """Close the node this event terminates, matched by name then depth."""
241
+ status = _status_from(event_name, payload)
242
+ label = self._label_for(resource, payload, "job")
243
+
244
+ for index in range(len(self._node_stack) - 1, -1, -1):
245
+ node = self._node_stack[index]
246
+ if node.label == label:
247
+ node.status = status
248
+ node.end_time = time.time()
249
+ del self._node_stack[index:]
250
+ return
251
+
252
+ # No open node matched (a end without its start) — fall back to the
253
+ # root so a completed run is never left rendering as still running.
254
+ if self._root is not None and self._root.end_time is None:
255
+ self._root.status = status
256
+ self._root.end_time = time.time()
257
+
258
+ def _record_custom(
259
+ self, event_name: str, resource: str, payload: dict[str, Any]
260
+ ) -> None:
261
+ """Attach a custom ``rc.emit`` event to the current node."""
262
+ node = self.current_node
263
+ if node is None:
264
+ # No job scope yet — start one so custom events are still visible.
265
+ self._root = TreeNode(
266
+ label="events", status="running", start_time=time.time()
267
+ )
268
+ node = self._root
269
+
270
+ if "progress" in payload:
271
+ progress = payload["progress"]
272
+ bar_width = 20
273
+ try:
274
+ filled = int(bar_width * float(progress) / 100)
275
+ except (TypeError, ValueError):
276
+ filled = 0
277
+ filled = max(0, min(bar_width, filled))
278
+ bar = "█" * filled + "░" * (bar_width - filled)
279
+ node.events.append(f"{event_name}: [{bar}] {progress}%")
280
+ elif resource:
281
+ node.events.append(f"{event_name} ({resource})")
282
+ elif event_name:
283
+ node.events.append(event_name)
284
+
285
+
286
+ def _depth_of(payload: dict[str, Any]) -> int:
287
+ """Read ``invoke_depth`` off a job event payload (0 when absent)."""
288
+ try:
289
+ return max(0, int(payload.get("invoke_depth", 0) or 0))
290
+ except (TypeError, ValueError):
291
+ return 0
292
+
293
+
294
+ def _status_from(event_name: str, payload: dict[str, Any]) -> str:
295
+ """Derive a node status from a terminal event name or its payload."""
296
+ if event_name.endswith(".error") or event_name.endswith(".failed"):
297
+ return "failure"
298
+ status = payload.get("status")
299
+ if status is not None:
300
+ value = getattr(status, "value", status)
301
+ text = str(value).lower()
302
+ if text in _STATUS_STYLES:
303
+ return text
304
+ return "success"
305
+
306
+
307
+ # ─── Plugin Class ─────────────────────────────────────────────────────
308
+
309
+
310
+ class FlowVizPlugin:
311
+ """Registers the flow-viz tree as an ambient live construct.
312
+
313
+ Implements PluginConfigProtocol (``__call__``) for auto-registration via
314
+ the ``functualize.plugins`` entry point.
315
+
316
+ Deliberately not a ``PromptCollector``: it draws, it does not ask. Giving
317
+ it a stub ``collect`` would make it win prompt resolution and swallow
318
+ prompts it cannot answer — and it auto-loads, so that would break
319
+ prompting for every project that installs it.
320
+ """
321
+
322
+ name: str = "flow-viz"
323
+ version: str = "0.2.0"
324
+ description: str = "Inline flow visualization for job execution"
325
+
326
+ def __call__(self, app: Any) -> None:
327
+ """Register the construct as an ambient default, unless disabled."""
328
+ if not _enabled(app):
329
+ return
330
+ with contextlib.suppress(Exception):
331
+ app.register_ambient_construct(
332
+ FlowVizConstruct,
333
+ name="flow-viz",
334
+ predicate=_renders_for,
335
+ )
336
+
337
+
338
+ def _renders_for(descriptor: Any) -> bool:
339
+ """Only render for jobs that invoke children — a tree needs branches.
340
+
341
+ A single-step job's tree is one line that repeats what the log already
342
+ says, so the default stays out of the way.
343
+ """
344
+ if descriptor is None:
345
+ return False
346
+ if getattr(descriptor, "uses_invoke", False):
347
+ return True
348
+ steps = getattr(descriptor, "workflow_steps", None)
349
+ return bool(steps) and len(steps) > 1
350
+
351
+
352
+ def _enabled(app: Any) -> bool:
353
+ """Whether ``[flow-viz] enabled`` permits registration (default True)."""
354
+ try:
355
+ settings = getattr(app, "settings", None)
356
+ if settings is None:
357
+ return True
358
+ value = settings.get("flow-viz.enabled", True)
359
+ if isinstance(value, str):
360
+ return value.strip().lower() not in {"false", "0", "no", "off"}
361
+ return bool(value)
362
+ except Exception:
363
+ return True
File without changes
@@ -0,0 +1 @@
1
+ """Shared fixtures for functualize-flow-viz plugin tests."""
@@ -0,0 +1,265 @@
1
+ """Tests for the flow-viz plugin as a hosted LiveConstruct.
2
+
3
+ The plugin used to be a self-rendering Surface with two hand-rolled renderers
4
+ (``PlainTextRenderer`` / ``InlineTTYRenderer``); this suite replaced the tests
5
+ written around those. What matters now is:
6
+
7
+ - ``handle_event`` builds the right tree state from the event stream,
8
+ - ``__rich__`` renders that state as a ``rich.tree.Tree``,
9
+ - the plugin registers as an *ambient* construct with a sensible predicate,
10
+ - the config lever actually suppresses registration.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ from typing import Any
17
+
18
+ import pytest
19
+ from functualize_flow_viz import FlowVizConstruct, FlowVizPlugin, TreeNode
20
+ from rich.console import Console
21
+
22
+
23
+ class _Event:
24
+ """Minimal stand-in for a StructuredEvent."""
25
+
26
+ def __init__(self, event_name: str, resource: str = "", **payload: Any) -> None:
27
+ self.event_name = event_name
28
+ self.resource = resource
29
+ self.payload = payload
30
+
31
+
32
+ def _render(construct: FlowVizConstruct) -> str:
33
+ console = Console(file=io.StringIO(), width=100, force_terminal=False)
34
+ console.print(construct.__rich__())
35
+ return console.file.getvalue() # type: ignore[union-attr]
36
+
37
+
38
+ # ─── Tree state from events ───────────────────────────────────────────
39
+
40
+
41
+ def test_job_started_creates_the_root() -> None:
42
+ c = FlowVizConstruct()
43
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
44
+
45
+ assert c._root is not None
46
+ assert c._root.label == "deploy"
47
+ assert c._root.status == "running"
48
+ assert "deploy" in _render(c)
49
+
50
+
51
+ def test_job_completed_marks_the_root_successful() -> None:
52
+ c = FlowVizConstruct()
53
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
54
+ c.handle_event(_Event("job.execute.end", job_name="deploy", status="success"))
55
+
56
+ assert c._root is not None
57
+ assert c._root.status == "success"
58
+ assert c._root.end_time is not None
59
+
60
+
61
+ def test_job_failed_marks_the_root_failed() -> None:
62
+ c = FlowVizConstruct()
63
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
64
+ c.handle_event(_Event("job.execute.error", job_name="deploy"))
65
+
66
+ assert c._root is not None
67
+ assert c._root.status == "failure"
68
+
69
+
70
+ def test_invoke_depth_nests_a_child_under_the_job() -> None:
71
+ c = FlowVizConstruct()
72
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
73
+ c.handle_event(_Event("job.execute.start", job_name="migrate", invoke_depth=1))
74
+ c.handle_event(_Event("job.execute.end", job_name="migrate", status="success"))
75
+
76
+ assert c._root is not None
77
+ assert [child.label for child in c._root.children] == ["migrate"]
78
+ assert c._root.children[0].status == "success"
79
+
80
+ rendered = _render(c)
81
+ assert "deploy" in rendered
82
+ assert "migrate" in rendered
83
+
84
+
85
+ def test_deeper_invoke_depth_nests_and_unwinds() -> None:
86
+ c = FlowVizConstruct()
87
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
88
+ c.handle_event(_Event("job.execute.start", job_name="migrate", invoke_depth=1))
89
+ c.handle_event(_Event("job.execute.start", job_name="seed", invoke_depth=2))
90
+
91
+ assert c.current_node is not None
92
+ assert c.current_node.label == "seed"
93
+
94
+ c.handle_event(_Event("job.execute.end", job_name="seed", status="success"))
95
+ assert c.current_node is not None
96
+ assert c.current_node.label == "migrate"
97
+
98
+ c.handle_event(_Event("job.execute.end", job_name="migrate", status="success"))
99
+ assert c.current_node is c._root
100
+
101
+ assert c._root is not None
102
+ migrate = c._root.children[0]
103
+ assert [g.label for g in migrate.children] == ["seed"]
104
+
105
+
106
+ def test_failed_child_is_marked_failure() -> None:
107
+ c = FlowVizConstruct()
108
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
109
+ c.handle_event(_Event("job.execute.start", job_name="migrate", invoke_depth=1))
110
+ c.handle_event(_Event("job.execute.error", job_name="migrate"))
111
+
112
+ assert c._root is not None
113
+ assert c._root.children[0].status == "failure"
114
+
115
+
116
+ def test_custom_events_attach_to_the_current_node() -> None:
117
+ c = FlowVizConstruct()
118
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
119
+ c.handle_event(_Event("upload.chunk", resource="s3://bucket"))
120
+
121
+ assert c._root is not None
122
+ assert any("upload.chunk" in e for e in c._root.events)
123
+ assert "upload.chunk" in _render(c)
124
+
125
+
126
+ def test_progress_payload_renders_a_bar() -> None:
127
+ c = FlowVizConstruct()
128
+ c.handle_event(_Event("job.execute.start", job_name="deploy", invoke_depth=0))
129
+ c.handle_event(_Event("upload.progress", progress=50))
130
+
131
+ assert c._root is not None
132
+ recorded = c._root.events[0]
133
+ assert "█" in recorded and "░" in recorded
134
+ assert "50%" in recorded
135
+
136
+
137
+ def test_custom_event_before_any_job_still_renders() -> None:
138
+ """Events with no job scope must not be silently dropped."""
139
+ c = FlowVizConstruct()
140
+ c.handle_event(_Event("standalone.thing"))
141
+
142
+ assert c._root is not None
143
+ assert "standalone.thing" in _render(c)
144
+
145
+
146
+ def test_empty_construct_renders_without_error() -> None:
147
+ assert _render(FlowVizConstruct()) is not None
148
+
149
+
150
+ def test_each_construct_has_independent_state() -> None:
151
+ """The plugin registers a factory, so runs must not share a tree."""
152
+ a, b = FlowVizConstruct(), FlowVizConstruct()
153
+ a.handle_event(_Event("job.execute.start", job_name="first", invoke_depth=0))
154
+
155
+ assert b._root is None
156
+
157
+
158
+ # ─── Node rendering ───────────────────────────────────────────────────
159
+
160
+
161
+ def test_node_line_includes_icon_and_duration() -> None:
162
+ node = TreeNode(label="deploy", status="success", start_time=0.0, end_time=1.5)
163
+ line = node.render_line()
164
+
165
+ assert "deploy" in line
166
+ assert "✓" in line
167
+ assert "1.5s" in line
168
+
169
+
170
+ def test_pending_node_has_no_duration_suffix() -> None:
171
+ assert TreeNode(label="deploy").render_line().strip().endswith("deploy")
172
+
173
+
174
+ # ─── Plugin registration ──────────────────────────────────────────────
175
+
176
+
177
+ class _FakeSettings:
178
+ def __init__(self, values: dict[str, Any] | None = None) -> None:
179
+ self._values = values or {}
180
+
181
+ def get(self, key: str, default: Any = None) -> Any:
182
+ return self._values.get(key, default)
183
+
184
+
185
+ class _FakeApp:
186
+ def __init__(self, settings: _FakeSettings | None = None) -> None:
187
+ self.settings = settings
188
+ self.registered: list[tuple[Any, str, Any]] = []
189
+
190
+ def register_ambient_construct(
191
+ self, factory: Any, *, name: str | None = None, predicate: Any = None
192
+ ) -> None:
193
+ self.registered.append((factory, name or "", predicate))
194
+
195
+
196
+ class _Descriptor:
197
+ def __init__(self, uses_invoke: bool = False, workflow_steps: Any = None) -> None:
198
+ self.uses_invoke = uses_invoke
199
+ self.workflow_steps = workflow_steps or []
200
+
201
+
202
+ def test_plugin_registers_an_ambient_construct() -> None:
203
+ app = _FakeApp()
204
+ FlowVizPlugin()(app)
205
+
206
+ assert len(app.registered) == 1
207
+ factory, name, predicate = app.registered[0]
208
+ assert factory is FlowVizConstruct
209
+ assert name == "flow-viz"
210
+ assert predicate is not None
211
+
212
+
213
+ def test_plugin_registers_a_factory_not_an_instance() -> None:
214
+ """Each run needs fresh tree state, so registration must pass the class."""
215
+ app = _FakeApp()
216
+ FlowVizPlugin()(app)
217
+
218
+ factory = app.registered[0][0]
219
+ assert isinstance(factory(), FlowVizConstruct)
220
+ assert factory() is not factory()
221
+
222
+
223
+ @pytest.mark.parametrize(
224
+ ("descriptor", "expected"),
225
+ [
226
+ (_Descriptor(uses_invoke=True), True),
227
+ (_Descriptor(workflow_steps=["a", "b"]), True),
228
+ (_Descriptor(uses_invoke=False), False),
229
+ (_Descriptor(workflow_steps=["only-one"]), False),
230
+ (None, False),
231
+ ],
232
+ )
233
+ def test_predicate_targets_jobs_with_branches(descriptor: Any, expected: bool) -> None:
234
+ """The tree renders only where it has something to show."""
235
+ app = _FakeApp()
236
+ FlowVizPlugin()(app)
237
+ predicate = app.registered[0][2]
238
+
239
+ assert predicate(descriptor) is expected
240
+
241
+
242
+ def test_disabled_by_config_skips_registration() -> None:
243
+ app = _FakeApp(_FakeSettings({"flow-viz.enabled": False}))
244
+ FlowVizPlugin()(app)
245
+
246
+ assert app.registered == []
247
+
248
+
249
+ def test_disabled_by_string_config_skips_registration() -> None:
250
+ app = _FakeApp(_FakeSettings({"flow-viz.enabled": "false"}))
251
+ FlowVizPlugin()(app)
252
+
253
+ assert app.registered == []
254
+
255
+
256
+ def test_enabled_by_default_without_settings() -> None:
257
+ app = _FakeApp(_FakeSettings({}))
258
+ FlowVizPlugin()(app)
259
+
260
+ assert len(app.registered) == 1
261
+
262
+
263
+ def test_plugin_is_not_a_prompt_collector() -> None:
264
+ """It draws; it must never win prompt resolution."""
265
+ assert not hasattr(FlowVizPlugin(), "collect")
@@ -0,0 +1,15 @@
1
+ """Unit tests for functualize-flow-viz plugin.
2
+
3
+ Tests the flow visualization plugin's graph rendering and export.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ class TestImports:
10
+ """Verify the plugin is importable."""
11
+
12
+ def test_import_package(self):
13
+ import functualize_flow_viz
14
+
15
+ assert dir(functualize_flow_viz)