oct
Loading...
Searching...
No Matches
executor.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/mcp/executor.py
4
5"""
6Purpose
7-------
8Tool Executor (Layer 4, inner logic) for the ``oct-mcp`` six-layer
9pipeline. Maps validated tool arguments to ``oct`` CLI argv lists and
10delegates execution to :class:`oct.mcp.sandbox.SandboxExecutor`.
11
12Responsibilities
13----------------
14- Provide one ``_build_argv_<tool>()`` function per Phase 5A tool that
15 converts a validated Pydantic model to an ``["oct", subcommand, ...]``
16 argv list.
17- Provide :class:`ToolExecutor` which wires together the Pydantic model
18 dict and the sandbox.
19- Never import oct internals directly - always call the ``oct`` CLI as
20 a subprocess (blueprint ยง5.5 isolation principle, design decision D3).
21
22Diagnostics
23-----------
24Domain: MCP
25Levels:
26 L2 - lifecycle: tool execution
27 L3 - argv details
28 L4 - deep trace
29
30Contracts
31---------
32- This module does not import ``click`` or the MCP SDK.
33- ``_build_argv_*()`` functions accept a ``dict`` (from
34 ``model.model_dump()``) and return a ``list[str]``. They never raise
35 for valid validated input.
36- :meth:`ToolExecutor.execute` never raises - it returns
37 :class:`~oct.mcp.sandbox.SandboxResult` directly from the sandbox.
38
39Dependencies
40------------
41- oct.core.diagnostics (_dbg structured logger)
42- oct.mcp.sandbox (SandboxExecutor and SandboxResult, which run each built argv)
43- oct (not imported; run as python -m oct, the CLI every tool argv invokes)
44"""
45
46from __future__ import annotations
47
48import sys
49from pathlib import Path
50from typing import Callable
51
52from oct.core.diagnostics import _dbg
53
54from oct.mcp.sandbox import SandboxExecutor, SandboxResult
55
56
57# ---------------------------------------------------------------------
58# argv builders (one per tool)
59# ---------------------------------------------------------------------
60
61
62def _build_argv_lint(args: dict, project_root: Path) -> list[str]:
63 """Build argv for ``oct lint``."""
64 cmd = [sys.executable, "-m", "oct", "lint"]
65 if args.get("json_output", True):
66 cmd.append("--json")
67 profile = args.get("profile")
68 if profile:
69 cmd.extend(["--profile", profile])
70 if args.get("changed", False):
71 cmd.append("--changed")
72 if args.get("fix_headers", False):
73 cmd.append("--fix-headers")
74 jobs = args.get("jobs", 1)
75 if jobs > 1:
76 cmd.extend(["--jobs", str(jobs)])
77 paths = args.get("paths", [])
78 cmd.extend(paths)
79 return cmd
80
81
82def _build_argv_health(args: dict, project_root: Path) -> list[str]:
83 """Build argv for ``oct health``."""
84 cmd = [sys.executable, "-m", "oct", "health"]
85 if args.get("json_output", True):
86 cmd.append("--json")
87 if args.get("verbose", False):
88 cmd.append("--verbose")
89 return cmd
90
91
92def _build_argv_skeleton(args: dict, project_root: Path) -> list[str]:
93 """Build argv for ``oct export-skeleton``.
94
95 OI-502: no longer emits ``--output-dir``; that flag is not accepted
96 by the CLI. The output destination is derived by the CLI from the
97 project root.
98 """
99 cmd = [sys.executable, "-m", "oct", "export-skeleton"]
100 if args.get("json_output", True):
101 cmd.append("--json")
102 return cmd
103
104
105def _build_argv_deps(args: dict, project_root: Path) -> list[str]:
106 """Build argv for ``oct deps``.
107
108 OI-502: no longer emits ``--check``; that flag is not accepted by
109 the CLI. Pin auditing is instead available via ``--audit-pins`` (not
110 modelled here yet).
111 """
112 cmd = [sys.executable, "-m", "oct", "deps"]
113 if args.get("json_output", True):
114 cmd.append("--json")
115 return cmd
116
117
118def _build_argv_test(args: dict, project_root: Path) -> list[str]:
119 """Build argv for ``oct test``.
120
121 OI-502: no longer emits ``--jobs``; the CLI does not accept it and
122 the ``jobs`` field was removed from :class:`OctTestArgs`.
123 """
124 cmd = [sys.executable, "-m", "oct", "test"]
125 if args.get("json_output", True):
126 cmd.append("--json")
127 if args.get("changed", False):
128 cmd.append("--changed")
129 paths = args.get("paths", [])
130 cmd.extend(paths)
131 return cmd
132
133
134def _build_argv_typecheck(args: dict, project_root: Path) -> list[str]:
135 """Build argv for ``oct typecheck``."""
136 cmd = [sys.executable, "-m", "oct", "typecheck"]
137 if args.get("json_output", True):
138 cmd.append("--json")
139 paths = args.get("paths", [])
140 cmd.extend(paths)
141 return cmd
142
143
144def _build_argv_diag(args: dict, project_root: Path) -> list[str]:
145 """Build argv for ``oct diag <subcommand>``.
146
147 OI-502: no longer emits ``--json``; no diag subcommand currently
148 accepts that flag (``validate-config`` may grow one via OI-534, at
149 which point this builder will conditionally re-emit it).
150 """
151 subcommand = args.get("subcommand", "validate-config")
152 cmd = [sys.executable, "-m", "oct", "diag", subcommand]
153 return cmd
154
155
156def _build_argv_git_status(args: dict, project_root: Path) -> list[str]:
157 """Build argv for ``oct git status``."""
158 cmd = [sys.executable, "-m", "oct", "git", "status"]
159 if args.get("json_output", True):
160 cmd.append("--json")
161 if args.get("verbose", False):
162 cmd.append("--verbose")
163 return cmd
164
165
166def _build_argv_git_check(args: dict, project_root: Path) -> list[str]:
167 """Build argv for ``oct git check``."""
168 cmd = [sys.executable, "-m", "oct", "git", "check"]
169 if args.get("json_output", True):
170 cmd.append("--json")
171 if args.get("verbose", False):
172 cmd.append("--verbose")
173 return cmd
174
175
176# ---------------------------------------------------------------------
177# Phase 5B - argv builders for write tools (11)
178# Note: dry_run is determined by SafetyGate, not just by the args dict.
179# Each builder receives a ``_dry_run_override`` key injected by
180# ToolExecutor.execute_write() when SafetyDecision.dry_run is True.
181# ---------------------------------------------------------------------
182
183
184def _build_argv_format(args: dict, project_root: Path) -> list[str]:
185 """Build argv for ``oct format``.
186
187 Uses ``--dry-run`` unless ``apply=True`` AND ``confirm=True``.
188 The safety gate enforces this rule upstream; the argv builder also
189 checks ``_dry_run_override`` (set by execute_write()) as a redundant guard.
190 """
191 cmd = [sys.executable, "-m", "oct", "format"]
192 # SafetyGate passes _dry_run_override=True when apply=False + confirm=True.
193 dry_run = args.get("_dry_run_override", not (args.get("apply") and args.get("confirm")))
194 if dry_run:
195 cmd.append("--dry-run")
196 else:
197 cmd.append("--fix")
198 if args.get("json_output", True):
199 cmd.append("--json")
200 if args.get("changed", False):
201 cmd.append("--changed")
202 if args.get("verbose", False):
203 cmd.append("--verbose")
204 # OI-502: ``jobs`` removed; the formatter argparser does not accept it.
205 cmd.extend(args.get("paths", []))
206 return cmd
207
208
209def _build_argv_docs(args: dict, project_root: Path) -> list[str]:
210 """Build argv for ``oct docs``."""
211 cmd = [sys.executable, "-m", "oct", "docs"]
212 if args.get("all_docs", False):
213 cmd.append("--all")
214 elif args.get("sphinx", False):
215 cmd.append("--sphinx")
216 elif args.get("doxygen", False):
217 cmd.append("--doxygen")
218 if args.get("clean", False):
219 cmd.append("--clean")
220 if args.get("dry_run", True):
221 cmd.append("--dry-run")
222 return cmd
223
224
225def _build_argv_export_source(args: dict, project_root: Path) -> list[str]:
226 """Build argv for ``oct export-source``."""
227 cmd = [sys.executable, "-m", "oct", "export-source"]
228 if args.get("verbose", False):
229 cmd.append("--verbose")
230 if args.get("single_dir", False):
231 cmd.append("--single-dir")
232 if args.get("warn_secrets", True):
233 cmd.append("--warn-secrets")
234 if args.get("block_secrets", False):
235 cmd.append("--block-secrets")
236 return cmd
237
238
239def _build_argv_new(args: dict, project_root: Path) -> list[str]:
240 """Build argv for ``oct new``."""
241 path = args.get("path", "")
242 cmd = [sys.executable, "-m", "oct", "new", path]
243 if args.get("dry_run", True):
244 cmd.append("--dry-run")
245 if args.get("force", False):
246 cmd.append("--force")
247 if args.get("create_test", False):
248 cmd.append("--test")
249 if args.get("verbose", False):
250 cmd.append("--verbose")
251 return cmd
252
253
254def _build_argv_scaffold(args: dict, project_root: Path) -> list[str]:
255 """Build argv for ``oct scaffold``."""
256 name = args.get("name", "")
257 cmd = [sys.executable, "-m", "oct", "scaffold", name]
258 if args.get("dry_run", True):
259 cmd.append("--dry-run")
260 return cmd
261
262
263def _build_argv_clean(args: dict, project_root: Path) -> list[str]:
264 """Build argv for ``oct clean``."""
265 cmd = [sys.executable, "-m", "oct", "clean"]
266 path = args.get("path")
267 if path:
268 cmd.append(path)
269 if args.get("dry_run", True):
270 cmd.append("--dry-run")
271 return cmd
272
273
274def _build_argv_install_hooks(args: dict, project_root: Path) -> list[str]:
275 """Build argv for ``oct install-hooks`` (deprecated; calls git hooks install)."""
276 cmd = [sys.executable, "-m", "oct", "git", "hooks", "install"]
277 if args.get("force", False):
278 cmd.append("--force")
279 return cmd
280
281
282def _build_argv_git_commit(args: dict, project_root: Path) -> list[str]:
283 """Build argv for ``oct git commit``.
284
285 Note: ``--no-verify`` is intentionally omitted - bypassing hooks
286 is never permitted via MCP (design decision D-5B-6).
287 """
288 message = args.get("message", "")
289 cmd = [sys.executable, "-m", "oct", "git", "commit", "-m", message]
290 if args.get("json_output", True):
291 cmd.append("--json")
292 if args.get("force", False):
293 cmd.append("--force")
294 return cmd
295
296
297def _build_argv_git_hooks_install(args: dict, project_root: Path) -> list[str]:
298 """Build argv for ``oct git hooks install``."""
299 cmd = [sys.executable, "-m", "oct", "git", "hooks", "install"]
300 if args.get("force", False):
301 cmd.append("--force")
302 return cmd
303
304
305def _build_argv_git_init(args: dict, project_root: Path) -> list[str]:
306 """Build argv for ``oct git init``."""
307 cmd = [sys.executable, "-m", "oct", "git", "init"]
308 if args.get("dry_run", True):
309 cmd.append("--dry-run")
310 if args.get("github", False):
311 cmd.append("--github")
312 if args.get("no_hooks", False):
313 cmd.append("--no-hooks")
314 return cmd
315
316
317def _build_argv_git_changelog(args: dict, project_root: Path) -> list[str]:
318 """Build argv for ``oct git changelog``."""
319 cmd = [sys.executable, "-m", "oct", "git", "changelog"]
320 if args.get("json_output", True):
321 cmd.append("--json")
322 if args.get("dry_run", True):
323 cmd.append("--dry-run")
324 since = args.get("since")
325 if since:
326 cmd.extend(["--since", since])
327 version = args.get("version")
328 if version:
329 cmd.extend(["--release", version])
330 return cmd
331
332
333# ---------------------------------------------------------------------
334# Argv builder registry
335# ---------------------------------------------------------------------
336
337_ARGV_BUILDERS: dict[str, Callable[[dict, Path], list[str]]] = {
338 # Phase 5A - read-only
339 "oct_lint": _build_argv_lint,
340 "oct_health": _build_argv_health,
341 "oct_skeleton": _build_argv_skeleton,
342 "oct_deps": _build_argv_deps,
343 "oct_test": _build_argv_test,
344 "oct_typecheck": _build_argv_typecheck,
345 "oct_diag": _build_argv_diag,
346 "oct_git_status": _build_argv_git_status,
347 "oct_git_check": _build_argv_git_check,
348 # Phase 5B - write tools
349 "oct_format": _build_argv_format,
350 "oct_docs": _build_argv_docs,
351 "oct_export_source": _build_argv_export_source,
352 "oct_new": _build_argv_new,
353 "oct_scaffold": _build_argv_scaffold,
354 "oct_clean": _build_argv_clean,
355 "oct_install_hooks": _build_argv_install_hooks,
356 "oct_git_commit": _build_argv_git_commit,
357 "oct_git_hooks_install": _build_argv_git_hooks_install,
358 "oct_git_init": _build_argv_git_init,
359 "oct_git_changelog": _build_argv_git_changelog,
360}
361
362
363# ---------------------------------------------------------------------
364# Tool executor
365# ---------------------------------------------------------------------
366
367
369 """Maps MCP tool names and validated args to sandboxed ``oct`` calls.
370
371 Parameters
372 ----------
373 sandbox
374 :class:`~oct.mcp.sandbox.SandboxExecutor` instance (injected
375 so tests can substitute a mock).
376 timeout_default
377 Default subprocess timeout in seconds.
378 timeout_test
379 Override timeout for ``oct_test`` (pytest may need more time).
380 max_output_bytes
381 Hard cap on combined stdout+stderr passed to the sandbox.
382 """
383
385 self,
386 sandbox: SandboxExecutor | None = None,
387 timeout_default: int = 60,
388 timeout_test: int = 300,
389 max_output_bytes: int = 1_048_576,
390 ) -> None:
391 func = "ToolExecutor.__init__"
392 self._sandbox = sandbox or SandboxExecutor()
393 self._timeout_default = timeout_default
394 self._timeout_test = timeout_test
395 self._max_output_bytes = max_output_bytes
396 _dbg("MCP", func, "executor initialised", 2)
397
399 self,
400 tool_name: str,
401 validated_args: dict,
402 project_root: Path,
403 ) -> SandboxResult:
404 """Execute *tool_name* with *validated_args* inside the sandbox.
405
406 Parameters
407 ----------
408 tool_name
409 One of the 9 Phase 5A tool names.
410 validated_args
411 A ``model_dump()`` dict from the corresponding Pydantic model.
412 project_root
413 Absolute project root path (used as subprocess cwd and
414 forwarded to the argv builder).
415
416 Returns
417 -------
418 :class:`~oct.mcp.sandbox.SandboxResult` - never raises.
419 """
420 func = "ToolExecutor.execute"
421 builder = _ARGV_BUILDERS.get(tool_name)
422 if builder is None:
423 msg = f"No argv builder for tool {tool_name!r}"
424 _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1)
425 return SandboxResult(
426 exit_code=1,
427 stdout="",
428 stderr=msg,
429 error_message=msg,
430 )
431
432 cmd = builder(validated_args, project_root)
433 _dbg("MCP", func, f"tool={tool_name} cmd={cmd!r}", 3)
434
435 # oct test gets a longer timeout (pytest can be slow).
436 timeout = (
437 self._timeout_test
438 if tool_name == "oct_test"
439 else self._timeout_default
440 )
441
442 return self._sandbox.run(
443 cmd=cmd,
444 cwd=project_root,
445 timeout=timeout,
446 max_output_bytes=self._max_output_bytes,
447 )
448
450 self,
451 tool_name: str,
452 validated_args: dict,
453 project_root: Path,
454 dry_run_override: bool = False,
455 ) -> SandboxResult:
456 """Execute a write tool, injecting the safety-gate dry-run decision.
457
458 Parameters
459 ----------
460 tool_name
461 One of the 11 Phase 5B write tool names.
462 validated_args
463 A ``model_dump()`` dict from the corresponding Pydantic model.
464 project_root
465 Absolute project root path.
466 dry_run_override
467 When ``True`` (as determined by :class:`~oct.mcp.safety.SafetyGate`),
468 injects ``_dry_run_override=True`` into the args dict so that
469 ``_build_argv_format`` always passes ``--dry-run`` regardless of
470 the ``apply`` field value.
471 """
472 func = "ToolExecutor.execute_write"
473 args = dict(validated_args)
474 if dry_run_override:
475 args["_dry_run_override"] = True
476
477 builder = _ARGV_BUILDERS.get(tool_name)
478 if builder is None:
479 msg = f"No argv builder for write tool {tool_name!r}"
480 _dbg("MCP", func, f"SYSTEM_ERROR: {msg}", 1)
481 return SandboxResult(
482 exit_code=1,
483 stdout="",
484 stderr=msg,
485 error_message=msg,
486 )
487
488 cmd = builder(args, project_root)
489 _dbg("MCP", func, f"tool={tool_name} cmd={cmd!r}", 3)
490
491 # Write tools use the default timeout (doc builds can be slow;
492 # git commit runs the quality gate; both are bounded by timeout_default).
493 return self._sandbox.run(
494 cmd=cmd,
495 cwd=project_root,
496 timeout=self._timeout_default,
497 max_output_bytes=self._max_output_bytes,
498 )
None __init__(self, SandboxExecutor|None sandbox=None, int timeout_default=60, int timeout_test=300, int max_output_bytes=1_048_576)
Definition executor.py:390
SandboxResult execute_write(self, str tool_name, dict validated_args, Path project_root, bool dry_run_override=False)
Definition executor.py:455
SandboxResult execute(self, str tool_name, dict validated_args, Path project_root)
Definition executor.py:403
list[str] _build_argv_git_check(dict args, Path project_root)
Definition executor.py:166
list[str] _build_argv_health(dict args, Path project_root)
Definition executor.py:82
list[str] _build_argv_clean(dict args, Path project_root)
Definition executor.py:263
list[str] _build_argv_install_hooks(dict args, Path project_root)
Definition executor.py:274
list[str] _build_argv_new(dict args, Path project_root)
Definition executor.py:239
list[str] _build_argv_git_init(dict args, Path project_root)
Definition executor.py:305
list[str] _build_argv_docs(dict args, Path project_root)
Definition executor.py:209
list[str] _build_argv_diag(dict args, Path project_root)
Definition executor.py:144
list[str] _build_argv_typecheck(dict args, Path project_root)
Definition executor.py:134
list[str] _build_argv_test(dict args, Path project_root)
Definition executor.py:118
list[str] _build_argv_git_status(dict args, Path project_root)
Definition executor.py:156
list[str] _build_argv_git_changelog(dict args, Path project_root)
Definition executor.py:317
list[str] _build_argv_git_hooks_install(dict args, Path project_root)
Definition executor.py:297
list[str] _build_argv_lint(dict args, Path project_root)
Definition executor.py:62
list[str] _build_argv_deps(dict args, Path project_root)
Definition executor.py:105
list[str] _build_argv_skeleton(dict args, Path project_root)
Definition executor.py:92
list[str] _build_argv_export_source(dict args, Path project_root)
Definition executor.py:225
list[str] _build_argv_format(dict args, Path project_root)
Definition executor.py:184
list[str] _build_argv_scaffold(dict args, Path project_root)
Definition executor.py:254
list[str] _build_argv_git_commit(dict args, Path project_root)
Definition executor.py:282