milo-cli 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.
milo/pipeline.py ADDED
@@ -0,0 +1,276 @@
1
+ """Pipeline orchestration with observable state through the Store/saga system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, replace
8
+ from typing import Any
9
+
10
+ from milo._types import Action, Call, Fork, Put
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Data types
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class Phase:
19
+ """A named pipeline phase."""
20
+
21
+ name: str
22
+ handler: Callable[..., Any]
23
+ description: str = ""
24
+ depends_on: tuple[str, ...] = ()
25
+ parallel: bool = False
26
+ weight: int = 1
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class PhaseStatus:
31
+ """Runtime status of a single phase."""
32
+
33
+ name: str
34
+ status: str = "pending" # pending, running, completed, failed, skipped
35
+ started_at: float = 0.0
36
+ elapsed: float = 0.0
37
+ error: str = ""
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class PipelineState:
42
+ """Observable state for a running pipeline."""
43
+
44
+ name: str = ""
45
+ phases: tuple[PhaseStatus, ...] = ()
46
+ current_phase: str = ""
47
+ started_at: float = 0.0
48
+ elapsed: float = 0.0
49
+ progress: float = 0.0
50
+ status: str = "pending" # pending, running, completed, failed
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Pipeline action types
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ PIPELINE_START = "@@PIPELINE_START"
59
+ PIPELINE_COMPLETE = "@@PIPELINE_COMPLETE"
60
+ PHASE_START = "@@PHASE_START"
61
+ PHASE_COMPLETE = "@@PHASE_COMPLETE"
62
+ PHASE_FAILED = "@@PHASE_FAILED"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Pipeline class
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ class Pipeline:
71
+ """Declarative build pipeline that executes through the Store.
72
+
73
+ Usage::
74
+
75
+ pipeline = Pipeline(
76
+ "build",
77
+ Phase("discover", handler=discover),
78
+ Phase("parse", handler=parse, depends_on=("discover",)),
79
+ Phase("render", handler=render, depends_on=("parse",), parallel=True),
80
+ Phase("assets", handler=assets, depends_on=("parse",), parallel=True),
81
+ Phase("write", handler=write, depends_on=("render", "assets")),
82
+ )
83
+
84
+ # Extend with >>
85
+ pipeline = pipeline >> Phase("health", handler=check)
86
+
87
+ The pipeline generates a saga and reducer that work with the Store
88
+ for observable, testable execution.
89
+ """
90
+
91
+ def __init__(self, name: str, *phases: Phase) -> None:
92
+ self.name = name
93
+ self.phases = list(phases)
94
+
95
+ def __rshift__(self, phase: Phase) -> Pipeline:
96
+ """Extend the pipeline: ``pipeline >> Phase(...)``."""
97
+ new = Pipeline(self.name, *self.phases, phase)
98
+ return new
99
+
100
+ def build_reducer(self) -> Callable:
101
+ """Generate a reducer that handles pipeline state transitions."""
102
+ name = self.name
103
+ phase_names = tuple(p.name for p in self.phases)
104
+ total_weight = sum(p.weight for p in self.phases)
105
+ weight_map = {p.name: p.weight for p in self.phases}
106
+
107
+ def reducer(state: PipelineState | None, action: Action) -> PipelineState:
108
+ if action.type == "@@INIT":
109
+ return PipelineState(
110
+ name=name,
111
+ phases=tuple(PhaseStatus(name=n) for n in phase_names),
112
+ status="pending",
113
+ )
114
+
115
+ if state is None:
116
+ return PipelineState(name=name)
117
+
118
+ if action.type == PIPELINE_START:
119
+ return replace(
120
+ state,
121
+ status="running",
122
+ started_at=action.payload or time.monotonic(),
123
+ )
124
+
125
+ if action.type == PHASE_START:
126
+ phase_name = action.payload
127
+ now = time.monotonic()
128
+ new_phases = tuple(
129
+ replace(p, status="running", started_at=now) if p.name == phase_name else p
130
+ for p in state.phases
131
+ )
132
+ return replace(state, phases=new_phases, current_phase=phase_name)
133
+
134
+ if action.type == PHASE_COMPLETE:
135
+ phase_name = (
136
+ action.payload.get("name", "")
137
+ if isinstance(action.payload, dict)
138
+ else action.payload
139
+ )
140
+ now = time.monotonic()
141
+ new_phases = []
142
+ completed_weight = 0
143
+ for p in state.phases:
144
+ if p.name == phase_name:
145
+ new_phases.append(
146
+ replace(p, status="completed", elapsed=now - p.started_at)
147
+ )
148
+ else:
149
+ new_phases.append(p)
150
+ if new_phases[-1].status == "completed":
151
+ completed_weight += weight_map.get(p.name, 1)
152
+
153
+ progress = completed_weight / total_weight if total_weight > 0 else 1.0
154
+ return replace(
155
+ state,
156
+ phases=tuple(new_phases),
157
+ progress=progress,
158
+ )
159
+
160
+ if action.type == PHASE_FAILED:
161
+ payload = (
162
+ action.payload if isinstance(action.payload, dict) else {"name": action.payload}
163
+ )
164
+ phase_name = payload.get("name", "")
165
+ error = payload.get("error", "")
166
+ now = time.monotonic()
167
+ new_phases = tuple(
168
+ replace(p, status="failed", error=error, elapsed=now - p.started_at)
169
+ if p.name == phase_name
170
+ else p
171
+ for p in state.phases
172
+ )
173
+ return replace(
174
+ state,
175
+ phases=new_phases,
176
+ status="failed",
177
+ current_phase=phase_name,
178
+ )
179
+
180
+ if action.type == PIPELINE_COMPLETE:
181
+ now = time.monotonic()
182
+ return replace(
183
+ state,
184
+ status="completed",
185
+ progress=1.0,
186
+ elapsed=now - state.started_at if state.started_at else 0.0,
187
+ current_phase="",
188
+ )
189
+
190
+ return state
191
+
192
+ return reducer
193
+
194
+ def build_saga(self) -> Callable:
195
+ """Generate a saga that executes all phases in dependency order."""
196
+ phases = list(self.phases)
197
+ dep_graph = {p.name: set(p.depends_on) for p in phases}
198
+ phase_map = {p.name: p for p in phases}
199
+
200
+ def saga():
201
+ yield Put(Action(PIPELINE_START, time.monotonic()))
202
+
203
+ executed: set[str] = set()
204
+ remaining = set(dep_graph.keys())
205
+
206
+ while remaining:
207
+ # Find phases whose dependencies are all satisfied
208
+ ready = [name for name in remaining if dep_graph[name].issubset(executed)]
209
+
210
+ if not ready:
211
+ # Circular dependency or impossible state
212
+ yield Put(
213
+ Action(
214
+ PHASE_FAILED,
215
+ {"name": next(iter(remaining)), "error": "Unresolvable dependencies"},
216
+ )
217
+ )
218
+ return
219
+
220
+ # Separate parallel and sequential phases
221
+ parallel_ready = [n for n in ready if phase_map[n].parallel]
222
+ sequential_ready = [n for n in ready if not phase_map[n].parallel]
223
+
224
+ # Run parallel phases concurrently via Fork
225
+ if parallel_ready:
226
+ for name in parallel_ready:
227
+ yield Fork(_make_phase_saga(name, phase_map[name].handler))
228
+ # Mark them as executed (Fork runs concurrently)
229
+ for name in parallel_ready:
230
+ remaining.discard(name)
231
+ executed.add(name)
232
+
233
+ # Run sequential phases one at a time
234
+ for name in sequential_ready:
235
+ yield Put(Action(PHASE_START, name))
236
+ try:
237
+ result = yield Call(phase_map[name].handler)
238
+ yield Put(Action(PHASE_COMPLETE, {"name": name, "result": result}))
239
+ except Exception as e:
240
+ yield Put(Action(PHASE_FAILED, {"name": name, "error": str(e)}))
241
+ return
242
+ remaining.discard(name)
243
+ executed.add(name)
244
+
245
+ yield Put(Action(PIPELINE_COMPLETE))
246
+
247
+ return saga
248
+
249
+ def execution_order(self) -> list[str]:
250
+ """Return the topological execution order of phases."""
251
+ dep_graph = {p.name: set(p.depends_on) for p in self.phases}
252
+ order: list[str] = []
253
+ remaining = set(dep_graph.keys())
254
+
255
+ while remaining:
256
+ ready = [n for n in remaining if dep_graph[n].issubset(set(order))]
257
+ if not ready:
258
+ break
259
+ order.extend(sorted(ready))
260
+ remaining -= set(ready)
261
+
262
+ return order
263
+
264
+
265
+ def _make_phase_saga(name: str, handler: Callable) -> Callable:
266
+ """Create a saga for a single phase (used by Fork for parallel phases)."""
267
+
268
+ def phase_saga():
269
+ yield Put(Action(PHASE_START, name))
270
+ try:
271
+ result = yield Call(handler)
272
+ yield Put(Action(PHASE_COMPLETE, {"name": name, "result": result}))
273
+ except Exception as e:
274
+ yield Put(Action(PHASE_FAILED, {"name": name, "error": str(e)}))
275
+
276
+ return phase_saga
milo/plugins.py ADDED
@@ -0,0 +1,168 @@
1
+ """Plugin system with hook registry and Store middleware integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from milo._errors import ErrorCode, PluginError
10
+ from milo._types import Action
11
+
12
+
13
+ class HookRegistry:
14
+ """Registry of named hooks that plugins can subscribe to.
15
+
16
+ Usage::
17
+
18
+ hooks = HookRegistry()
19
+ hooks.define("before_build")
20
+ hooks.define("after_phase", required_args=("phase_name",))
21
+
22
+ @hooks.on("before_build")
23
+ def my_plugin(config):
24
+ print("Building with", config)
25
+
26
+ # Invoke all listeners
27
+ hooks.invoke("before_build", config=my_config)
28
+
29
+ # Freeze after setup (optional but recommended)
30
+ hooks.freeze()
31
+
32
+ # Use as Store middleware
33
+ store = Store(reducer, state, middleware=(hooks.as_middleware(),))
34
+ """
35
+
36
+ def __init__(self) -> None:
37
+ self._hooks: dict[str, list[Callable]] = {}
38
+ self._action_map: dict[str, str] = {} # action_type -> hook_name
39
+ self._frozen = False
40
+ self._lock = threading.Lock()
41
+
42
+ def define(
43
+ self,
44
+ name: str,
45
+ *,
46
+ action_type: str = "",
47
+ description: str = "",
48
+ ) -> None:
49
+ """Define a named hook point.
50
+
51
+ Args:
52
+ name: Hook name (e.g., "before_build", "after_phase").
53
+ action_type: If set, this hook fires automatically when
54
+ the Store dispatches an action of this type.
55
+ description: Human-readable description for docs/introspection.
56
+ """
57
+ if self._frozen:
58
+ raise PluginError(
59
+ ErrorCode.PLG_HOOK,
60
+ f"Cannot define hook '{name}' — registry is frozen",
61
+ )
62
+ with self._lock:
63
+ if name not in self._hooks:
64
+ self._hooks[name] = []
65
+ if action_type:
66
+ self._action_map[action_type] = name
67
+
68
+ def on(self, hook_name: str) -> Callable:
69
+ """Decorator to register a listener on a hook.
70
+
71
+ Usage::
72
+
73
+ @hooks.on("before_build")
74
+ def my_listener(config): ...
75
+ """
76
+
77
+ def decorator(fn: Callable) -> Callable:
78
+ self.register(hook_name, fn)
79
+ return fn
80
+
81
+ return decorator
82
+
83
+ def register(self, hook_name: str, fn: Callable) -> None:
84
+ """Register a listener function on a hook."""
85
+ if self._frozen:
86
+ raise PluginError(
87
+ ErrorCode.PLG_HOOK,
88
+ f"Cannot register on hook '{hook_name}' — registry is frozen",
89
+ )
90
+ with self._lock:
91
+ if hook_name not in self._hooks:
92
+ raise PluginError(
93
+ ErrorCode.PLG_HOOK,
94
+ f"Unknown hook '{hook_name}'",
95
+ suggestion=f"Define it first with hooks.define('{hook_name}')",
96
+ )
97
+ self._hooks[hook_name].append(fn)
98
+
99
+ def invoke(self, hook_name: str, **kwargs: Any) -> list[Any]:
100
+ """Invoke all listeners registered on a hook.
101
+
102
+ Returns a list of return values from each listener.
103
+ Listeners are called in registration order.
104
+ """
105
+ with self._lock:
106
+ listeners = list(self._hooks.get(hook_name, []))
107
+
108
+ results = []
109
+ for fn in listeners:
110
+ try:
111
+ results.append(fn(**kwargs))
112
+ except Exception as e:
113
+ raise PluginError(
114
+ ErrorCode.PLG_HOOK,
115
+ f"Hook '{hook_name}' listener {getattr(fn, '__name__', repr(fn))!r} raised: {e}",
116
+ ) from e
117
+ return results
118
+
119
+ def freeze(self) -> None:
120
+ """Freeze the registry — no more defines or registrations."""
121
+ self._frozen = True
122
+
123
+ @property
124
+ def frozen(self) -> bool:
125
+ return self._frozen
126
+
127
+ def hook_names(self) -> tuple[str, ...]:
128
+ """Return all defined hook names."""
129
+ return tuple(self._hooks.keys())
130
+
131
+ def listeners(self, hook_name: str) -> tuple[Callable, ...]:
132
+ """Return all listeners for a hook."""
133
+ return tuple(self._hooks.get(hook_name, []))
134
+
135
+ def as_middleware(self) -> Callable:
136
+ """Return a Store middleware that fires hooks on matching actions.
137
+
138
+ The middleware intercepts actions whose type matches an
139
+ ``action_type`` registered via ``define()``, invokes the
140
+ corresponding hook, and then passes the action through.
141
+
142
+ Usage::
143
+
144
+ store = Store(reducer, state, middleware=(hooks.as_middleware(),))
145
+ """
146
+ action_map = self._action_map
147
+ registry = self
148
+
149
+ def middleware(store_api: Any) -> Callable:
150
+ def wrapper(next_dispatch: Callable) -> Callable:
151
+ def dispatch(action: Action) -> Any:
152
+ # Fire hook before reducer processes the action
153
+ hook_name = action_map.get(action.type)
154
+ if hook_name:
155
+ registry.invoke(
156
+ hook_name,
157
+ action=action,
158
+ get_state=store_api.get_state
159
+ if hasattr(store_api, "get_state")
160
+ else lambda: None,
161
+ )
162
+ return next_dispatch(action)
163
+
164
+ return dispatch
165
+
166
+ return wrapper
167
+
168
+ return middleware
milo/py.typed ADDED
File without changes
milo/registry.py ADDED
@@ -0,0 +1,213 @@
1
+ """Milo CLI registry — tracks installed CLIs for gateway discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ _REGISTRY_DIR = Path.home() / ".milo"
15
+ _REGISTRY_FILE = _REGISTRY_DIR / "registry.json"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class HealthResult:
20
+ """Result of a CLI health check."""
21
+
22
+ name: str
23
+ reachable: bool
24
+ latency_ms: float
25
+ error: str = ""
26
+ stale: bool = False
27
+
28
+
29
+ def _load() -> dict[str, Any]:
30
+ """Load the registry file."""
31
+ if not _REGISTRY_FILE.exists():
32
+ return {"version": 1, "clis": {}}
33
+ try:
34
+ return json.loads(_REGISTRY_FILE.read_text())
35
+ except json.JSONDecodeError:
36
+ return {"version": 1, "clis": {}}
37
+ except OSError:
38
+ return {"version": 1, "clis": {}}
39
+
40
+
41
+ def _save(data: dict[str, Any]) -> None:
42
+ """Save the registry file."""
43
+ _REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
44
+ _REGISTRY_FILE.write_text(json.dumps(data, indent=2) + "\n")
45
+
46
+
47
+ def install(
48
+ name: str,
49
+ command: list[str],
50
+ *,
51
+ description: str = "",
52
+ version: str = "",
53
+ project_root: str = "",
54
+ ) -> None:
55
+ """Register a CLI in the milo registry.
56
+
57
+ Args:
58
+ name: CLI name (used as namespace prefix in the gateway).
59
+ command: Shell command to start the CLI with --mcp.
60
+ description: Human-readable description.
61
+ version: CLI version string.
62
+ project_root: Absolute path to the project root.
63
+ """
64
+ data = _load()
65
+ entry: dict[str, Any] = {
66
+ "command": command,
67
+ "description": description,
68
+ "version": version,
69
+ }
70
+ if project_root:
71
+ entry["project_root"] = project_root
72
+ entry["fingerprint"] = fingerprint(command, project_root)
73
+ entry["installed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
74
+
75
+ data["clis"][name] = entry
76
+ _save(data)
77
+ sys.stderr.write(f"Registered {name!r} in {_REGISTRY_FILE}\n")
78
+ sys.stderr.write(f" Command: {' '.join(command)}\n")
79
+ if description:
80
+ sys.stderr.write(f" Description: {description}\n")
81
+ sys.stderr.write("\n")
82
+ sys.stderr.write("Tools are available via the milo gateway:\n")
83
+ sys.stderr.write(" uv run python -m milo.gateway --mcp\n")
84
+ sys.stderr.flush()
85
+
86
+
87
+ def uninstall(name: str) -> bool:
88
+ """Remove a CLI from the milo registry. Returns True if it was found."""
89
+ data = _load()
90
+ if name not in data.get("clis", {}):
91
+ sys.stderr.write(f"{name!r} not found in registry\n")
92
+ return False
93
+ del data["clis"][name]
94
+ _save(data)
95
+ sys.stderr.write(f"Removed {name!r} from {_REGISTRY_FILE}\n")
96
+ return True
97
+
98
+
99
+ def list_clis() -> dict[str, dict[str, Any]]:
100
+ """Return all registered CLIs."""
101
+ data = _load()
102
+ return data.get("clis", {})
103
+
104
+
105
+ def registry_path() -> Path:
106
+ """Return the registry file path."""
107
+ return _REGISTRY_FILE
108
+
109
+
110
+ def fingerprint(command: list[str], project_root: str) -> str:
111
+ """Compute a SHA-256 fingerprint for a CLI entry."""
112
+ content = json.dumps({"command": command, "project_root": project_root}, sort_keys=True)
113
+ return hashlib.sha256(content.encode()).hexdigest()
114
+
115
+
116
+ def health_check(name: str) -> HealthResult:
117
+ """Ping a registered CLI with initialize and measure latency."""
118
+ data = _load()
119
+ clis = data.get("clis", {})
120
+ if name not in clis:
121
+ return HealthResult(name=name, reachable=False, latency_ms=0.0, error="Not registered")
122
+
123
+ info = clis[name]
124
+ command = info.get("command", [])
125
+ if not command:
126
+ return HealthResult(name=name, reachable=False, latency_ms=0.0, error="No command")
127
+
128
+ start = time.monotonic()
129
+ try:
130
+ input_data = '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'
131
+ result = subprocess.run(
132
+ command,
133
+ input=input_data,
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=10,
137
+ )
138
+ elapsed = (time.monotonic() - start) * 1000
139
+
140
+ # Check for valid response
141
+ for line in result.stdout.strip().split("\n"):
142
+ if not line:
143
+ continue
144
+ try:
145
+ response = json.loads(line)
146
+ if response.get("id") == 1 and "result" in response:
147
+ # Check staleness
148
+ stored_fp = info.get("fingerprint", "")
149
+ project_root = info.get("project_root", "")
150
+ stale = False
151
+ if stored_fp and project_root:
152
+ current_fp = fingerprint(command, project_root)
153
+ stale = current_fp != stored_fp
154
+ return HealthResult(
155
+ name=name,
156
+ reachable=True,
157
+ latency_ms=round(elapsed, 2),
158
+ stale=stale,
159
+ )
160
+ except json.JSONDecodeError:
161
+ continue
162
+
163
+ return HealthResult(
164
+ name=name,
165
+ reachable=False,
166
+ latency_ms=round(elapsed, 2),
167
+ error="No valid response",
168
+ )
169
+ except subprocess.TimeoutExpired:
170
+ elapsed = (time.monotonic() - start) * 1000
171
+ return HealthResult(
172
+ name=name, reachable=False, latency_ms=round(elapsed, 2), error="Timeout"
173
+ )
174
+ except Exception as e:
175
+ elapsed = (time.monotonic() - start) * 1000
176
+ return HealthResult(name=name, reachable=False, latency_ms=round(elapsed, 2), error=str(e))
177
+
178
+
179
+ def check_all() -> list[HealthResult]:
180
+ """Run health checks on all registered CLIs."""
181
+ clis = list_clis()
182
+ return [health_check(name) for name in clis]
183
+
184
+
185
+ def doctor() -> str:
186
+ """Generate a diagnostic report for all registered CLIs."""
187
+ clis = list_clis()
188
+ if not clis:
189
+ return "No CLIs registered. Use --mcp-install on a milo CLI.\n"
190
+
191
+ lines: list[str] = []
192
+ lines.append("milo gateway — diagnostic report")
193
+ lines.append(f"Registry: {_REGISTRY_FILE}")
194
+ lines.append(f"CLIs: {len(clis)}")
195
+ lines.append("")
196
+
197
+ results = check_all()
198
+ for r in results:
199
+ status = "OK" if r.reachable else "FAIL"
200
+ stale_marker = " [STALE]" if r.stale else ""
201
+ lines.append(f" {r.name}: {status} ({r.latency_ms:.0f}ms){stale_marker}")
202
+ if r.error:
203
+ lines.append(f" Error: {r.error}")
204
+
205
+ lines.append("")
206
+
207
+ healthy = sum(1 for r in results if r.reachable)
208
+ lines.append(f"Summary: {healthy}/{len(results)} reachable")
209
+ stale = sum(1 for r in results if r.stale)
210
+ if stale:
211
+ lines.append(f" {stale} stale (fingerprint mismatch)")
212
+ lines.append("")
213
+ return "\n".join(lines)