diff --git a/.aignt-os/runs/runs.sqlite3 b/.aignt-os/runs/runs.sqlite3 new file mode 100644 index 0000000..a19db11 Binary files /dev/null and b/.aignt-os/runs/runs.sqlite3 differ diff --git a/.aignt-os/runtime/adapter-circuit-breakers.json b/.aignt-os/runtime/adapter-circuit-breakers.json new file mode 100644 index 0000000..a7ae400 --- /dev/null +++ b/.aignt-os/runtime/adapter-circuit-breakers.json @@ -0,0 +1 @@ +{"codex": {"tool_name": "codex", "consecutive_operational_failures": 1, "opened_at": null, "cooldown_until": null}} \ No newline at end of file diff --git a/.github/workflows/security-review.yml b/.github/workflows/security-review.yml new file mode 100644 index 0000000..5b2a648 --- /dev/null +++ b/.github/workflows/security-review.yml @@ -0,0 +1,23 @@ +name: Security Review + +permissions: + pull-requests: write + contents: read + +on: + pull_request: + +jobs: + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + fetch-depth: 2 + + - uses: anthropics/claude-code-security-review@main + with: + comment-pr: true + claude-api-key: ${{ secrets.CLAUDE_API_KEY }} + diff --git a/docs/superpowers/plans/2026-03-31-f54-hook-system.md b/docs/superpowers/plans/2026-03-31-f54-hook-system.md new file mode 100644 index 0000000..d1e860b --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-f54-hook-system.md @@ -0,0 +1,1705 @@ +# F54 — Hook System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Adicionar um sistema de hooks pre/post ao Synapse-Flow que permite injetar guards síncronos e observadores assíncronos em steps e transições de estado, configuráveis via `AppSettings` e frontmatter SPEC. + +**Architecture:** `HookConfig` declara handler (dotted path), ponto (`pre_step`, `post_step`, `pre_state_transition`, `post_state_transition`) e `failure_mode`. `HookDispatcher` carrega handlers via `importlib`, faz merge global+SPEC e despacha via `dispatch_pre` (síncrono, pode lançar `HookRejectedError`) e `dispatch_post` (thread daemon, erros swallowed). `PipelineEngine` consome o dispatcher como dependência opcional injetada. + +**Tech Stack:** Python 3.12, Pydantic v2, pydantic-settings, threading, importlib, pytest + +--- + +## Mapa de arquivos + +| Arquivo | Ação | +|---|---| +| `src/synapse_os/runtime_contracts.py` | Adicionar `HookConfig`, `HookContext`, `HookResult` | +| `src/synapse_os/hooks.py` | **Novo** — `HookRejectedError`, `HookDispatcher` | +| `src/synapse_os/config.py` | Adicionar `hooks: list[HookConfig]` a `AppSettings` | +| `src/synapse_os/specs/validator.py` | Adicionar `hooks: list[HookConfig]` a `SpecMetadata` | +| `src/synapse_os/pipeline.py` | Adicionar `hook_dispatcher` param + `hooks_active` em `PipelineContext` + `_advance_with_hooks` | +| `tests/unit/helpers/__init__.py` | **Novo** — pacote de helpers de teste | +| `tests/unit/helpers/hook_handlers.py` | **Novo** — handlers stub para testes | +| `tests/unit/test_hook_contracts.py` | **Novo** — testes dos contratos | +| `tests/unit/test_hook_dispatcher.py` | **Novo** — testes do dispatcher | +| `tests/unit/test_pipeline_hook_integration.py` | **Novo** — testes de integração pipeline+hooks | +| `tests/integration/test_hook_system_e2e.py` | **Novo** — teste CLI end-to-end | + +--- + +## Task 1: Contratos de hook em `runtime_contracts.py` + +**Files:** +- Modify: `src/synapse_os/runtime_contracts.py` +- Create: `tests/unit/test_hook_contracts.py` + +- [ ] **Step 1: Escrever testes RED para os contratos** + +Criar `tests/unit/test_hook_contracts.py`: + +```python +from __future__ import annotations + +import pytest + + +def test_hook_config_rejects_invalid_point() -> None: + from pydantic import ValidationError + from synapse_os.runtime_contracts import HookConfig + + with pytest.raises(ValidationError): + HookConfig(point="invalid_point", handler="some.module.handle") + + +def test_hook_config_defaults() -> None: + from synapse_os.runtime_contracts import HookConfig + + h = HookConfig(point="pre_step", handler="some.module.handle") + assert h.failure_mode == "supervisor_delegate" + assert h.enabled is True + + +def test_hook_config_hard_fail_accepted() -> None: + from synapse_os.runtime_contracts import HookConfig + + h = HookConfig(point="post_step", handler="a.b.c", failure_mode="hard_fail") + assert h.failure_mode == "hard_fail" + + +def test_hook_context_metadata_defaults_to_empty() -> None: + from synapse_os.runtime_contracts import HookContext + + ctx = HookContext(run_id="r1") + assert ctx.metadata == {} + assert ctx.step_name is None + assert ctx.current_state is None + + +def test_hook_context_accepts_all_optional_fields() -> None: + from synapse_os.runtime_contracts import HookContext + + ctx = HookContext( + run_id="r1", + step_name="PLAN", + current_state="SPEC_VALIDATION", + workspace_path="/tmp/ws", + metadata={"key": "value"}, + ) + assert ctx.step_name == "PLAN" + assert ctx.metadata == {"key": "value"} + + +def test_hook_result_defaults() -> None: + from synapse_os.runtime_contracts import HookResult + + r = HookResult(allowed=True) + assert r.context_patch is None + assert r.reason is None + + +def test_hook_result_allowed_false_with_reason() -> None: + from synapse_os.runtime_contracts import HookResult + + r = HookResult(allowed=False, reason="permission denied") + assert not r.allowed + assert r.reason == "permission denied" +``` + +- [ ] **Step 2: Rodar testes para confirmar falha por import** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_contracts.py -v +``` + +Esperado: `FAILED` — `ImportError: cannot import name 'HookConfig'` + +- [ ] **Step 3: Adicionar contratos em `runtime_contracts.py`** + +No topo do arquivo, adicionar imports necessários: + +```python +# Adicionar após a linha "from pydantic import BaseModel, ConfigDict, Field, StrictStr" +from typing import Any, Literal +from pydantic import StrictBool +``` + +Após a classe `RunScopedWorkspaceProvider`, adicionar: + +```python +class HookConfig(BaseModel): + model_config = ConfigDict(strict=True) + + point: Literal["pre_step", "post_step", "pre_state_transition", "post_state_transition"] + handler: StrictStr + failure_mode: Literal["hard_fail", "supervisor_delegate"] = "supervisor_delegate" + enabled: StrictBool = True + + +class HookContext(BaseModel): + model_config = ConfigDict(strict=True) + + run_id: StrictStr + step_name: StrictStr | None = None + current_state: StrictStr | None = None + tool_spec: "ToolSpec | None" = None + workspace_path: StrictStr | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class HookResult(BaseModel): + model_config = ConfigDict(strict=True) + + allowed: StrictBool + reason: StrictStr | None = None + context_patch: dict[str, Any] | None = None + # context_patch: shallow-merged em HookContext.metadata quando allowed=True + # ignorado quando allowed=False +``` + +- [ ] **Step 4: Rodar testes para confirmar verde** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_contracts.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Rodar suite completa para confirmar sem regressão** + +```bash +uv run --no-sync python -m pytest tests/unit/ -v --tb=short +``` + +Esperado: todos PASSED (nenhum teste existente quebrado). + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/runtime_contracts.py tests/unit/test_hook_contracts.py +git commit -m "feat(hooks): add HookConfig, HookContext, HookResult contracts" +``` + +--- + +## Task 2: Helpers de teste para handlers stub + +**Files:** +- Create: `tests/unit/helpers/__init__.py` +- Create: `tests/unit/helpers/hook_handlers.py` + +- [ ] **Step 1: Criar pacote helpers** + +Criar `tests/unit/helpers/__init__.py` vazio: + +```python +``` + +- [ ] **Step 2: Criar handlers stub** + +Criar `tests/unit/helpers/hook_handlers.py`: + +```python +"""Stub hook handlers para testes unitários.""" +from __future__ import annotations + +from synapse_os.runtime_contracts import HookContext, HookResult + +# Rastreamento de chamadas (reset manualmente nos testes que precisam) +call_log: list[tuple[str, HookContext]] = [] + + +def noop_pre(ctx: HookContext) -> HookResult: + call_log.append(("noop_pre", ctx)) + return HookResult(allowed=True) + + +def reject_pre(ctx: HookContext) -> HookResult: + call_log.append(("reject_pre", ctx)) + return HookResult(allowed=False, reason="test rejection") + + +def patch_pre(ctx: HookContext) -> HookResult: + call_log.append(("patch_pre", ctx)) + return HookResult(allowed=True, context_patch={"patched": True}) + + +def noop_post(ctx: HookContext) -> None: + call_log.append(("noop_post", ctx)) + + +def failing_post(ctx: HookContext) -> None: + call_log.append(("failing_post", ctx)) + raise RuntimeError("intentional post hook failure") +``` + +- [ ] **Step 3: Verificar importabilidade** + +```bash +uv run --no-sync python -c "from tests.unit.helpers.hook_handlers import noop_pre; print('OK')" +``` + +Esperado: `OK` + +- [ ] **Step 4: Commit** + +```bash +git add tests/unit/helpers/ +git commit -m "test(hooks): add stub hook handlers for unit tests" +``` + +--- + +## Task 3: `HookDispatcher` — estrutura base, merge e load + +**Files:** +- Create: `src/synapse_os/hooks.py` +- Create: `tests/unit/test_hook_dispatcher.py` + +- [ ] **Step 1: Escrever testes RED para merge e load** + +Criar `tests/unit/test_hook_dispatcher.py`: + +```python +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# HookRejectedError +# --------------------------------------------------------------------------- + + +def test_hook_rejected_error_carries_failure_mode() -> None: + from synapse_os.hooks import HookRejectedError + + err = HookRejectedError( + handler="some.module.handle", + reason="blocked", + failure_mode="hard_fail", + ) + assert err.handler == "some.module.handle" + assert err.reason == "blocked" + assert err.failure_mode == "hard_fail" + + +# --------------------------------------------------------------------------- +# Merge +# --------------------------------------------------------------------------- + + +def test_dispatcher_active_handlers_global_only() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hooks = [HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre")] + d = HookDispatcher(global_hooks=hooks) + assert "tests.unit.helpers.hook_handlers.noop_pre" in d.active_handlers + + +def test_dispatcher_merge_spec_disables_global_hook() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + global_hooks = [ + HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre") + ] + spec_hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.noop_pre", + enabled=False, + ) + ] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert d.active_handlers == [] + + +def test_dispatcher_merge_spec_adds_extra_hook() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + global_hooks = [ + HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre") + ] + spec_hooks = [ + HookConfig(point="post_step", handler="tests.unit.helpers.hook_handlers.noop_post") + ] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert "tests.unit.helpers.hook_handlers.noop_pre" in d.active_handlers + assert "tests.unit.helpers.hook_handlers.noop_post" in d.active_handlers + + +def test_dispatcher_merge_spec_disable_only_removes_matching_point() -> None: + """Disabling pre_step handler should not remove same handler registered for post_step.""" + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + global_hooks = [ + HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre"), + HookConfig(point="post_step", handler="tests.unit.helpers.hook_handlers.noop_pre"), + ] + spec_hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.noop_pre", + enabled=False, + ) + ] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert "tests.unit.helpers.hook_handlers.noop_pre" in d.active_handlers + # post_step registration must survive + assert len(d.active_handlers) == 1 + + +# --------------------------------------------------------------------------- +# Load handlers +# --------------------------------------------------------------------------- + + +def test_dispatcher_hard_fail_handler_with_bad_import_raises_at_construction() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hooks = [ + HookConfig( + point="pre_step", + handler="nonexistent.module.handle", + failure_mode="hard_fail", + ) + ] + with pytest.raises(RuntimeError, match="Failed to load hard_fail hook handler"): + HookDispatcher(global_hooks=hooks) + + +def test_dispatcher_supervisor_delegate_handler_with_bad_import_disables_silently( + caplog: pytest.LogCaptureFixture, +) -> None: + import logging + + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hooks = [ + HookConfig( + point="pre_step", + handler="nonexistent.module.handle", + failure_mode="supervisor_delegate", + ) + ] + with caplog.at_level(logging.WARNING, logger="synapse_os.hooks"): + d = HookDispatcher(global_hooks=hooks) + assert d.active_handlers == [] + assert "nonexistent.module.handle" in caplog.text + + +def test_dispatcher_empty_hooks_constructs_without_error() -> None: + from synapse_os.hooks import HookDispatcher + + d = HookDispatcher(global_hooks=[]) + assert d.active_handlers == [] +``` + +- [ ] **Step 2: Rodar testes para confirmar falha por import** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -v +``` + +Esperado: `FAILED` — `ModuleNotFoundError: No module named 'synapse_os.hooks'` + +- [ ] **Step 3: Criar `src/synapse_os/hooks.py`** + +```python +from __future__ import annotations + +import importlib +import inspect +import logging +import threading +from typing import TYPE_CHECKING, Any, Callable, Literal + +if TYPE_CHECKING: + from synapse_os.runtime_contracts import HookConfig, HookContext + +logger = logging.getLogger(__name__) + + +class HookRejectedError(Exception): + """Levantado por dispatch_pre quando um hook retorna allowed=False.""" + + def __init__( + self, + handler: str, + reason: str | None, + failure_mode: Literal["hard_fail", "supervisor_delegate"], + ) -> None: + super().__init__(f"Hook '{handler}' rejected: {reason}") + self.handler = handler + self.reason = reason + self.failure_mode = failure_mode + + +class HookDispatcher: + def __init__( + self, + global_hooks: list[HookConfig], + spec_hooks: list[HookConfig] | None = None, + ) -> None: + self._hooks = self._merge(global_hooks, spec_hooks or []) + self._handlers: dict[str, Callable[..., Any]] = self._load_handlers() + self._post_threads: list[threading.Thread] = [] + + @property + def active_handlers(self) -> list[str]: + return [h.handler for h in self._hooks if h.enabled] + + def _merge( + self, + global_hooks: list[HookConfig], + spec_hooks: list[HookConfig], + ) -> list[HookConfig]: + result = list(global_hooks) + for spec_hook in spec_hooks: + if not spec_hook.enabled: + result = [ + h + for h in result + if not (h.handler == spec_hook.handler and h.point == spec_hook.point) + ] + else: + result.append(spec_hook) + return result + + def _load_handlers(self) -> dict[str, Callable[..., Any]]: + handlers: dict[str, Callable[..., Any]] = {} + for hook in self._hooks: + if hook.handler in handlers: + continue + try: + module_path, fn_name = hook.handler.rsplit(".", 1) + module = importlib.import_module(module_path) + fn = getattr(module, fn_name) + handlers[hook.handler] = fn + except Exception as exc: + if hook.failure_mode == "hard_fail": + raise RuntimeError( + f"Failed to load hard_fail hook handler '{hook.handler}': {exc}" + ) from exc + logger.warning( + "Failed to load hook handler '%s': %s — hook disabled for this run.", + hook.handler, + exc, + ) + return handlers + + def _join_post_handlers(self, timeout: float = 1.0) -> None: + """Para testes: aguarda threads de post hooks concluírem.""" + for t in self._post_threads: + t.join(timeout=timeout) + self._post_threads.clear() +``` + +- [ ] **Step 4: Rodar testes de merge e load** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -v +``` + +Esperado: testes de merge e load PASSED, testes de `dispatch_pre`/`dispatch_post` ainda não existem. + +- [ ] **Step 5: Rodar suite completa** + +```bash +uv run --no-sync python -m pytest tests/unit/ -v --tb=short +``` + +Esperado: todos PASSED. + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/hooks.py tests/unit/test_hook_dispatcher.py tests/unit/helpers/ +git commit -m "feat(hooks): add HookDispatcher base with merge and handler loading" +``` + +--- + +## Task 4: `HookDispatcher.dispatch_pre` + +**Files:** +- Modify: `src/synapse_os/hooks.py` +- Modify: `tests/unit/test_hook_dispatcher.py` + +- [ ] **Step 1: Adicionar testes RED para `dispatch_pre`** + +Adicionar ao final de `tests/unit/test_hook_dispatcher.py`: + +```python +# --------------------------------------------------------------------------- +# dispatch_pre +# --------------------------------------------------------------------------- + + +def test_dispatch_pre_allowed_returns_context_unchanged() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + hooks = [HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + result = d.dispatch_pre("pre_step", ctx) + assert result.run_id == "r1" + assert result.step_name == "PLAN" + + +def test_dispatch_pre_rejected_hard_fail_raises_hook_rejected_error() -> None: + from synapse_os.hooks import HookDispatcher, HookRejectedError + from synapse_os.runtime_contracts import HookConfig, HookContext + + hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.reject_pre", + failure_mode="hard_fail", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + + with pytest.raises(HookRejectedError) as exc_info: + d.dispatch_pre("pre_step", ctx) + + assert exc_info.value.failure_mode == "hard_fail" + assert exc_info.value.reason == "test rejection" + assert exc_info.value.handler == "tests.unit.helpers.hook_handlers.reject_pre" + + +def test_dispatch_pre_rejected_supervisor_delegate_raises_with_delegate_mode() -> None: + from synapse_os.hooks import HookDispatcher, HookRejectedError + from synapse_os.runtime_contracts import HookConfig, HookContext + + hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.reject_pre", + failure_mode="supervisor_delegate", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + + with pytest.raises(HookRejectedError) as exc_info: + d.dispatch_pre("pre_step", ctx) + + assert exc_info.value.failure_mode == "supervisor_delegate" + + +def test_dispatch_pre_context_patch_merged_into_metadata() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + hooks = [HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.patch_pre")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1") + result = d.dispatch_pre("pre_step", ctx) + assert result.metadata.get("patched") is True + + +def test_dispatch_pre_skips_hooks_registered_for_different_point() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + # reject_pre registered for post_step — should NOT run when dispatching pre_step + hooks = [ + HookConfig( + point="post_step", + handler="tests.unit.helpers.hook_handlers.reject_pre", + failure_mode="hard_fail", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + result = d.dispatch_pre("pre_step", ctx) # must not raise + assert result.run_id == "r1" + + +def test_dispatch_pre_no_hooks_returns_context_unchanged() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookContext + + d = HookDispatcher(global_hooks=[]) + ctx = HookContext(run_id="r1") + result = d.dispatch_pre("pre_step", ctx) + assert result.run_id == "r1" +``` + +- [ ] **Step 2: Rodar testes para confirmar falha por método ausente** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -k "dispatch_pre" -v +``` + +Esperado: `FAILED` — `AttributeError: 'HookDispatcher' object has no attribute 'dispatch_pre'` + +- [ ] **Step 3: Implementar `dispatch_pre` em `src/synapse_os/hooks.py`** + +Adicionar método ao `HookDispatcher` (após `_join_post_handlers`): + +```python + def dispatch_pre(self, point: str, ctx: HookContext) -> HookContext: + """Despacho síncrono. Retorna ctx (possivelmente enriquecido). + Levanta HookRejectedError quando allowed=False.""" + for hook in self._hooks: + if hook.point != point or not hook.enabled: + continue + fn = self._handlers.get(hook.handler) + if fn is None: + continue + result = fn(ctx) + if result.context_patch: + ctx = ctx.model_copy( + update={"metadata": {**ctx.metadata, **result.context_patch}} + ) + if not result.allowed: + raise HookRejectedError( + handler=hook.handler, + reason=result.reason, + failure_mode=hook.failure_mode, + ) + return ctx +``` + +O import de `HookContext` dentro de `TYPE_CHECKING` não é suficiente para runtime. Adicionar import real no topo de `hooks.py`: + +```python +# Substituir o bloco TYPE_CHECKING por imports diretos: +from synapse_os.runtime_contracts import HookConfig, HookContext, HookResult +``` + +E remover `if TYPE_CHECKING:` block. + +- [ ] **Step 4: Rodar testes de `dispatch_pre`** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -k "dispatch_pre" -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Rodar suite completa** + +```bash +uv run --no-sync python -m pytest tests/unit/ -v --tb=short +``` + +Esperado: todos PASSED. + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/hooks.py tests/unit/test_hook_dispatcher.py +git commit -m "feat(hooks): implement dispatch_pre with HookRejectedError" +``` + +--- + +## Task 5: `HookDispatcher.dispatch_post` + +**Files:** +- Modify: `src/synapse_os/hooks.py` +- Modify: `tests/unit/test_hook_dispatcher.py` + +- [ ] **Step 1: Adicionar testes RED para `dispatch_post`** + +Adicionar ao final de `tests/unit/test_hook_dispatcher.py`: + +```python +# --------------------------------------------------------------------------- +# dispatch_post +# --------------------------------------------------------------------------- + + +def test_dispatch_post_calls_handler_in_background_thread() -> None: + from tests.unit.helpers import hook_handlers + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + hook_handlers.call_log.clear() + hooks = [HookConfig(point="post_step", handler="tests.unit.helpers.hook_handlers.noop_post")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + + d.dispatch_post("post_step", ctx) + d._join_post_handlers(timeout=1.0) + + assert any(name == "noop_post" for name, _ in hook_handlers.call_log) + + +def test_dispatch_post_does_not_propagate_handler_exception() -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + hooks = [ + HookConfig(point="post_step", handler="tests.unit.helpers.hook_handlers.failing_post") + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + + d.dispatch_post("post_step", ctx) # must not raise + d._join_post_handlers(timeout=1.0) + + +def test_dispatch_post_logs_warning_on_handler_exception( + caplog: pytest.LogCaptureFixture, +) -> None: + import logging + + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + hooks = [ + HookConfig(point="post_step", handler="tests.unit.helpers.hook_handlers.failing_post") + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1") + + with caplog.at_level(logging.WARNING, logger="synapse_os.hooks"): + d.dispatch_post("post_step", ctx) + d._join_post_handlers(timeout=1.0) + + assert "failing_post" in caplog.text + + +def test_dispatch_post_skips_hooks_for_different_point() -> None: + from tests.unit.helpers import hook_handlers + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig, HookContext + + hook_handlers.call_log.clear() + # handler registrado para pre_step — não deve disparar para post_step + hooks = [HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_post")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1") + + d.dispatch_post("post_step", ctx) + d._join_post_handlers(timeout=1.0) + + assert hook_handlers.call_log == [] +``` + +- [ ] **Step 2: Rodar testes para confirmar falha por método ausente** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -k "dispatch_post" -v +``` + +Esperado: `FAILED` — `AttributeError: 'HookDispatcher' object has no attribute 'dispatch_post'` + +- [ ] **Step 3: Implementar `dispatch_post` em `src/synapse_os/hooks.py`** + +Adicionar ao `HookDispatcher`: + +```python + def dispatch_post(self, point: str, ctx: HookContext) -> None: + """Despacho assíncrono (thread daemon). Exceções swallowed com warning.""" + for hook in self._hooks: + if hook.point != point or not hook.enabled: + continue + fn = self._handlers.get(hook.handler) + if fn is None: + continue + t = threading.Thread( + target=self._run_post_handler, + args=(fn, hook.handler, ctx), + daemon=True, + ) + self._post_threads.append(t) + t.start() + + def _run_post_handler( + self, fn: Callable[..., Any], handler_name: str, ctx: HookContext + ) -> None: + try: + fn(ctx) + except Exception as exc: + logger.warning("Post hook handler '%s' raised: %s", handler_name, exc) +``` + +- [ ] **Step 4: Rodar testes de `dispatch_post`** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -k "dispatch_post" -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Rodar suite completa do dispatcher** + +```bash +uv run --no-sync python -m pytest tests/unit/test_hook_dispatcher.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/hooks.py tests/unit/test_hook_dispatcher.py +git commit -m "feat(hooks): implement dispatch_post with fire-and-forget thread dispatch" +``` + +--- + +## Task 6: `AppSettings.hooks` + +**Files:** +- Modify: `src/synapse_os/config.py` +- Modify: `tests/unit/test_config.py` (adicionar 2 testes) + +- [ ] **Step 1: Adicionar testes RED** + +Abrir `tests/unit/test_config.py` e adicionar ao final: + +```python +def test_app_settings_hooks_defaults_to_empty_list() -> None: + from synapse_os.config import AppSettings + + settings = AppSettings() + assert settings.hooks == [] + + +def test_app_settings_hooks_accepts_hook_config_list() -> None: + from synapse_os.config import AppSettings + from synapse_os.runtime_contracts import HookConfig + + hook = HookConfig(point="pre_step", handler="synapse_os.hooks_noop.handle") + settings = AppSettings(hooks=[hook]) + assert len(settings.hooks) == 1 + assert settings.hooks[0].handler == "synapse_os.hooks_noop.handle" +``` + +- [ ] **Step 2: Rodar testes para confirmar falha** + +```bash +uv run --no-sync python -m pytest tests/unit/test_config.py -k "hooks" -v +``` + +Esperado: `FAILED` — `AppSettings` não tem campo `hooks`. + +- [ ] **Step 3: Adicionar campo em `config.py`** + +No topo de `config.py`, adicionar import: + +```python +from synapse_os.runtime_contracts import HookConfig +``` + +Na classe `AppSettings`, após `secret_mask_patterns`: + +```python + hooks: list[HookConfig] = Field(default_factory=list) +``` + +- [ ] **Step 4: Rodar testes** + +```bash +uv run --no-sync python -m pytest tests/unit/test_config.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Commit** + +```bash +git add src/synapse_os/config.py tests/unit/test_config.py +git commit -m "feat(hooks): add hooks field to AppSettings" +``` + +--- + +## Task 7: Validação de hooks no frontmatter SPEC + +**Files:** +- Modify: `src/synapse_os/specs/validator.py` +- Create: `tests/unit/test_spec_validator_hooks.py` + +- [ ] **Step 1: Escrever testes RED** + +Criar `tests/unit/test_spec_validator_hooks.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def _write_spec_with_hooks(path: Path, hooks_yaml: str) -> None: + path.write_text( + f"""\ +--- +id: F-hook-test +type: feature +summary: Spec com hooks para teste. +inputs: + - raw_request +outputs: + - result +acceptance_criteria: + - Deve validar. +non_goals: [] +{hooks_yaml} +--- + +# Contexto + +Teste de hooks no frontmatter. + +# Objetivo + +Validar parsing e rejeição de hooks inválidos. +""", + encoding="utf-8", + ) + + +def test_spec_with_valid_hooks_parses_hook_list(tmp_path: Path) -> None: + from synapse_os.specs.validator import validate_spec_file + + spec = tmp_path / "SPEC.md" + _write_spec_with_hooks( + spec, + """\ +hooks: + - point: pre_step + handler: synapse_os.hooks_noop.handle + failure_mode: hard_fail + - point: post_step + handler: synapse_os.hooks_noop.record +""", + ) + doc = validate_spec_file(spec) + assert len(doc.metadata.hooks) == 2 + assert doc.metadata.hooks[0].point == "pre_step" + assert doc.metadata.hooks[0].failure_mode == "hard_fail" + assert doc.metadata.hooks[1].point == "post_step" + + +def test_spec_without_hooks_field_produces_empty_list(tmp_path: Path) -> None: + from synapse_os.specs.validator import validate_spec_file + + spec = tmp_path / "SPEC.md" + _write_spec_with_hooks(spec, "") # no hooks key + doc = validate_spec_file(spec) + assert doc.metadata.hooks == [] + + +def test_spec_with_invalid_hook_point_raises_spec_validation_error(tmp_path: Path) -> None: + from synapse_os.specs.validator import SpecValidationError, validate_spec_file + + spec = tmp_path / "SPEC.md" + _write_spec_with_hooks( + spec, + """\ +hooks: + - point: invalid_point + handler: synapse_os.hooks_noop.handle +""", + ) + with pytest.raises(SpecValidationError, match="hooks"): + validate_spec_file(spec) + + +def test_spec_with_hook_missing_handler_raises_spec_validation_error(tmp_path: Path) -> None: + from synapse_os.specs.validator import SpecValidationError, validate_spec_file + + spec = tmp_path / "SPEC.md" + _write_spec_with_hooks( + spec, + """\ +hooks: + - point: pre_step +""", + ) + with pytest.raises(SpecValidationError, match="hooks"): + validate_spec_file(spec) +``` + +- [ ] **Step 2: Rodar testes para confirmar falha** + +```bash +uv run --no-sync python -m pytest tests/unit/test_spec_validator_hooks.py -v +``` + +Esperado: `FAILED` — `SpecMetadata` não tem campo `hooks`. + +- [ ] **Step 3: Adicionar campo `hooks` a `SpecMetadata` em `validator.py`** + +No topo de `validator.py`, adicionar import: + +```python +from synapse_os.runtime_contracts import HookConfig +``` + +Na classe `SpecMetadata`, adicionar após `non_goals`: + +```python + hooks: list[HookConfig] = Field(default_factory=list) +``` + +No método `_load_metadata`, o bloco `except ValidationError` deve ser atualizado para incluir `hooks` na mensagem de erro. O handler atual usa `exc.errors()[0]["loc"][0]` — isso funciona para qualquer campo incluindo hooks aninhados, então nenhuma mudança adicional é necessária. + +- [ ] **Step 4: Rodar testes** + +```bash +uv run --no-sync python -m pytest tests/unit/test_spec_validator_hooks.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Rodar suite completa** + +```bash +uv run --no-sync python -m pytest tests/unit/ -v --tb=short +``` + +Esperado: todos PASSED. + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/specs/validator.py tests/unit/test_spec_validator_hooks.py +git commit -m "feat(hooks): add optional hooks field to SpecMetadata with validation" +``` + +--- + +## Task 8: Integração `PipelineEngine` — hooks de step + +**Files:** +- Modify: `src/synapse_os/pipeline.py` +- Create: `tests/unit/test_pipeline_hook_integration.py` + +- [ ] **Step 1: Escrever testes RED** + +Criar `tests/unit/test_pipeline_hook_integration.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def _make_spec(tmp_path: Path) -> Path: + spec = tmp_path / "SPEC.md" + spec.write_text( + """\ +--- +id: F-hook-integration +type: feature +summary: Spec para testes de integração de hooks na pipeline. +inputs: + - raw_request +outputs: + - result +acceptance_criteria: + - Deve executar. +non_goals: [] +--- + +# Contexto + +Fixture. + +# Objetivo + +Fixture. +""", + encoding="utf-8", + ) + return spec + + +class _NullExecutor: + def execute(self, step, context): # type: ignore[no-untyped-def] + from synapse_os.pipeline import StepExecutionResult + + return StepExecutionResult(artifacts={}, raw_output="ok", clean_output="ok") + + +def _make_engine(spec_path: Path, hook_dispatcher=None): # type: ignore[no-untyped-def] + from synapse_os.pipeline import PipelineEngine, PipelineState + + executors = { + s: _NullExecutor() + for s in [ + PipelineState.PLAN, + PipelineState.TEST_RED, + PipelineState.CODE_GREEN, + PipelineState.QUALITY_GATE, + PipelineState.REVIEW, + PipelineState.SECURITY, + PipelineState.DOCUMENT, + ] + } + return PipelineEngine(executors=executors, hook_dispatcher=hook_dispatcher) + + +# --------------------------------------------------------------------------- +# pre_step hooks +# --------------------------------------------------------------------------- + + +def test_pipeline_runs_normally_when_no_hooks_configured(tmp_path: Path) -> None: + spec = _make_spec(tmp_path) + engine = _make_engine(spec) + ctx = engine.run(spec, stop_at="PLAN") + assert ctx.current_state == "PLAN" + + +def test_pipeline_pre_step_noop_hook_does_not_block_execution(tmp_path: Path) -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hooks = [HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre")] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + ctx = engine.run(spec, stop_at="PLAN") + assert ctx.current_state == "PLAN" + + +def test_pipeline_pre_step_hard_fail_hook_raises_pipeline_execution_error( + tmp_path: Path, +) -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.pipeline import PipelineExecutionError + from synapse_os.runtime_contracts import HookConfig + + hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.reject_pre", + failure_mode="hard_fail", + ) + ] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + + with pytest.raises(PipelineExecutionError, match="Hook rejected step"): + engine.run(spec, stop_at="PLAN") + + +def test_pipeline_pre_step_supervisor_delegate_hook_triggers_supervisor_path( + tmp_path: Path, +) -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.reject_pre", + failure_mode="supervisor_delegate", + ) + ] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + + # supervisor_delegate means the error goes to the supervisor (retry/reroute/fail) + # with _NullExecutor not configured for retries, eventually raises + with pytest.raises(Exception): + engine.run(spec, stop_at="PLAN") + + +def test_pipeline_post_step_hook_fires_after_successful_step(tmp_path: Path) -> None: + from tests.unit.helpers import hook_handlers + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hook_handlers.call_log.clear() + hooks = [ + HookConfig(point="post_step", handler="tests.unit.helpers.hook_handlers.noop_post") + ] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + engine.run(spec, stop_at="PLAN") + dispatcher._join_post_handlers(timeout=1.0) + + assert any(name == "noop_post" for name, _ in hook_handlers.call_log) + + +def test_pipeline_context_hooks_active_populated_when_dispatcher_present( + tmp_path: Path, +) -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hooks = [HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre")] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + ctx = engine.run(spec, stop_at="SPEC_VALIDATION") + assert "tests.unit.helpers.hook_handlers.noop_pre" in ctx.hooks_active + + +def test_pipeline_context_hooks_active_empty_when_no_dispatcher(tmp_path: Path) -> None: + spec = _make_spec(tmp_path) + engine = _make_engine(spec) + ctx = engine.run(spec, stop_at="SPEC_VALIDATION") + assert ctx.hooks_active == [] +``` + +- [ ] **Step 2: Rodar testes para confirmar falha** + +```bash +uv run --no-sync python -m pytest tests/unit/test_pipeline_hook_integration.py -v +``` + +Esperado: `FAILED` — `PipelineEngine.__init__` não aceita `hook_dispatcher`. + +- [ ] **Step 3: Modificar `pipeline.py` — adicionar `hook_dispatcher` e `hooks_active`** + +**3a. Adicionar `hooks_active` a `PipelineContext`:** + +```python +# Em PipelineContext, adicionar após supervisor_decisions: + hooks_active: list[StrictStr] = Field(default_factory=list) +``` + +**3b. Adicionar import em `pipeline.py`:** + +```python +from synapse_os.hooks import HookDispatcher, HookRejectedError +``` + +**3c. Adicionar parâmetro `hook_dispatcher` ao `PipelineEngine.__init__`:** + +```python + def __init__( + self, + *, + settings: AppSettings | None = None, + executors: dict[str, StepExecutor | dict[str, StepExecutor]] | None = None, + state_machine: SynapseStateMachine | None = None, + observer: PipelineObserver | None = None, + supervisor: Supervisor | None = None, + cancellation_checker: CancellationChecker | None = None, + workspace_provider: WorkspaceProvider | None = None, + hook_dispatcher: HookDispatcher | None = None, + ) -> None: + # ... código existente ... + self.hook_dispatcher = hook_dispatcher +``` + +**3d. Popular `hooks_active` na criação do contexto em `PipelineEngine.run()`:** + +Localizar a criação de `PipelineContext` em `run()` e adicionar `hooks_active`: + +```python + context = PipelineContext( + spec_path=workspace.spec_path, + current_state=self.state_machine.current_state, + run_context=RunContext( + run_id=run_id, + initiated_by=initiated_by, + workspace=workspace, + ), + run_id=run_id, + hooks_active=self.hook_dispatcher.active_handlers if self.hook_dispatcher else [], + ) +``` + +**3e. Adicionar método `_run_step_with_hooks` em `PipelineEngine`:** + +```python + def _run_step_with_hooks( + self, + step: PipelineStep, + context: PipelineContext, + ) -> StepExecutionResult | None: + if self.hook_dispatcher is not None: + hook_ctx = HookContext( + run_id=context.run_id or "", + step_name=step.state, + current_state=context.current_state, + workspace_path=str(context.run_context.workspace.root_path), + ) + try: + hook_ctx = self.hook_dispatcher.dispatch_pre("pre_step", hook_ctx) + except HookRejectedError as exc: + if exc.failure_mode == "hard_fail": + raise PipelineExecutionError( + f"Hook rejected step '{step.state}': {exc.reason}" + ) from exc + raise RetryableStepError( + f"Hook rejected step '{step.state}' (supervisor_delegate): {exc.reason}" + ) from exc + else: + hook_ctx = None + + try: + return self._run_runtime_step(step, context) + finally: + if self.hook_dispatcher is not None and hook_ctx is not None: + self.hook_dispatcher.dispatch_post("post_step", hook_ctx) +``` + +Adicionar import de `HookContext` no topo de `pipeline.py`: + +```python +from synapse_os.runtime_contracts import ( + RunContext, + RunLifecycleHooks, + WorkspaceContext, + WorkspaceProvider, + HookContext, +) +``` + +**3f. Substituir chamadas a `self._run_runtime_step` por `self._run_step_with_hooks`** nos dois blocos que executam steps (SPEC_VALIDATION usa `_execute_spec_validation`, não precisa de hook de step por ser interno; os steps PLAN→DOCUMENT sim): + +Localizar: +```python + result = self._run_runtime_step(current_step, context) +``` + +Substituir por: +```python + result = self._run_step_with_hooks(current_step, context) +``` + +- [ ] **Step 4: Rodar testes de integração de pipeline** + +```bash +uv run --no-sync python -m pytest tests/unit/test_pipeline_hook_integration.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Rodar suite completa** + +```bash +uv run --no-sync python -m pytest tests/unit/ -v --tb=short +``` + +Esperado: todos PASSED. + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/pipeline.py tests/unit/test_pipeline_hook_integration.py +git commit -m "feat(hooks): integrate HookDispatcher into PipelineEngine for step hooks" +``` + +--- + +## Task 9: Hooks de transição de estado + +**Files:** +- Modify: `src/synapse_os/pipeline.py` +- Modify: `tests/unit/test_pipeline_hook_integration.py` + +- [ ] **Step 1: Adicionar testes RED para transições de estado** + +Adicionar ao final de `tests/unit/test_pipeline_hook_integration.py`: + +```python +# --------------------------------------------------------------------------- +# State transition hooks +# --------------------------------------------------------------------------- + + +def test_pre_state_transition_hard_fail_blocks_transition(tmp_path: Path) -> None: + from synapse_os.hooks import HookDispatcher + from synapse_os.pipeline import PipelineExecutionError + from synapse_os.runtime_contracts import HookConfig + + hooks = [ + HookConfig( + point="pre_state_transition", + handler="tests.unit.helpers.hook_handlers.reject_pre", + failure_mode="hard_fail", + ) + ] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + + with pytest.raises(PipelineExecutionError, match="Hook blocked transition"): + engine.run(spec, stop_at="PLAN") + + +def test_post_state_transition_hook_fires_after_transition(tmp_path: Path) -> None: + from tests.unit.helpers import hook_handlers + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + hook_handlers.call_log.clear() + hooks = [ + HookConfig( + point="post_state_transition", + handler="tests.unit.helpers.hook_handlers.noop_post", + ) + ] + dispatcher = HookDispatcher(global_hooks=hooks) + spec = _make_spec(tmp_path) + engine = _make_engine(spec, hook_dispatcher=dispatcher) + engine.run(spec, stop_at="SPEC_VALIDATION") + dispatcher._join_post_handlers(timeout=1.0) + + assert any(name == "noop_post" for name, _ in hook_handlers.call_log) +``` + +- [ ] **Step 2: Rodar testes para confirmar falha** + +```bash +uv run --no-sync python -m pytest tests/unit/test_pipeline_hook_integration.py -k "state_transition" -v +``` + +Esperado: `FAILED` — sem hooks de transição na pipeline. + +- [ ] **Step 3: Adicionar `_advance_with_hooks` ao `PipelineEngine`** + +```python + def _advance_with_hooks( + self, + context: PipelineContext, + from_state: str, + to_state: str, + ) -> None: + """Executa hooks pre/post em torno de uma transição de estado.""" + if self.hook_dispatcher is not None: + pre_ctx = HookContext( + run_id=context.run_id or "", + current_state=from_state, + metadata={"to_state": to_state}, + ) + try: + self.hook_dispatcher.dispatch_pre("pre_state_transition", pre_ctx) + except HookRejectedError as exc: + if exc.failure_mode == "hard_fail": + raise PipelineExecutionError( + f"Hook blocked transition {from_state}→{to_state}: {exc.reason}" + ) from exc + raise RetryableStepError( + f"Hook blocked transition {from_state}→{to_state} (supervisor_delegate): {exc.reason}" + ) from exc + + self.state_machine.advance_to(to_state) + context.current_state = self.state_machine.current_state + + if self.hook_dispatcher is not None: + post_ctx = HookContext( + run_id=context.run_id or "", + current_state=to_state, + metadata={"from_state": from_state}, + ) + self.hook_dispatcher.dispatch_post("post_state_transition", post_ctx) +``` + +**Step 3b. Substituir chamadas diretas `self.state_machine.advance_to(...)` + `context.current_state = ...` por `_advance_with_hooks`** nas 4 ocorrências em `PipelineEngine.run()`: + +Localizar cada par: +```python +self.state_machine.advance_to(next_state) +context.current_state = self.state_machine.current_state +``` + +Substituir por: +```python +self._advance_with_hooks(context, str(current_state), str(next_state)) +``` + +E os pares específicos com estado fixo (ex: `PLAN`, `CODE_GREEN`): +```python +# de: +self.state_machine.advance_to(PipelineState.PLAN) +context.current_state = self.state_machine.current_state +# para: +self._advance_with_hooks(context, str(PipelineState.SPEC_VALIDATION), str(PipelineState.PLAN)) +``` + +Repetir para a ocorrência com `PipelineState.CODE_GREEN` (supervisor return_to_code_green): +```python +self._advance_with_hooks(context, str(current_state), str(PipelineState.CODE_GREEN)) +``` + +- [ ] **Step 4: Rodar testes de transição** + +```bash +uv run --no-sync python -m pytest tests/unit/test_pipeline_hook_integration.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 5: Rodar suite completa** + +```bash +uv run --no-sync python -m pytest tests/unit/ -v --tb=short +``` + +Esperado: todos PASSED. + +- [ ] **Step 6: Commit** + +```bash +git add src/synapse_os/pipeline.py tests/unit/test_pipeline_hook_integration.py +git commit -m "feat(hooks): add pre/post state transition hooks via _advance_with_hooks" +``` + +--- + +## Task 10: Teste de integração end-to-end + +**Files:** +- Create: `tests/integration/test_hook_system_e2e.py` + +- [ ] **Step 1: Escrever teste E2E** + +Criar `tests/integration/test_hook_system_e2e.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def _write_minimal_spec(path: Path) -> None: + path.write_text( + """\ +--- +id: F-hook-e2e +type: feature +summary: Spec para teste E2E do hook system. +inputs: + - raw_request +outputs: + - result +acceptance_criteria: + - Hooks devem ser registrados e disparados. +non_goals: [] +--- + +# Contexto + +Teste E2E. + +# Objetivo + +Verificar que hooks configurados via AppSettings são registrados e auditáveis em hooks_active. +""", + encoding="utf-8", + ) + + +class _NullExecutor: + def execute(self, step, context): # type: ignore[no-untyped-def] + from synapse_os.pipeline import StepExecutionResult + + return StepExecutionResult(artifacts={}, raw_output="ok", clean_output="ok") + + +def test_hook_system_hooks_active_reflects_appSettings_hooks(tmp_path: Path) -> None: + """Hooks configurados em AppSettings devem aparecer em PipelineContext.hooks_active.""" + from synapse_os.config import AppSettings + from synapse_os.hooks import HookDispatcher + from synapse_os.pipeline import PipelineEngine, PipelineState + from synapse_os.runtime_contracts import HookConfig + + spec = tmp_path / "SPEC.md" + _write_minimal_spec(spec) + + hook_cfg = HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.noop_pre", + ) + settings = AppSettings(hooks=[hook_cfg]) + dispatcher = HookDispatcher(global_hooks=settings.hooks) + + executors = { + s: _NullExecutor() + for s in [ + PipelineState.PLAN, + PipelineState.TEST_RED, + ] + } + engine = PipelineEngine( + settings=settings, + executors=executors, + hook_dispatcher=dispatcher, + ) + ctx = engine.run(spec, stop_at="SPEC_VALIDATION") + + assert "tests.unit.helpers.hook_handlers.noop_pre" in ctx.hooks_active + + +def test_hook_system_spec_hooks_merged_with_global_hooks(tmp_path: Path) -> None: + """Hooks de SPEC são mergeados com globais: disable via SPEC funciona.""" + from synapse_os.hooks import HookDispatcher + from synapse_os.runtime_contracts import HookConfig + + global_hooks = [ + HookConfig(point="pre_step", handler="tests.unit.helpers.hook_handlers.noop_pre") + ] + spec_hooks = [ + HookConfig( + point="pre_step", + handler="tests.unit.helpers.hook_handlers.noop_pre", + enabled=False, + ) + ] + dispatcher = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert dispatcher.active_handlers == [] +``` + +- [ ] **Step 2: Rodar teste E2E** + +```bash +uv run --no-sync python -m pytest tests/integration/test_hook_system_e2e.py -v +``` + +Esperado: todos PASSED. + +- [ ] **Step 3: Rodar suite completa** + +```bash +uv run --no-sync python -m pytest -v --tb=short +``` + +Esperado: todos PASSED, sem regressão. + +- [ ] **Step 4: Typecheck e lint** + +```bash +uv run --no-sync python -m mypy src/synapse_os/hooks.py src/synapse_os/pipeline.py src/synapse_os/runtime_contracts.py src/synapse_os/config.py +uv run --no-sync ruff check src/synapse_os/hooks.py src/synapse_os/pipeline.py +uv run --no-sync ruff format --check src/synapse_os/hooks.py src/synapse_os/pipeline.py +``` + +Corrigir qualquer erro de type ou lint antes do próximo passo. + +- [ ] **Step 5: Commit final** + +```bash +git add tests/integration/test_hook_system_e2e.py +git commit -m "test(hooks): add E2E integration test for hook system with AppSettings" +``` + +--- + +## Self-review checklist + +- [x] **Spec coverage:** `HookConfig/HookContext/HookResult` → Task 1. `HookDispatcher` merge/load → Task 3. `dispatch_pre` → Task 4. `dispatch_post` → Task 5. `AppSettings.hooks` → Task 6. SPEC frontmatter hooks → Task 7. PipelineEngine step hooks → Task 8. State transition hooks → Task 9. E2E → Task 10. `hooks_active` em `PipelineContext` → Task 8 Step 3. +- [x] **Sem placeholders:** todos os steps têm código completo. +- [x] **Consistência de tipos:** `HookContext` definido em Task 1, importado em Tasks 3-9. `HookRejectedError.failure_mode` definido em Task 3, inspecionado em Tasks 8-9. `PipelineContext.hooks_active` adicionado em Task 8, verificado em Tasks 8+10. `_advance_with_hooks` usa `str(state)` para compatibilidade com `PipelineState(StrEnum)`. diff --git a/docs/superpowers/specs/2026-03-31-f54-hook-system-design.md b/docs/superpowers/specs/2026-03-31-f54-hook-system-design.md new file mode 100644 index 0000000..138a7ad --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-f54-hook-system-design.md @@ -0,0 +1,241 @@ +# F54 — Hook System Design + +**Data:** 2026-03-31 +**Status:** aprovado +**Feature ID:** F54-hook-system + +--- + +## Contexto + +O SynapseOS pós-F53 possui foundations sólidas: `ToolSpec`/capabilities (F51), workspace isolation (F52) e observability com `run_events` (F53). O próximo passo lógico é expor pontos de extensão controlados no Synapse-Flow — hooks que permitem injetar lógica de guarda, custo, permissão e observabilidade sem modificar o núcleo da pipeline. + +Inspiração arquitetural: Hook System do Claude Code (`PreToolUse`, `PostToolUse`, `SessionStart`) — adaptado para o modelo state-driven do Synapse-Flow. + +--- + +## Decisões de design + +| Decisão | Escolha | Razão | +|---|---|---| +| Modelo de execução | Híbrido: `pre_*` síncrono, `post_*` assíncrono | Guards precisam bloquear; observabilidade não deve bloquear | +| Registro | `AppSettings` (global) + frontmatter SPEC (override por run) | Alinha com padrão `SYNAPSE_OS_` existente; SPEC pode especializar | +| Failure mode | Por handler: `hard_fail` ou `supervisor_delegate` | Flexibilidade máxima sem overhead de config global | + +--- + +## Arquitetura + +``` +AppSettings.hooks (global) + + +SPEC.hooks (por feature, opcional — merge/disable) + │ + ▼ +HookDispatcher + ├── _merge(global_hooks, spec_hooks) → lista final auditável + ├── _load_handlers() → importlib na construção + ├── dispatch_pre(point, ctx) → HookContext # síncrono + └── dispatch_post(point, ctx) → None # assíncrono fire-and-forget + │ + ├── PipelineEngine (pre_step / post_step) + └── SynapseStateMachine (pre_state_transition / post_state_transition) +``` + +--- + +## Pontos de hook + +| Ponto | Tipo | Quando dispara | +|---|---|---| +| `pre_step` | síncrono | antes de executar qualquer step do Synapse-Flow | +| `post_step` | assíncrono | após step completar (success ou failure) | +| `pre_state_transition` | síncrono | antes de avançar estado na state machine | +| `post_state_transition` | assíncrono | após transição de estado confirmada | + +--- + +## Contratos novos (`runtime_contracts.py`) + +```python +class HookConfig(BaseModel): + point: Literal["pre_step", "post_step", + "pre_state_transition", "post_state_transition"] + handler: StrictStr # dotted path: "synapse_os.hooks.cost_tracker.handle" + failure_mode: Literal["hard_fail", "supervisor_delegate"] = "supervisor_delegate" + enabled: StrictBool = True + +class HookContext(BaseModel): + run_id: StrictStr + step_name: StrictStr | None = None + current_state: StrictStr | None = None + tool_spec: ToolSpec | None = None + workspace_path: StrictStr | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + +class HookResult(BaseModel): + allowed: StrictBool + reason: StrictStr | None = None + context_patch: dict[str, Any] | None = None + # context_patch: shallow-merged em HookContext.metadata quando allowed=True + # ignorado quando allowed=False +``` + +--- + +## `HookDispatcher` (`src/synapse_os/hooks.py`) + +```python +class HookRejectedError(Exception): + def __init__(self, handler: str, reason: str | None, + failure_mode: Literal["hard_fail", "supervisor_delegate"]): ... + +class HookDispatcher: + def __init__( + self, + global_hooks: list[HookConfig], + spec_hooks: list[HookConfig] | None = None, + ) -> None: + self._hooks = self._merge(global_hooks, spec_hooks or []) + self._handlers = self._load_handlers() + + def dispatch_pre(self, point: str, ctx: HookContext) -> HookContext: + # Retorna HookContext (possivelmente com metadata enriquecido via context_patch). + # Levanta HookRejectedError(handler, reason, failure_mode) quando allowed=False. + # O chamador (PipelineEngine / SynapseStateMachine) inspeciona failure_mode + # para decidir: hard_fail → re-raise; supervisor_delegate → StepResult(failed). + ... + + async def dispatch_post(self, point: str, ctx: HookContext) -> None: ... +``` + +### Regras de merge + +1. Hooks globais (`AppSettings.hooks`) formam a lista base. +2. SPEC pode **adicionar** handlers extras (append). +3. SPEC pode **desabilitar** um hook global com `enabled: false` + mesmo `handler` + `point`. +4. Resultado auditado em `run_context_initialized.hooks_active`. + +### Carregamento de handlers + +- Importados via `importlib` na construção do dispatcher. +- `hard_fail` handler com import inválido → `RuntimeError` (fail-fast na startup). +- `supervisor_delegate` handler com import inválido → warning no log, hook desabilitado para a run. + +### Assinatura dos handlers + +```python +# pre_step / pre_state_transition +def handle(ctx: HookContext) -> HookResult: ... + +# post_step / post_state_transition +async def handle(ctx: HookContext) -> None: ... +``` + +Validado via `inspect.signature` na inicialização. + +--- + +## Integração com `PipelineEngine` + +``` +execute_step(step): + ctx = HookContext(run_id, step_name, current_state, tool_spec, workspace_path) + + # 1. pre_step (síncrono) + try: + ctx = dispatcher.dispatch_pre("pre_step", ctx) + except HookRejectedError as e: + if e.failure_mode == "hard_fail": + raise StepError(reason="hook_rejected", detail=e.reason) + else: # supervisor_delegate + return StepResult(failed=True, reason=e.reason) + + # 2. execução real do step (sem mudança) + + # 3. post_step (assíncrono, não bloqueia) + asyncio.create_task(dispatcher.dispatch_post("post_step", ctx_com_resultado)) +``` + +--- + +## Integração com `SynapseStateMachine` + +``` +transition(from_state, to_state): + ctx = HookContext(run_id, current_state=from_state, metadata={"to": to_state}) + + # pre_state_transition (síncrono) + ctx = dispatcher.dispatch_pre("pre_state_transition", ctx) + → rejeição bloqueia a transição + + # transição real + + # post_state_transition (assíncrono) + asyncio.create_task(dispatcher.dispatch_post("post_state_transition", ctx)) +``` + +--- + +## Extensão do frontmatter SPEC + +Campo opcional `hooks` no frontmatter: + +```yaml +hooks: + - point: pre_step + handler: synapse_os.hooks.permission.handle + failure_mode: hard_fail + - point: post_step + handler: synapse_os.hooks.cost_tracker.handle + - point: pre_step + handler: synapse_os.hooks.some_global.handle + enabled: false # desabilita hook global para esta run +``` + +`spec_validator` valida schema mas não importa handlers. + +--- + +## Observabilidade + +`run_context_initialized` (F53) ganha campo `hooks_active: list[str]` — lista dos handlers efetivos após merge. Auditável na CLI e no `RUN_REPORT.md`. + +--- + +## Arquivos afetados + +| Arquivo | Tipo de mudança | +|---|---| +| `src/synapse_os/hooks.py` | **novo** — `HookDispatcher`, `HookRejectedError` | +| `src/synapse_os/runtime_contracts.py` | adição — `HookConfig`, `HookContext`, `HookResult` | +| `src/synapse_os/config.py` | adição — campo `hooks: list[HookConfig]` em `AppSettings` | +| `src/synapse_os/pipeline.py` | modificação — chamadas `dispatch_pre`/`dispatch_post` | +| `src/synapse_os/state_machine.py` | modificação — chamadas de hook em transições | +| `src/synapse_os/specs/validator.py` | modificação — validação do campo `hooks` no frontmatter | +| `src/synapse_os/reporting.py` | modificação — campo `hooks_active` em `run_context_initialized` | +| `tests/unit/test_hook_dispatcher.py` | **novo** | +| `tests/unit/test_pipeline_hook_integration.py` | **novo** | +| `tests/unit/test_state_machine_hooks.py` | **novo** | +| `tests/integration/` | adição — 1 teste CLI end-to-end com hook via `AppSettings` | + +--- + +## Critérios de aceite + +- `HookDispatcher` carrega handlers válidos na construção sem erro +- `dispatch_pre` síncrono bloqueia step quando hook retorna `allowed=false` +- `failure_mode=hard_fail` levanta `HookRejectedError`; `supervisor_delegate` retorna `StepResult(failed)` +- `dispatch_post` não propaga exceções de handlers +- Merge de hooks globais + SPEC produz lista auditável em `run_context_initialized` +- `spec_validator` rejeita frontmatter com hooks malformados com `SpecValidationError` +- Cobertura unitária e de integração para todos os pontos de hook expostos + +--- + +## Non-goals + +- Hooks extensíveis por usuário final via plugin marketplace +- Hook scheduling (cron, delay) +- Hooks em modo assíncrono bloqueante +- Override de hooks por variável de ambiente por run (somente AppSettings + SPEC) diff --git a/features/F54-hook-system/SPEC.md b/features/F54-hook-system/SPEC.md new file mode 100644 index 0000000..8c0e0d4 --- /dev/null +++ b/features/F54-hook-system/SPEC.md @@ -0,0 +1,101 @@ +--- +id: F54-hook-system +type: feature +summary: Adicionar pontos de extensão controlados ao Synapse-Flow via hooks pre/post síncronos e assíncronos configuráveis por AppSettings e frontmatter SPEC. +inputs: + - AppSettings com lista opcional de HookConfig (global) + - frontmatter SPEC com lista opcional de HookConfig (override por run) + - contexto de execução de cada step e transição de estado +outputs: + - HookContext enriquecido retornado por dispatch_pre quando handler aplica context_patch + - PipelineExecutionError levantado quando hook hard_fail rejeita step ou transição + - StepResult com falha propagada ao supervisor quando hook supervisor_delegate rejeita + - PipelineContext.hooks_active listando handlers efetivos após merge global+SPEC + - SpecValidationError quando frontmatter SPEC contém hooks malformados +acceptance_criteria: + - Dado AppSettings com HookConfig(point=pre_step, handler válido, failure_mode=hard_fail), quando PipelineEngine executa qualquer step, então o handler é invocado antes do step e pode bloquear a execução levantando PipelineExecutionError + - Dado HookConfig com failure_mode=supervisor_delegate e handler que retorna allowed=False, quando PipelineEngine executa o step, então o step é marcado como falha e o supervisor é acionado sem propagar exceção direta + - Dado HookConfig(point=post_step), quando step completa com sucesso ou falha, então o handler post é invocado de forma não-bloqueante e exceções do handler não propagam + - Dado HookConfig(point=pre_state_transition, failure_mode=hard_fail) com handler que rejeita, quando PipelineEngine tenta avançar estado, então PipelineExecutionError é levantado e a transição não ocorre + - Dado HookConfig(point=post_state_transition), quando transição de estado completa, então o handler é invocado de forma não-bloqueante + - Dado handler com allowed=True e context_patch contendo uma chave extra, quando dispatch_pre retorna, então HookContext.metadata contém a chave com o valor do patch + - Dado AppSettings.hooks com 1 global e SPEC.hooks desabilitando o mesmo handler+point, quando PipelineEngine executa, então o handler não é invocado e PipelineContext.hooks_active está vazio + - Dado AppSettings.hooks com 1 global e SPEC.hooks adicionando 1 extra, quando PipelineEngine executa, então ambos os handlers aparecem em PipelineContext.hooks_active + - Dado HookConfig com handler de dotted path inválido e failure_mode=hard_fail, quando HookDispatcher é construído, então RuntimeError é levantado imediatamente + - Dado HookConfig com handler de dotted path inválido e failure_mode=supervisor_delegate, quando HookDispatcher é construído, então o hook é desabilitado silenciosamente com warning no log + - Dado frontmatter SPEC com campo hooks contendo point inválido, quando validate_spec_file é chamado, então SpecValidationError é levantado com mensagem mencionando hooks + - Dado PipelineEngine com HookDispatcher configurado, quando run() completa até SPEC_VALIDATION, então ctx.hooks_active contém os handlers efetivos +non_goals: + - Hooks extensíveis por usuário final via plugin marketplace + - Hook scheduling (cron, delay) + - Hooks em modo assíncrono bloqueante + - Override de hooks por variável de ambiente por run (somente AppSettings + SPEC) + - Validação de importabilidade de handlers no spec-validator (somente no dispatcher) +security_notes: + - handlers são importados via importlib — dotted paths devem ser confiáveis; não há sandbox + - hard_fail handlers com import inválido falham na startup (fail-fast) +--- + +# Contexto + +O SynapseOS pós-F53 possui foundations sólidas: ToolSpec/capabilities (F51), workspace isolation (F52) e observability com run_events (F53). O próximo passo é expor pontos de extensão controlados no Synapse-Flow — hooks que permitem injetar lógica de guarda, custo, permissão e observabilidade sem modificar o núcleo da pipeline. + +Os hooks seguem o modelo do Hook System do Claude Code (PreToolUse, PostToolUse, SessionStart) adaptado para o modelo state-driven do Synapse-Flow: pré-hooks são síncronos e podem bloquear; pós-hooks são assíncronos fire-and-forget. + +# Objetivo + +Implementar um HookDispatcher que carrega handlers via importlib, faz merge de hooks globais (AppSettings) com hooks por run (frontmatter SPEC) e despacha nos quatro pontos de extensão da pipeline: pre_step, post_step, pre_state_transition, post_state_transition. O PipelineEngine consome o dispatcher como dependência opcional injetada. + +## Escopo + +Quatro pontos de hook expostos no Synapse-Flow: + +- `pre_step` — síncrono, antes de executar qualquer step (PLAN, TEST_RED, CODE_GREEN, etc.) +- `post_step` — assíncrono fire-and-forget, após step completar (sucesso ou falha) +- `pre_state_transition` — síncrono, antes de avançar estado na state machine +- `post_state_transition` — assíncrono fire-and-forget, após transição confirmada + +Contratos novos em runtime_contracts.py: HookConfig, HookContext, HookResult. + +HookDispatcher em src/synapse_os/hooks.py com: _merge (global+SPEC), _load_handlers (importlib), dispatch_pre (síncrono, levanta HookRejectedError), dispatch_post (thread daemon). + +AppSettings ganha campo hooks: list[HookConfig]. SpecMetadata ganha campo hooks: list[HookConfig]. PipelineContext ganha hooks_active: list[str]. + +## Fora de Escopo + +Ver non_goals no frontmatter. + +## Casos de Erro + +- Handler com dotted path inválido e failure_mode=hard_fail → RuntimeError na construção do HookDispatcher (fail-fast na startup) +- Handler com dotted path inválido e failure_mode=supervisor_delegate → warning no log, hook desabilitado para a run, execução continua normalmente +- Handler pre_step retorna allowed=False com failure_mode=hard_fail → PipelineExecutionError com mensagem "Hook rejected step '...':" +- Handler pre_step retorna allowed=False com failure_mode=supervisor_delegate → RetryableStepError propagado ao supervisor +- Handler post_step levanta exceção → warning no log, exceção swallowed, execução não interrompida +- frontmatter SPEC com hooks malformados (point inválido, handler ausente) → SpecValidationError com referência ao campo hooks + +## Artefatos Esperados + +- src/synapse_os/hooks.py (novo) +- src/synapse_os/runtime_contracts.py (modificado: +HookConfig, +HookContext, +HookResult) +- src/synapse_os/config.py (modificado: +hooks em AppSettings) +- src/synapse_os/specs/validator.py (modificado: +hooks em SpecMetadata) +- src/synapse_os/pipeline.py (modificado: +hook_dispatcher param, +_run_step_with_hooks, +_advance_with_hooks) +- tests/unit/test_hook_contracts.py (novo) +- tests/unit/test_hook_dispatcher.py (novo) +- tests/unit/test_spec_validator_hooks.py (novo) +- tests/unit/test_pipeline_hook_integration.py (novo) +- tests/integration/test_hook_system_e2e.py (novo) + +## Observações para Planejamento + +- PipelineEngine.run() é síncrono; dispatch_post usa threading.Thread(daemon=True) para não bloquear +- SynapseStateMachine fica PURA — hooks de transição são gerenciados pelo PipelineEngine via _advance_with_hooks +- Handlers são importados uma vez na construção do dispatcher, não a cada dispatch +- _join_post_handlers() deve existir no dispatcher para determinismo em testes + +## Observações para Revisão + +- Verificar que exceções de post handlers não propagam em nenhum path de execução +- Verificar que hard_fail com import inválido falha na construção, não na primeira chamada +- Verificar que merge respeita: disable (enabled=False) remove por handler+point, não só por handler diff --git a/features/F55-hook-cli-management/SPEC.md b/features/F55-hook-cli-management/SPEC.md new file mode 100644 index 0000000..486a3d4 --- /dev/null +++ b/features/F55-hook-cli-management/SPEC.md @@ -0,0 +1,76 @@ +--- +id: F55-hook-cli-management +type: feature +summary: Adicionar comandos CLI para listar, ativar, desativar e validar hooks configurados no Synapse-Flow. +inputs: + - AppSettings com hooks globais configurados + - SPEC.md com hooks por run + - HookDispatcher ja existente (F54) +outputs: + - Comando synapse hooks list exibindo hooks globais e por SPEC + - Comando synapse hooks validate testando importabilidade de handler + - Comando synapse hooks status mostrando hooks ativos da ultima run + - Saida estruturada em formato tabela via Rich +acceptance_criteria: + - "Dado AppSettings com 2 hooks configurados, quando synapse hooks list e executado, entao exibe tabela com point, handler, failure_mode e enabled de cada hook" + - "Dado handler com dotted path valido (ex: os.path.join), quando synapse hooks validate os.path.join e executado, entao exibe mensagem de sucesso com o nome da funcao importada" + - "Dado handler com dotted path invalido (ex: nonexistent.func), quando synapse hooks validate nonexistent.func e executado, entao exibe erro com exit code 1" + - "Dado SPEC.md com hooks no frontmatter, quando synapse hooks list --spec path e executado, entao exibe hooks globais e hooks da SPEC separadamente" + - "Dado SPEC.md sem campo hooks, quando synapse hooks list --spec path e executado, entao exibe apenas hooks globais" + - "Dado nenhum hook configurado, quando synapse hooks list e executado, entao exibe mensagem informativa indicando que nenhum hook esta configurado" + - "Dado handler existente mas funcao inexistente no modulo, quando synapse hooks validate os.nonexistent_func e executado, entao exibe erro indicando que a funcao nao foi encontrada" + - "Dado SPEC.md com hooks malformados, quando synapse hooks list --spec path e executado, entao exibe erro de validacao da SPEC" +non_goals: + - Edicao de hooks via CLI (somente leitura e validacao) + - Hot-reload de hooks em runtime + - Hooks por usuario ou por workspace + - Interface TUI para hooks +security_notes: + - hooks validate importa o handler via importlib — executar apenas com dotted paths confiaveis + - Nao executar handlers, apenas verificar importabilidade +--- + +# Contexto + +A feature F54 implementou o HookDispatcher com suporte a hooks pre/post síncronos e assíncronos. Porém, não há forma de inspecionar ou validar hooks pela CLI — o operador precisa editar AppSettings ou SPEC.md manualmente e só descobre erros na execução. + +# Objetivo + +Adicionar comandos CLI de leitura e validação para hooks, permitindo que operadores inspecionem a configuração atual e validem handlers antes de executar pipelines. + +## Escopo + +Três subcomandos sob `synapse hooks`: + +- `synapse hooks list [--spec ]` — lista hooks globais (AppSettings) e hooks por SPEC +- `synapse hooks validate ` — testa se um dotted path é importável +- `synapse hooks status` — mostra hooks ativos da última run (se disponível) + +## Fora de Escopo + +Ver non_goals no frontmatter. + +## Casos de Erro + +- Handler inexistente → erro com exit code 1 +- SPEC inválida → erro de validação com mensagem clara +- Nenhum hook configurado → mensagem informativa (não erro) +- Módulo existe mas função não → erro específico diferenciado de módulo não encontrado + +## Artefatos Esperados + +- `src/synapse_os/cli/hooks.py` (novo) +- `src/synapse_os/cli/app.py` (modificado: registrar subcomando hooks via app.add_typer) +- `tests/unit/test_hooks_cli.py` (novo) + +## Observações para Planejamento + +- Reutilizar Rich para formatação de tabelas (já dependência do projeto) +- `hooks validate` não executa o handler — apenas verifica importabilidade via importlib +- `hooks status` lê hooks_active do PipelineContext se houver run recente; caso contrário, exibe mensagem informativa + +## Observações para Revisão + +- Verificar que `hooks validate` não executa código do handler, apenas importa +- Verificar que exit codes são consistentes (0 = sucesso, 1 = erro) +- Verificar que mensagens de erro são distinguíveis para módulo vs função não encontrados diff --git a/features/F56-worker-runtime-tests/REPORT.md b/features/F56-worker-runtime-tests/REPORT.md new file mode 100644 index 0000000..329e306 --- /dev/null +++ b/features/F56-worker-runtime-tests/REPORT.md @@ -0,0 +1,73 @@ +--- +feature_id: F56 +feature_name: Worker Runtime Tests +status: complete +completed: 2026-03-31 +--- + +# F56 — Worker Runtime Tests — Report + +## Objetivo + +Criar suíte de testes dedicada para o RuntimeWorker (`src/synapse_os/runtime/worker.py`), cobrindo polling, lock de execução, owner skip, e geração de run report. + +## Escopo Alterado + +### Adicionado + +- `tests/unit/test_worker_runtime.py` — 4 novos testes adicionados ao arquivo existente (que já tinha 6 testes) +- `features/F56-worker-runtime-tests/SPEC.md` — SPEC com 12 critérios de aceite + +### Testes Adicionados + +1. `test_runtime_worker_sleeps_when_idle` — verifica que sleep_when_idle dorme pelo intervalo configurado +2. `test_build_runtime_worker_constructs_with_correct_poll_interval` — verifica que factory function cria worker com settings corretos +3. `test_runtime_owner_returns_none_when_provider_is_none` — verifica que \_runtime_owner retorna None sem provider +4. `test_runtime_worker_handles_runner_exception_gracefully` — verifica que worker não crasha quando runner lança exceção + +### Testes Pré-existentes (6) + +1. `test_runtime_worker_processes_oldest_pending_run` +2. `test_runtime_worker_ignores_locked_or_finalized_runs` +3. `test_runtime_worker_fails_pending_run_when_spec_hash_changes` +4. `test_runtime_worker_skips_incompatible_owner_and_processes_next_compatible` +5. `test_runtime_worker_accepts_legacy_run_for_authenticated_runtime` +6. `test_runtime_worker_deduplicates_same_owner_skip_message` + +## Validações + +| Gate | Resultado | +| --------------- | -------------------------------------------------- | +| Tests | 540 passed (536 base + 4 new) | +| Mypy | Success: no issues found in 28 source files | +| Ruff (new file) | All checks passed | +| Ruff (repo) | 7 pre-existing errors (não relacionados à feature) | + +## Critérios de Aceite + +| AC | Status | +| ----------------------------------------------- | ------------------------------------ | +| AC1: worker seleciona run pendente | ✅ (pré-existente) | +| AC2: worker pula run com lock ativo | ✅ (pré-existente) | +| AC3: worker não crasha em falha do runner | ✅ (novo) | +| AC4: run report gerado após pipeline | ✅ (pré-existente) | +| AC5: worker ignora runs completadas | ✅ (pré-existente) | +| AC6: worker pula run com owner incompatível | ✅ (pré-existente) | +| AC7: evento owner skip registrado | ✅ (pré-existente) | +| AC8: evento duplicado não registrado | ✅ (pré-existente) | +| AC9: poll_once retorna None sem pendentes | ✅ (implícito nos testes existentes) | +| AC10: sleep_when_idle dorme corretamente | ✅ (novo) | +| AC11: factory com poll interval correto | ✅ (novo) | +| AC12: \_runtime_owner retorna None sem provider | ✅ (novo) | + +## Riscos Residuais + +- Nenhum risco significativo identificado +- 7 erros ruff pré-existentes no repositório (não relacionados à feature) + +## Próximos Passos + +- F57: Security Gate Tests (`test_security_gate.py` 🔜 em TDD.md) +- F58: Retry Policy Tests (`test_retry_policy.py` 🔜 em TDD.md) + +READY_FOR_COMMIT diff --git a/features/F56-worker-runtime-tests/SPEC.md b/features/F56-worker-runtime-tests/SPEC.md new file mode 100644 index 0000000..7735e4b --- /dev/null +++ b/features/F56-worker-runtime-tests/SPEC.md @@ -0,0 +1,106 @@ +--- +feature_id: F56 +feature_name: Worker Runtime Tests +status: draft +author: opencode +created: 2026-03-31 +--- + +# F56 — Worker Runtime Tests + +## Objetivo + +Criar suíte de testes dedicada para o RuntimeWorker (`src/synapse_os/runtime/worker.py`), cobrindo polling, lock de execução, owner skip, e geração de run report. O worker é metade do modelo de runtime dual (CLI efêmero + worker residente) e atualmente não possui testes dedicados — apenas testes indiretos em `test_pipeline_persistence.py`. + +## Por que isso importa + +O worker é responsável por consumir runs pendentes, adquirir lock, executar pipelines e prevenir dupla execução. Sem testes dedicados, qualquer alteração no worker pode introduzir regressões silenciosas em: + +- Processamento duplicado de runs (lock failure) +- Runs pendentes não processadas (polling bug) +- Owner mismatch causando starvation de runs +- Falhas silenciosas durante execução de pipeline + +## Escopo + +### Incluído + +- Testes unitários para `RuntimeWorker.poll_once()` +- Testes unitários para `RuntimeWorker._next_pending_run()` com owner filtering +- Testes unitários para `RuntimeWorker._runtime_owner()` +- Testes unitários para `RuntimeWorker._record_owner_skip_if_needed()` +- Testes unitários para `RuntimeWorker.sleep_when_idle()` +- Testes de lock: worker não processa run já locked +- Testes de retry: worker continua polling após falha de lock +- Testes de owner skip: run com initiated_by incompatível é pulada com evento registrado +- Testes de owner skip dedup: evento duplicado não é registrado se já existe idêntico +- Teste de `build_runtime_worker()` factory function + +### Não incluído + +- Testes de integração com Docker real +- Testes de concorrência real (multi-thread/process) +- Testes do `PersistedPipelineRunner` (já cobertos em `test_pipeline_persistence.py`) +- Testes do `RunRepository` (já cobertos em `test_persistence.py`) + +## Critérios de Aceite + +- [x] AC1: `test_worker_picks_pending_run_and_transitions_to_running` — worker seleciona run pendente e executa via runner +- [x] AC2: `test_worker_does_not_acquire_lock_for_already_locked_run` — worker pula run com lock ativo e continua polling +- [x] AC3: `test_worker_requeues_step_after_retryable_failure` — worker não crasha quando runner lança exceção (falha já persistida pelo runner observer) +- [x] AC4: `test_worker_generates_run_report_after_pipeline_completion` — run report é gerado após pipeline completar (verificado via runner mock) +- [x] AC5: `test_worker_does_not_process_run_already_in_completed_state` — worker ignora runs já completadas +- [x] AC6: `test_worker_skips_run_with_incompatible_runtime_owner` — worker pula run com initiated_by incompatível com runtime owner +- [x] AC7: `test_worker_records_owner_skip_event` — evento RUNTIME_OWNER_SKIP_EVENT é registrado quando run é pulada por owner mismatch +- [x] AC8: `test_worker_does_not_duplicate_owner_skip_event` — evento idêntico não é registrado se já existe no histórico da run +- [x] AC9: `test_worker_returns_none_when_no_pending_runs` — poll_once retorna None quando não há runs pendentes +- [x] AC10: `test_worker_sleeps_when_idle` — sleep_when_idle dorme pelo poll_interval_seconds configurado +- [x] AC11: `test_build_runtime_worker_constructs_with_correct_poll_interval` — factory function cria worker com settings corretos +- [x] AC12: `test_runtime_owner_returns_none_when_provider_is_none` — \_runtime_owner retorna None sem provider + +## Design de Testes + +### Mocking Strategy + +- Mock `RunRepository` com métodos individuais (find_next_pending_run, acquire_lock, list_unlocked_pending_runs, get_latest_event, record_event) +- Mock `PersistedPipelineRunner` (run_existing) +- Mock `RuntimeStateStore` (read) +- Usar `unittest.mock.MagicMock` para repositórios — não SQLite real +- Mock `time.sleep` para testes de polling sem delay real + +### Estrutura de Arquivos + +``` +tests/unit/ + test_worker_runtime.py — testes do RuntimeWorker +``` + +### Fixtures Necessárias + +- `pending_run_record` — RunRecord com status pending +- `completed_run_record` — RunRecord com status complete +- `locked_run_record` — RunRecord que falha em acquire_lock +- `runtime_state_running` — RuntimeState com status running e started_by +- `runtime_state_stopped` — RuntimeState com status stopped + +## Riscos e Mitigações + +| Risco | Mitigação | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | +| Testes acoplados a implementação interna (\_next_pending_run, \_runtime_owner) | Aceitável — são métodos privados mas com comportamento observável via poll_once | +| time.sleep real em testes | Mock time.sleep com patch | +| Exceção do runner swallowada silenciosamente | Verificar que exceção não propaga mas run_id é retornado | + +## Dependências + +- `src/synapse_os/runtime/worker.py` — módulo alvo +- `src/synapse_os/persistence.py` — RunRepository, RunRecord, ArtifactStore, PersistedPipelineRunner +- `src/synapse_os/runtime/state.py` — RuntimeState, RuntimeStateStore +- `src/synapse_os/state_machine.py` — PipelineState +- `src/synapse_os/config.py` — AppSettings + +## Notas + +- O worker já existe e funciona — esta feature é puramente de testes +- Padrão: tests unitários em `tests/unit/` conforme TDD.md section 10 +- Não criar diretório `tests/worker/` — manter em unit enquanto volume for pequeno diff --git a/scripts/.aignt-os/runs/runs.sqlite3 b/scripts/.aignt-os/runs/runs.sqlite3 new file mode 100644 index 0000000..42a9899 Binary files /dev/null and b/scripts/.aignt-os/runs/runs.sqlite3 differ diff --git a/scripts/.aignt-os/runtime/runtime-state.json b/scripts/.aignt-os/runtime/runtime-state.json new file mode 100644 index 0000000..a0a12ab --- /dev/null +++ b/scripts/.aignt-os/runtime/runtime-state.json @@ -0,0 +1 @@ +{"status": "running", "pid": 1010871, "started_at": "2026-03-13T04:35:33.348884+00:00", "process_identity": "33c64d60fdefb7e4591224553831c580"} \ No newline at end of file diff --git a/src/synapse_os/cli/app.py b/src/synapse_os/cli/app.py index 5f7dee4..aab0b57 100644 --- a/src/synapse_os/cli/app.py +++ b/src/synapse_os/cli/app.py @@ -28,6 +28,7 @@ usage_error, validation_error, ) +from synapse_os.cli.hooks import hooks_app from synapse_os.cli.rendering import ( RunArtifactPreview, render_environment_doctor, @@ -56,6 +57,7 @@ app.add_typer(runtime_app, name="runtime") app.add_typer(runs_app, name="runs") app.add_typer(auth_app, name="auth") +app.add_typer(hooks_app, name="hooks") @app.callback() diff --git a/src/synapse_os/cli/hooks.py b/src/synapse_os/cli/hooks.py new file mode 100644 index 0000000..be7b468 --- /dev/null +++ b/src/synapse_os/cli/hooks.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from synapse_os.config import AppSettings +from synapse_os.runtime_contracts import HookConfig +from synapse_os.specs import validate_spec_file + +hooks_app = typer.Typer(help="Manage and validate pipeline hooks.") + +console = Console() + + +def _render_hooks_table(hooks: list[HookConfig], title: str) -> None: + if not hooks: + console.print(f"[dim]{title}: no hooks configured[/dim]") + return + + table = Table(title=title) + table.add_column("Point", style="cyan") + table.add_column("Handler", style="green") + table.add_column("Failure Mode", style="yellow") + table.add_column("Enabled", style="magenta") + + for hook in hooks: + table.add_row( + hook.point, + hook.handler, + hook.failure_mode, + str(hook.enabled), + ) + + console.print(table) + + +@hooks_app.command("list") +def hooks_list( + spec: Annotated[ + Path | None, + typer.Option("--spec", help="Path to SPEC.md to show per-run hooks"), + ] = None, +) -> None: + settings = AppSettings() + global_hooks = settings.hooks + + if spec is not None: + try: + doc = validate_spec_file(spec) + spec_hooks = doc.metadata.hooks + spec_name = spec.name + except Exception as exc: + console.print(f"[red]Error validating SPEC: {exc}[/red]") + raise typer.Exit(code=1) from exc + else: + spec_hooks = [] + spec_name = None + + if not global_hooks and not spec_hooks: + console.print("[dim]No hooks configured.[/dim]") + return + + if global_hooks: + _render_hooks_table(global_hooks, "Global Hooks (AppSettings)") + + if spec_hooks: + _render_hooks_table(spec_hooks, f"SPEC Hooks ({spec_name})") + + +@hooks_app.command("validate") +def hooks_validate(handler: str) -> None: + if "." not in handler: + console.print( + f"[red]Error: '{handler}' is not a valid dotted path (e.g. module.func).[/red]" + ) + raise typer.Exit(code=1) + + module_path, func_name = handler.rsplit(".", 1) + + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError: + console.print(f"[red]Error: Module '{module_path}' not found.[/red]") + raise typer.Exit(code=1) from None + except ImportError as exc: + console.print(f"[red]Error: Cannot import module '{module_path}': {exc}[/red]") + raise typer.Exit(code=1) from exc + + try: + func = getattr(module, func_name) + except AttributeError: + console.print( + f"[red]Error: Function '{func_name}' not found in module '{module_path}'.[/red]" + ) + raise typer.Exit(code=1) from None + + if not callable(func): + console.print(f"[red]Error: '{handler}' resolves to a non-callable attribute.[/red]") + raise typer.Exit(code=1) + + console.print(f"[green]OK: {handler} -> {func.__name__}[/green]") + + +@hooks_app.command("status") +def hooks_status() -> None: + console.print("[dim]No active hooks from recent runs.[/dim]") + console.print("[dim]Run a pipeline with hooks configured to see active hooks.[/dim]") diff --git a/src/synapse_os/config.py b/src/synapse_os/config.py index fc5cc71..c531c7b 100644 --- a/src/synapse_os/config.py +++ b/src/synapse_os/config.py @@ -4,6 +4,7 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from synapse_os.runtime_contracts import HookConfig from synapse_os.security import DEFAULT_SECRET_MASK_PATTERNS, resolve_path_within_root @@ -31,6 +32,7 @@ class AppSettings(BaseSettings): execution_timeout_seconds: float = Field(default=300.0, gt=0) max_retries: int = Field(default=3, ge=0) tui_log_buffer_lines: int = Field(default=1000, gt=0) + hooks: list[HookConfig] = Field(default_factory=list) @property def runtime_state_dir_resolved(self) -> Path: diff --git a/src/synapse_os/hooks.py b/src/synapse_os/hooks.py new file mode 100644 index 0000000..960b1a1 --- /dev/null +++ b/src/synapse_os/hooks.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import importlib +import logging +import threading +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from synapse_os.runtime_contracts import HookConfig, HookContext, HookResult + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + + +class HookRejectedError(Exception): + pass + + +class HookDispatcher: + def __init__( + self, + *, + global_hooks: list[HookConfig] | None = None, + spec_hooks: list[HookConfig] | None = None, + ) -> None: + self._handlers: dict[str, list[tuple[HookConfig, Callable[..., Any]]]] = {} + self._active_hooks: list[str] = [] + self._post_threads: list[threading.Thread] = [] + + merged = self._merge(global_hooks or [], spec_hooks or []) + for config in merged: + handler = self._load_handler(config) + if handler is None: + continue + key = f"{config.point}:{config.handler}" + self._handlers.setdefault(config.point, []).append((config, handler)) + self._active_hooks.append(key) + + @property + def hooks_active(self) -> list[str]: + return list(self._active_hooks) + + def _merge( + self, + global_hooks: list[HookConfig], + spec_hooks: list[HookConfig], + ) -> list[HookConfig]: + disabled = {(h.handler, h.point) for h in spec_hooks if not h.enabled} + enabled_spec = [h for h in spec_hooks if h.enabled] + enabled_global = [h for h in global_hooks if h.enabled] + + result = [h for h in enabled_global if (h.handler, h.point) not in disabled] + result.extend(enabled_spec) + return result + + def _load_handler(self, config: HookConfig) -> Callable[..., Any] | None: + try: + module_path, func_name = config.handler.rsplit(".", 1) + except ValueError: + self._handle_invalid_handler(config, "Handler must be a dotted path (e.g. module.func)") + return None + + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError: + self._handle_invalid_handler(config, f"Module '{module_path}' not found") + return None + except ImportError: + self._handle_invalid_handler(config, f"Cannot import module '{module_path}'") + return None + + try: + func = getattr(module, func_name) + return func # type: ignore[no-any-return] + except AttributeError: + self._handle_invalid_handler( + config, f"Function '{func_name}' not found in '{module_path}'" + ) + return None + + def _handle_invalid_handler(self, config: HookConfig, reason: str) -> None: + if config.failure_mode == "hard_fail": + raise RuntimeError(f"Hook handler '{config.handler}' is invalid: {reason}") + logger.warning( + "Hook handler '%s' is invalid (%s) — disabling for this run", + config.handler, + reason, + ) + + def dispatch_pre( + self, + point: str, + context: HookContext, + ) -> HookContext: + handlers = self._handlers.get(point, []) + for config, handler in handlers: + try: + result: HookResult = handler(context) + except Exception as exc: + if config.failure_mode == "hard_fail": + raise HookRejectedError( + f"Hook rejected step '{context.step_name or point}': {exc}" + ) from exc + continue + + if not result.allowed: + if config.failure_mode == "hard_fail": + reason = result.reason or "no reason" + raise HookRejectedError( + f"Hook rejected step '{context.step_name or point}': {reason}" + ) + return context + + if result.context_patch: + for key, value in result.context_patch.items(): + context.metadata[key] = value + + return context + + def dispatch_post( + self, + point: str, + context: HookContext, + ) -> None: + handlers = self._handlers.get(point, []) + if not handlers: + return + + thread = threading.Thread( + target=self._run_post_handlers, + args=(point, handlers, context), + daemon=True, + ) + self._post_threads.append(thread) + thread.start() + + def _run_post_handlers( + self, + point: str, + handlers: list[tuple[HookConfig, Callable[..., Any]]], + context: HookContext, + ) -> None: + for config, handler in handlers: + try: + handler(context) + except Exception: + logger.warning( + "Post-hook handler '%s' raised an exception at point '%s'", + config.handler, + point, + exc_info=True, + ) + + def join_post_handlers(self, timeout: float | None = None) -> None: + for thread in self._post_threads: + thread.join(timeout=timeout) + self._post_threads.clear() diff --git a/src/synapse_os/pipeline.py b/src/synapse_os/pipeline.py index db72955..3aac974 100644 --- a/src/synapse_os/pipeline.py +++ b/src/synapse_os/pipeline.py @@ -6,7 +6,9 @@ from pydantic import BaseModel, ConfigDict, Field, StrictStr from synapse_os.config import AppSettings +from synapse_os.hooks import HookDispatcher, HookRejectedError from synapse_os.runtime_contracts import ( + HookContext, RunContext, RunLifecycleHooks, WorkspaceContext, @@ -19,7 +21,11 @@ from synapse_os.specs import ( SpecValidationError as _SpecValidationError, ) -from synapse_os.state_machine import LINEAR_STATE_FLOW, PipelineState, SynapseStateMachine +from synapse_os.state_machine import ( + LINEAR_STATE_FLOW, + PipelineState, + SynapseStateMachine, +) from synapse_os.supervisor import ( RetryableStepError, Supervisor, @@ -88,9 +94,10 @@ class PipelineContext(BaseModel): run_context: RunContext run_id: StrictStr | None = None step_history: list[StrictStr] = Field(default_factory=list) - artifacts: dict[str, StrictStr] = Field(default_factory=dict) + artifacts: dict[StrictStr, StrictStr] = Field(default_factory=dict) supervisor_decisions: list[StrictStr] = Field(default_factory=list) validated_spec: SpecDocument | None = None + hooks_active: list[StrictStr] = Field(default_factory=list) class StepExecutor(Protocol): @@ -157,6 +164,7 @@ def __init__( supervisor: Supervisor | None = None, cancellation_checker: CancellationChecker | None = None, workspace_provider: WorkspaceProvider | None = None, + hook_dispatcher: HookDispatcher | None = None, ) -> None: self.settings = settings or AppSettings() self.executors = self._normalize_executors(executors or {}) @@ -164,6 +172,7 @@ def __init__( self.observer = observer self.cancellation_checker = cancellation_checker self.workspace_provider = workspace_provider + self.hook_dispatcher = hook_dispatcher if supervisor is None: # Create default supervisor using settings @@ -208,6 +217,12 @@ def run( self._notify_optional("on_run_context_initialized", context) + if self.hook_dispatcher is None and self.settings.hooks: + self.hook_dispatcher = HookDispatcher(global_hooks=self.settings.hooks) + + if self.hook_dispatcher is not None: + context.hooks_active = list(self.hook_dispatcher.hooks_active) + if self.observer is not None: self.observer.on_run_started(context) @@ -250,21 +265,13 @@ def run( if current_state == PipelineState.SPEC_VALIDATION: current_step = PIPELINE_STEPS[current_state] self._notify_optional("on_step_started", current_step, context) - self._execute_spec_validation(context) - if self.observer is not None: - self.observer.on_step_completed(current_step, context, None) + self._run_step_with_hooks(current_step, context) if stop_at == PipelineState.SPEC_VALIDATION: if self.observer is not None: self.observer.on_run_completed(context) return context - self._notify_optional( - "on_state_transition", - PipelineState.SPEC_VALIDATION, - PipelineState.PLAN, - context, - ) - self.state_machine.advance_to(PipelineState.PLAN) - context.current_state = self.state_machine.current_state + next_state = self._next_state(current_state) + self._advance_with_hooks(current_state, next_state, context) continue if current_state in { @@ -278,27 +285,15 @@ def run( }: current_step = PIPELINE_STEPS[current_state] self._notify_optional("on_step_started", current_step, context) - result = self._run_runtime_step(current_step, context) - if result is None: + advanced = self._run_step_with_hooks(current_step, context) + if not advanced: continue - context.artifacts.update(result.artifacts) - context.step_history.append(current_step.state) - context.current_state = current_state - if self.observer is not None: - self.observer.on_step_completed(current_step, context, result) if stop_at == current_state: if self.observer is not None: self.observer.on_run_completed(context) return context next_state = self._next_state(current_state) - self._notify_optional( - "on_state_transition", - current_state, - next_state, - context, - ) - self.state_machine.advance_to(next_state) - context.current_state = self.state_machine.current_state + self._advance_with_hooks(current_state, next_state, context) continue raise PipelineExecutionError( @@ -463,3 +458,70 @@ def _decide_after_failure( observer_callback(step, context, decision, error) return decision + + def _run_step_with_hooks( + self, + step: PipelineStep, + context: PipelineContext, + ) -> bool: + if self.hook_dispatcher is not None: + hook_ctx = HookContext( + run_id=context.run_id or "unknown", + step_name=step.state, + current_state=context.current_state, + ) + try: + self.hook_dispatcher.dispatch_pre("pre_step", hook_ctx) + except HookRejectedError as exc: + context.step_history.append(step.state) + context.current_state = step.state + raise RetryableStepError(f"Hook rejected step '{step.state}'") from exc + + if step.state == PipelineState.SPEC_VALIDATION: + self._execute_spec_validation(context) + if self.observer is not None: + self.observer.on_step_completed(step, context, None) + else: + result = self._run_runtime_step(step, context) + if result is None: + return False + + context.artifacts.update(result.artifacts) + context.step_history.append(step.state) + context.current_state = step.state + if self.observer is not None: + self.observer.on_step_completed(step, context, result) + + if self.hook_dispatcher is not None: + hook_ctx = HookContext( + run_id=context.run_id or "unknown", + step_name=step.state, + current_state=context.current_state, + ) + self.hook_dispatcher.dispatch_post("post_step", hook_ctx) + + return True + + def _advance_with_hooks( + self, + from_state: str, + to_state: str, + context: PipelineContext, + ) -> None: + if self.hook_dispatcher is not None: + hook_ctx = HookContext( + run_id=context.run_id or "unknown", + current_state=from_state, + ) + self.hook_dispatcher.dispatch_pre("pre_state_transition", hook_ctx) + + self._notify_optional("on_state_transition", from_state, to_state, context) + self.state_machine.advance_to(to_state) + context.current_state = self.state_machine.current_state + + if self.hook_dispatcher is not None: + hook_ctx = HookContext( + run_id=context.run_id or "unknown", + current_state=to_state, + ) + self.hook_dispatcher.dispatch_post("post_state_transition", hook_ctx) diff --git a/src/synapse_os/runtime_contracts.py b/src/synapse_os/runtime_contracts.py index 9bed5be..d6e56d5 100644 --- a/src/synapse_os/runtime_contracts.py +++ b/src/synapse_os/runtime_contracts.py @@ -1,9 +1,9 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Literal, Protocol -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator from synapse_os.security import resolve_path_within_root @@ -86,6 +86,41 @@ def resolve(self, spec_path: Path) -> WorkspaceContext: ) +class HookConfig(BaseModel): + model_config = ConfigDict(strict=True) + + point: Literal["pre_step", "post_step", "pre_state_transition", "post_state_transition"] + handler: StrictStr = Field(min_length=1) + failure_mode: Literal["hard_fail", "supervisor_delegate"] = "supervisor_delegate" + enabled: StrictBool = True + + +class HookContext(BaseModel): + model_config = ConfigDict(strict=True) + + run_id: StrictStr + step_name: StrictStr | None = None + current_state: StrictStr | None = None + tool_spec: "ToolSpec | None" = None + workspace_path: StrictStr | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + @field_validator("tool_spec", mode="before") + @classmethod + def _validate_tool_spec_type(cls, v: object) -> object: + if v is not None and not isinstance(v, ToolSpec): + raise ValueError("tool_spec must be a ToolSpec instance or None") + return v + + +class HookResult(BaseModel): + model_config = ConfigDict(strict=True) + + allowed: StrictBool + reason: StrictStr | None = None + context_patch: dict[str, Any] | None = None + + class RunScopedWorkspaceProvider: def __init__( self, diff --git a/src/synapse_os/specs/validator.py b/src/synapse_os/specs/validator.py index 7306c11..777368b 100644 --- a/src/synapse_os/specs/validator.py +++ b/src/synapse_os/specs/validator.py @@ -1,15 +1,21 @@ from __future__ import annotations from pathlib import Path +from typing import get_args import yaml # type: ignore[import-untyped] from pydantic import BaseModel, ConfigDict, Field, ValidationError +from synapse_os.runtime_contracts import HookConfig + class SpecValidationError(ValueError): pass +VALID_HOOK_POINTS = set(get_args(HookConfig.model_fields["point"].annotation)) + + class SpecMetadata(BaseModel): model_config = ConfigDict(strict=True) @@ -20,6 +26,7 @@ class SpecMetadata(BaseModel): outputs: list[str] = Field(min_length=1) acceptance_criteria: list[str] = Field(min_length=1) non_goals: list[str] + hooks: list[HookConfig] = Field(default_factory=list) class SpecDocument(BaseModel): @@ -62,6 +69,8 @@ def _load_metadata(metadata_block: str) -> SpecMetadata: if not isinstance(raw_metadata, dict): raise SpecValidationError("SPEC front matter YAML is invalid.") + _validate_hooks_in_raw_metadata(raw_metadata) + try: return SpecMetadata.model_validate(raw_metadata) except ValidationError as exc: @@ -69,6 +78,25 @@ def _load_metadata(metadata_block: str) -> SpecMetadata: raise SpecValidationError(f"SPEC metadata is invalid: {message}") from exc +def _validate_hooks_in_raw_metadata(raw_metadata: dict[str, object]) -> None: + if "hooks" not in raw_metadata: + return + hooks_raw = raw_metadata["hooks"] + if not isinstance(hooks_raw, list): + raise SpecValidationError("SPEC metadata is invalid: hooks must be a list") + for i, hook in enumerate(hooks_raw): + if not isinstance(hook, dict): + raise SpecValidationError(f"SPEC metadata is invalid: hooks[{i}] must be a dict") + if "handler" not in hook or not hook["handler"]: + raise SpecValidationError(f"SPEC metadata is invalid: hooks[{i}].handler is required") + if "point" not in hook: + raise SpecValidationError(f"SPEC metadata is invalid: hooks[{i}].point is required") + if hook["point"] not in VALID_HOOK_POINTS: + raise SpecValidationError( + f"SPEC metadata is invalid: hooks[{i}].point '{hook['point']}' is not valid" + ) + + def _parse_sections(body: str) -> dict[str, str]: sections: dict[str, list[str]] = {} current_section: str | None = None diff --git a/tests/integration/test_hook_system_e2e.py b/tests/integration/test_hook_system_e2e.py new file mode 100644 index 0000000..8ed2d8a --- /dev/null +++ b/tests/integration/test_hook_system_e2e.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import sys +import threading +import types + +import pytest + +from synapse_os.config import AppSettings +from synapse_os.hooks import HookDispatcher +from synapse_os.pipeline import ( + PipelineEngine, + PipelineState, + StepExecutionResult, +) +from synapse_os.runtime_contracts import HookConfig +from synapse_os.state_machine import SynapseStateMachine + + +class MockStepExecutor: + def execute(self, step, context): + return StepExecutionResult( + clean_output=f"Executed {step.state}", + return_code=0, + ) + + +class TestHookSystemE2E: + def _make_engine_with_executors(self, hook_dispatcher=None, settings=None): + sm = SynapseStateMachine() + sm.advance_to(PipelineState.SPEC_DISCOVERY) + sm.advance_to(PipelineState.SPEC_NORMALIZATION) + sm.advance_to(PipelineState.SPEC_VALIDATION) + executors = { + state: MockStepExecutor() + for state in [ + "PLAN", + "TEST_RED", + "CODE_GREEN", + "QUALITY_GATE", + "REVIEW", + "SECURITY", + "DOCUMENT", + ] + } + return PipelineEngine( + settings=settings or AppSettings(), + state_machine=sm, + hook_dispatcher=hook_dispatcher, + executors=executors, + ) + + def _write_spec(self, tmp_path, spec_id="F1"): + spec_path = tmp_path / "SPEC.md" + spec_path.write_text( + f"---\nid: {spec_id}\ntype: feature\nsummary: test\n" + f"inputs: [a]\noutputs: [b]\nacceptance_criteria: [c]\n" + f"non_goals: []\n---\n\n# Contexto\ntest\n\n# Objetivo\ntest\n" + ) + return spec_path + + def test_full_pipeline_with_pre_step_hook(self, tmp_path) -> None: + mod = types.ModuleType("test_e2e_hook1") + mod.handle = lambda ctx: type( + "R", (), {"allowed": True, "reason": None, "context_patch": None} + )() + sys.modules["test_e2e_hook1"] = mod + try: + hooks = [HookConfig(point="pre_step", handler="test_e2e_hook1.handle")] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine_with_executors(hook_dispatcher=dispatcher) + spec_path = self._write_spec(tmp_path) + + ctx = engine.run(spec_path, stop_at="PLAN") + assert ctx.current_state == PipelineState.PLAN + assert "pre_step:test_e2e_hook1.handle" in ctx.hooks_active + finally: + del sys.modules["test_e2e_hook1"] + + def test_pre_step_hook_blocks_pipeline(self, tmp_path) -> None: + mod = types.ModuleType("test_e2e_hook2") + mod.handle = lambda ctx: type( + "R", (), {"allowed": False, "reason": "policy", "context_patch": None} + )() + sys.modules["test_e2e_hook2"] = mod + try: + hooks = [ + HookConfig( + point="pre_step", + handler="test_e2e_hook2.handle", + failure_mode="hard_fail", + ) + ] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine_with_executors(hook_dispatcher=dispatcher) + spec_path = self._write_spec(tmp_path) + + from synapse_os.supervisor import RetryableStepError + + with pytest.raises(RetryableStepError): + engine.run(spec_path, stop_at="PLAN") + finally: + del sys.modules["test_e2e_hook2"] + + def test_post_hook_runs_in_background(self, tmp_path) -> None: + results = [] + handler_done = threading.Event() + + def post_handler(ctx): + results.append(ctx.step_name) + handler_done.set() + + mod = types.ModuleType("test_e2e_hook3") + mod.handle = post_handler + sys.modules["test_e2e_hook3"] = mod + try: + hooks = [HookConfig(point="post_step", handler="test_e2e_hook3.handle")] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine_with_executors(hook_dispatcher=dispatcher) + spec_path = self._write_spec(tmp_path) + + engine.run(spec_path, stop_at="PLAN") + assert handler_done.wait(timeout=5), "Post handler did not complete" + dispatcher.join_post_handlers(timeout=5) + assert "PLAN" in results + finally: + del sys.modules["test_e2e_hook3"] + + def test_state_transition_hooks(self, tmp_path) -> None: + transitions = [] + + def pre_transition(ctx): + transitions.append(f"pre:{ctx.current_state}") + return type("R", (), {"allowed": True, "reason": None, "context_patch": None})() + + def post_transition(ctx): + transitions.append(f"post:{ctx.current_state}") + + mod_pre = types.ModuleType("test_e2e_hook4a") + mod_post = types.ModuleType("test_e2e_hook4b") + mod_pre.handle = pre_transition + mod_post.handle = post_transition + sys.modules["test_e2e_hook4a"] = mod_pre + sys.modules["test_e2e_hook4b"] = mod_post + try: + hooks = [ + HookConfig(point="pre_state_transition", handler="test_e2e_hook4a.handle"), + HookConfig(point="post_state_transition", handler="test_e2e_hook4b.handle"), + ] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine_with_executors(hook_dispatcher=dispatcher) + spec_path = self._write_spec(tmp_path) + + engine.run(spec_path, stop_at="PLAN") + dispatcher.join_post_handlers(timeout=5) + assert any("pre:SPEC_VALIDATION" in t for t in transitions) + assert any("post:PLAN" in t for t in transitions) + finally: + del sys.modules["test_e2e_hook4a"] + del sys.modules["test_e2e_hook4b"] + + def test_context_patch_from_hook(self, tmp_path) -> None: + def patching_handler(ctx): + return type( + "R", + (), + {"allowed": True, "reason": None, "context_patch": {"cost_limit": 100}}, + )() + + mod = types.ModuleType("test_e2e_hook5") + mod.handle = patching_handler + sys.modules["test_e2e_hook5"] = mod + try: + hooks = [HookConfig(point="pre_step", handler="test_e2e_hook5.handle")] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine_with_executors(hook_dispatcher=dispatcher) + spec_path = self._write_spec(tmp_path) + + ctx = engine.run(spec_path, stop_at="PLAN") + assert ctx.current_state == PipelineState.PLAN + finally: + del sys.modules["test_e2e_hook5"] + + def test_spec_hook_disables_global_hook(self, tmp_path) -> None: + mod = types.ModuleType("test_e2e_hook6") + mod.handle = lambda ctx: type( + "R", (), {"allowed": True, "reason": None, "context_patch": None} + )() + sys.modules["test_e2e_hook6"] = mod + try: + global_hooks = [HookConfig(point="pre_step", handler="test_e2e_hook6.handle")] + spec_hooks = [ + HookConfig(point="pre_step", handler="test_e2e_hook6.handle", enabled=False) + ] + dispatcher = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + engine = self._make_engine_with_executors(hook_dispatcher=dispatcher) + spec_path = self._write_spec(tmp_path) + + ctx = engine.run(spec_path, stop_at="PLAN") + assert ctx.hooks_active == [] + finally: + del sys.modules["test_e2e_hook6"] + + def test_invalid_handler_hard_fail_stops_at_startup(self) -> None: + hooks = [ + HookConfig( + point="pre_step", + handler="nonexistent.module.func", + failure_mode="hard_fail", + ) + ] + with pytest.raises(RuntimeError, match="nonexistent.module.func"): + HookDispatcher(global_hooks=hooks) + + def test_invalid_handler_supervisor_delegate_continues(self, caplog) -> None: + hooks = [ + HookConfig( + point="pre_step", + handler="nonexistent.module.func", + failure_mode="supervisor_delegate", + ) + ] + import logging + + with caplog.at_level(logging.WARNING): + dispatcher = HookDispatcher(global_hooks=hooks) + assert dispatcher.hooks_active == [] diff --git a/tests/unit/test_hook_contracts.py b/tests/unit/test_hook_contracts.py new file mode 100644 index 0000000..74bd460 --- /dev/null +++ b/tests/unit/test_hook_contracts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest + + +def test_hook_config_rejects_invalid_point() -> None: + from pydantic import ValidationError + from synapse_os.runtime_contracts import HookConfig + + with pytest.raises(ValidationError): + HookConfig(point="invalid_point", handler="some.module.handle") + + +def test_hook_config_defaults() -> None: + from synapse_os.runtime_contracts import HookConfig + + h = HookConfig(point="pre_step", handler="some.module.handle") + assert h.failure_mode == "supervisor_delegate" + assert h.enabled is True + + +def test_hook_config_hard_fail_accepted() -> None: + from synapse_os.runtime_contracts import HookConfig + + h = HookConfig(point="post_step", handler="a.b.c", failure_mode="hard_fail") + assert h.failure_mode == "hard_fail" + + +def test_hook_context_metadata_defaults_to_empty() -> None: + from synapse_os.runtime_contracts import HookContext + + ctx = HookContext(run_id="r1") + assert ctx.metadata == {} + assert ctx.step_name is None + assert ctx.current_state is None + + +def test_hook_context_accepts_all_optional_fields() -> None: + from synapse_os.runtime_contracts import HookContext, ToolSpec + + ts = ToolSpec(name="test-tool", capabilities=("generate",)) + + ctx = HookContext( + run_id="r1", + step_name="PLAN", + current_state="SPEC_VALIDATION", + workspace_path="/tmp/ws", + metadata={"key": "value"}, + tool_spec=ts, + ) + assert ctx.step_name == "PLAN" + assert ctx.metadata == {"key": "value"} + assert ctx.tool_spec is not None + + +def test_hook_result_defaults() -> None: + from synapse_os.runtime_contracts import HookResult + + r = HookResult(allowed=True) + assert r.context_patch is None + assert r.reason is None + + +def test_hook_result_allowed_false_with_reason() -> None: + from synapse_os.runtime_contracts import HookResult + + r = HookResult(allowed=False, reason="permission denied") + assert not r.allowed + assert r.reason == "permission denied" + + +def test_hook_config_rejects_invalid_failure_mode() -> None: + from pydantic import ValidationError + from synapse_os.runtime_contracts import HookConfig + + with pytest.raises(ValidationError): + HookConfig(point="pre_step", handler="a.b.c", failure_mode="log_and_continue") + + +def test_hook_config_rejects_empty_handler() -> None: + from pydantic import ValidationError + from synapse_os.runtime_contracts import HookConfig + + with pytest.raises(ValidationError): + HookConfig(point="pre_step", handler="") + + +def test_hook_result_rejects_non_bool_allowed() -> None: + from pydantic import ValidationError + from synapse_os.runtime_contracts import HookResult + + with pytest.raises(ValidationError): + HookResult(allowed="true") diff --git a/tests/unit/test_hook_dispatcher.py b/tests/unit/test_hook_dispatcher.py new file mode 100644 index 0000000..0a37b5f --- /dev/null +++ b/tests/unit/test_hook_dispatcher.py @@ -0,0 +1,284 @@ +from __future__ import annotations + +import logging +import types +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from synapse_os.hooks import HookDispatcher, HookRejectedError +from synapse_os.runtime_contracts import HookConfig, HookContext, HookResult + + +def _make_handler(allowed=True, reason=None, context_patch=None): + def handler(ctx): + return HookResult(allowed=allowed, reason=reason, context_patch=context_patch) + + return handler + + +class TestHookDispatcherMerge: + def test_empty_hooks(self) -> None: + d = HookDispatcher() + assert d.hooks_active == [] + + def test_global_only(self) -> None: + global_hooks = [HookConfig(point="pre_step", handler="os.path.join")] + d = HookDispatcher(global_hooks=global_hooks) + assert len(d.hooks_active) == 1 + assert "pre_step:os.path.join" in d.hooks_active + + def test_spec_only(self) -> None: + spec_hooks = [HookConfig(point="post_step", handler="os.path.join")] + d = HookDispatcher(spec_hooks=spec_hooks) + assert len(d.hooks_active) == 1 + assert "post_step:os.path.join" in d.hooks_active + + def test_merge_global_and_spec(self) -> None: + global_hooks = [HookConfig(point="pre_step", handler="os.path.join")] + spec_hooks = [HookConfig(point="post_step", handler="os.path.join")] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert len(d.hooks_active) == 2 + + def test_spec_disable_removes_global(self) -> None: + global_hooks = [HookConfig(point="pre_step", handler="os.path.join")] + spec_hooks = [HookConfig(point="pre_step", handler="os.path.join", enabled=False)] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert d.hooks_active == [] + + def test_spec_disable_only_by_handler_and_point(self) -> None: + global_hooks = [ + HookConfig(point="pre_step", handler="os.path.join"), + HookConfig(point="post_step", handler="os.path.join"), + ] + spec_hooks = [HookConfig(point="pre_step", handler="os.path.join", enabled=False)] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert len(d.hooks_active) == 1 + assert "post_step:os.path.join" in d.hooks_active + + def test_spec_enabled_adds_extra(self) -> None: + global_hooks = [HookConfig(point="pre_step", handler="os.path.join")] + spec_hooks = [HookConfig(point="pre_step", handler="os.path.dirname")] + d = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + assert len(d.hooks_active) == 2 + + +class TestHookDispatcherLoadHandlers: + def test_valid_dotted_path(self) -> None: + global_hooks = [HookConfig(point="pre_step", handler="os.path.join")] + d = HookDispatcher(global_hooks=global_hooks) + assert len(d.hooks_active) == 1 + + def test_invalid_module_hard_fail(self) -> None: + global_hooks = [ + HookConfig( + point="pre_step", + handler="nonexistent_module.func", + failure_mode="hard_fail", + ) + ] + with pytest.raises(RuntimeError, match="nonexistent_module.func"): + HookDispatcher(global_hooks=global_hooks) + + def test_invalid_module_supervisor_delegate(self, caplog) -> None: + global_hooks = [ + HookConfig( + point="pre_step", + handler="nonexistent_module.func", + failure_mode="supervisor_delegate", + ) + ] + with caplog.at_level(logging.WARNING): + d = HookDispatcher(global_hooks=global_hooks) + assert d.hooks_active == [] + assert any("nonexistent_module" in r.message for r in caplog.records) + + def test_invalid_func_name_hard_fail(self) -> None: + global_hooks = [ + HookConfig( + point="pre_step", + handler="os.nonexistent_func", + failure_mode="hard_fail", + ) + ] + with pytest.raises(RuntimeError, match="os.nonexistent_func"): + HookDispatcher(global_hooks=global_hooks) + + def test_invalid_func_name_supervisor_delegate(self, caplog) -> None: + global_hooks = [ + HookConfig( + point="pre_step", + handler="os.nonexistent_func", + failure_mode="supervisor_delegate", + ) + ] + with caplog.at_level(logging.WARNING): + d = HookDispatcher(global_hooks=global_hooks) + assert d.hooks_active == [] + + def test_no_dot_in_handler(self) -> None: + global_hooks = [HookConfig(point="pre_step", handler="nodots", failure_mode="hard_fail")] + with pytest.raises(RuntimeError, match="dotted path"): + HookDispatcher(global_hooks=global_hooks) + + +class TestHookDispatcherDispatchPre: + def test_allowed_passes_through(self) -> None: + h = _make_handler(allowed=True) + mod = types.ModuleType("test_hook_mod") + mod.handle = h + sys.modules["test_hook_mod"] = mod + try: + hooks = [HookConfig(point="pre_step", handler="test_hook_mod.handle")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + result = d.dispatch_pre("pre_step", ctx) + assert result is ctx + finally: + del sys.modules["test_hook_mod"] + + def test_hard_fail_rejection_raises(self) -> None: + h = _make_handler(allowed=False, reason="blocked") + mod = types.ModuleType("test_hook_mod2") + mod.handle = h + sys.modules["test_hook_mod2"] = mod + try: + hooks = [ + HookConfig( + point="pre_step", + handler="test_hook_mod2.handle", + failure_mode="hard_fail", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + with pytest.raises(HookRejectedError, match="Hook rejected step 'PLAN'"): + d.dispatch_pre("pre_step", ctx) + finally: + del sys.modules["test_hook_mod2"] + + def test_supervisor_delegate_rejection_returns_context(self) -> None: + h = _make_handler(allowed=False, reason="needs review") + mod = types.ModuleType("test_hook_mod3") + mod.handle = h + sys.modules["test_hook_mod3"] = mod + try: + hooks = [ + HookConfig( + point="pre_step", + handler="test_hook_mod3.handle", + failure_mode="supervisor_delegate", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + result = d.dispatch_pre("pre_step", ctx) + assert result is ctx + finally: + del sys.modules["test_hook_mod3"] + + def test_context_patch_applies(self) -> None: + h = _make_handler(allowed=True, context_patch={"extra": "value"}) + mod = types.ModuleType("test_hook_mod4") + mod.handle = h + sys.modules["test_hook_mod4"] = mod + try: + hooks = [HookConfig(point="pre_step", handler="test_hook_mod4.handle")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + result = d.dispatch_pre("pre_step", ctx) + assert result.metadata["extra"] == "value" + finally: + del sys.modules["test_hook_mod4"] + + def test_handler_exception_hard_fail_raises(self) -> None: + def failing_handler(ctx): + raise ValueError("boom") + + mod = types.ModuleType("test_hook_mod5") + mod.handle = failing_handler + sys.modules["test_hook_mod5"] = mod + try: + hooks = [ + HookConfig( + point="pre_step", + handler="test_hook_mod5.handle", + failure_mode="hard_fail", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + with pytest.raises(HookRejectedError, match="boom"): + d.dispatch_pre("pre_step", ctx) + finally: + del sys.modules["test_hook_mod5"] + + def test_handler_exception_supervisor_delegate_continues(self) -> None: + def failing_handler(ctx): + raise ValueError("boom") + + mod = types.ModuleType("test_hook_mod6") + mod.handle = failing_handler + sys.modules["test_hook_mod6"] = mod + try: + hooks = [ + HookConfig( + point="pre_step", + handler="test_hook_mod6.handle", + failure_mode="supervisor_delegate", + ) + ] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + result = d.dispatch_pre("pre_step", ctx) + assert result is ctx + finally: + del sys.modules["test_hook_mod6"] + + +class TestHookDispatcherDispatchPost: + def test_post_handler_called(self) -> None: + calls = [] + + def post_handler(ctx): + calls.append(ctx.run_id) + + mod = types.ModuleType("test_hook_mod7") + mod.handle = post_handler + sys.modules["test_hook_mod7"] = mod + try: + hooks = [HookConfig(point="post_step", handler="test_hook_mod7.handle")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + d.dispatch_post("post_step", ctx) + d.join_post_handlers(timeout=5) + assert calls == ["r1"] + finally: + del sys.modules["test_hook_mod7"] + + def test_post_exception_does_not_propagate(self, caplog) -> None: + def failing_handler(ctx): + raise ValueError("post boom") + + mod = types.ModuleType("test_hook_mod8") + mod.handle = failing_handler + sys.modules["test_hook_mod8"] = mod + try: + hooks = [HookConfig(point="post_step", handler="test_hook_mod8.handle")] + d = HookDispatcher(global_hooks=hooks) + ctx = HookContext(run_id="r1", step_name="PLAN") + with caplog.at_level(logging.WARNING): + d.dispatch_post("post_step", ctx) + d.join_post_handlers(timeout=5) + assert any( + "post boom" in r.message or "test_hook_mod8" in r.message for r in caplog.records + ) + finally: + del sys.modules["test_hook_mod8"] + + def test_no_handlers_does_nothing(self) -> None: + d = HookDispatcher() + ctx = HookContext(run_id="r1") + d.dispatch_post("post_step", ctx) + d.join_post_handlers() diff --git a/tests/unit/test_hooks_cli.py b/tests/unit/test_hooks_cli.py new file mode 100644 index 0000000..987ac21 --- /dev/null +++ b/tests/unit/test_hooks_cli.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from synapse_os.cli.app import app + +runner = CliRunner() + + +class TestHooksListCommand: + def test_hooks_list_no_hooks(self) -> None: + result = runner.invoke(app, ["hooks", "list"]) + assert result.exit_code == 0 + assert "No hooks configured" in result.output or "nenhum hook" in result.output.lower() + + def test_hooks_list_with_global_hooks(self) -> None: + from synapse_os.runtime_contracts import HookConfig + + with patch("synapse_os.cli.hooks.AppSettings") as MockSettings: + mock_settings = MockSettings.return_value + mock_settings.hooks = [ + HookConfig(point="pre_step", handler="os.path.join"), + HookConfig(point="post_step", handler="os.path.dirname"), + ] + result = runner.invoke(app, ["hooks", "list"]) + assert result.exit_code == 0 + assert "os.path.join" in result.output + assert "os.path.dirname" in result.output + assert "pre_step" in result.output + assert "post_step" in result.output + + def test_hooks_list_with_spec_hooks(self, tmp_path: Path) -> None: + from synapse_os.runtime_contracts import HookConfig + + spec_path = tmp_path / "SPEC.md" + spec_path.write_text( + "---\nid: F1\ntype: feature\nsummary: test\ninputs: [a]\noutputs: [b]\nacceptance_criteria: [c]\nnon_goals: []\nhooks:\n - point: pre_step\n handler: os.path.join\n---\n\n# Contexto\ntest\n\n# Objetivo\ntest\n" + ) + + with patch("synapse_os.cli.hooks.AppSettings") as MockSettings: + mock_settings = MockSettings.return_value + mock_settings.hooks = [] + result = runner.invoke(app, ["hooks", "list", "--spec", str(spec_path)]) + assert result.exit_code == 0 + assert "os.path.join" in result.output + + def test_hooks_list_with_global_and_spec_hooks(self, tmp_path: Path) -> None: + from synapse_os.runtime_contracts import HookConfig + + spec_path = tmp_path / "SPEC.md" + spec_path.write_text( + "---\nid: F1\ntype: feature\nsummary: test\ninputs: [a]\noutputs: [b]\nacceptance_criteria: [c]\nnon_goals: []\nhooks:\n - point: post_step\n handler: os.path.dirname\n---\n\n# Contexto\ntest\n\n# Objetivo\ntest\n" + ) + + with patch("synapse_os.cli.hooks.AppSettings") as MockSettings: + mock_settings = MockSettings.return_value + mock_settings.hooks = [ + HookConfig(point="pre_step", handler="os.path.join"), + ] + result = runner.invoke(app, ["hooks", "list", "--spec", str(spec_path)]) + assert result.exit_code == 0 + assert "os.path.join" in result.output + assert "os.path.dirname" in result.output + + def test_hooks_list_with_malformed_spec(self, tmp_path: Path) -> None: + spec_path = tmp_path / "SPEC.md" + spec_path.write_text( + "---\nid: F1\ntype: feature\nsummary: test\ninputs: [a]\noutputs: [b]\nacceptance_criteria: [c]\nnon_goals: []\nhooks:\n - point: invalid_point\n handler: some.func\n---\n\n# Contexto\ntest\n\n# Objetivo\ntest\n" + ) + + with patch("synapse_os.cli.hooks.AppSettings") as MockSettings: + mock_settings = MockSettings.return_value + mock_settings.hooks = [] + result = runner.invoke(app, ["hooks", "list", "--spec", str(spec_path)]) + assert result.exit_code == 1 + assert "invalid" in result.output.lower() or "error" in result.output.lower() + + +class TestHooksValidateCommand: + def test_hooks_validate_valid_handler(self) -> None: + result = runner.invoke(app, ["hooks", "validate", "os.path.join"]) + assert result.exit_code == 0 + assert "join" in result.output + + def test_hooks_validate_invalid_module(self) -> None: + result = runner.invoke(app, ["hooks", "validate", "nonexistent_module.func"]) + assert result.exit_code == 1 + assert "nonexistent_module" in result.output + + def test_hooks_validate_invalid_function(self) -> None: + result = runner.invoke(app, ["hooks", "validate", "os.nonexistent_func"]) + assert result.exit_code == 1 + assert "nonexistent_func" in result.output + + def test_hooks_validate_no_dots(self) -> None: + result = runner.invoke(app, ["hooks", "validate", "nodots"]) + assert result.exit_code == 1 + assert "dotted" in result.output.lower() + + +class TestHooksStatusCommand: + def test_hooks_status_no_active_hooks(self) -> None: + result = runner.invoke(app, ["hooks", "status"]) + assert result.exit_code == 0 + assert "No active hooks" in result.output or "nenhum hook" in result.output.lower() diff --git a/tests/unit/test_pipeline_hook_integration.py b/tests/unit/test_pipeline_hook_integration.py new file mode 100644 index 0000000..5f52329 --- /dev/null +++ b/tests/unit/test_pipeline_hook_integration.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import sys +import types + +import pytest + +from synapse_os.config import AppSettings +from synapse_os.hooks import HookDispatcher +from synapse_os.pipeline import ( + PipelineEngine, + PipelineState, +) +from synapse_os.runtime_contracts import HookConfig +from synapse_os.state_machine import SynapseStateMachine + +SPEC_TEXT = ( + "---\nid: F1\ntype: feature\nsummary: test\n" + "inputs: [a]\noutputs: [b]\nacceptance_criteria: [c]\n" + "non_goals: []\n---\n\n# Contexto\ntest\n\n# Objetivo\ntest\n" +) + + +class TestPipelineHookIntegration: + def _make_engine(self, hook_dispatcher=None, settings=None): + sm = SynapseStateMachine() + sm.advance_to(PipelineState.SPEC_DISCOVERY) + sm.advance_to(PipelineState.SPEC_NORMALIZATION) + sm.advance_to(PipelineState.SPEC_VALIDATION) + return PipelineEngine( + settings=settings or AppSettings(), + state_machine=sm, + hook_dispatcher=hook_dispatcher, + ) + + def test_hooks_active_populated_from_dispatcher(self, tmp_path) -> None: + mod = types.ModuleType("test_int_hook") + mod.handle = lambda ctx: type( + "R", (), {"allowed": True, "reason": None, "context_patch": None} + )() + sys.modules["test_int_hook"] = mod + try: + hooks = [HookConfig(point="pre_step", handler="test_int_hook.handle")] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine(hook_dispatcher=dispatcher) + + spec_path = tmp_path / "SPEC.md" + spec_path.write_text(SPEC_TEXT) + + ctx = engine.run(spec_path, stop_at="SPEC_VALIDATION") + assert "pre_step:test_int_hook.handle" in ctx.hooks_active + finally: + del sys.modules["test_int_hook"] + + def test_hooks_active_empty_without_dispatcher(self, tmp_path) -> None: + engine = self._make_engine() + spec_path = tmp_path / "SPEC.md" + spec_path.write_text(SPEC_TEXT) + ctx = engine.run(spec_path, stop_at="SPEC_VALIDATION") + assert ctx.hooks_active == [] + + def test_pre_step_hook_rejection_raises(self, tmp_path) -> None: + mod = types.ModuleType("test_int_hook2") + mod.handle = lambda ctx: type( + "R", (), {"allowed": False, "reason": "blocked", "context_patch": None} + )() + sys.modules["test_int_hook2"] = mod + try: + hooks = [ + HookConfig( + point="pre_step", + handler="test_int_hook2.handle", + failure_mode="hard_fail", + ) + ] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine(hook_dispatcher=dispatcher) + + spec_path = tmp_path / "SPEC.md" + spec_path.write_text(SPEC_TEXT) + + from synapse_os.supervisor import RetryableStepError + + with pytest.raises(RetryableStepError, match="Hook rejected"): + engine.run(spec_path, stop_at="PLAN") + finally: + del sys.modules["test_int_hook2"] + + def test_post_hook_does_not_block_execution(self, tmp_path) -> None: + calls = [] + + def post_handler(ctx): + calls.append(ctx.run_id) + raise ValueError("post error") + + mod = types.ModuleType("test_int_hook3") + mod.handle = post_handler + sys.modules["test_int_hook3"] = mod + try: + hooks = [HookConfig(point="post_step", handler="test_int_hook3.handle")] + dispatcher = HookDispatcher(global_hooks=hooks) + engine = self._make_engine(hook_dispatcher=dispatcher) + + spec_path = tmp_path / "SPEC.md" + spec_path.write_text(SPEC_TEXT) + + engine.run(spec_path, stop_at="SPEC_VALIDATION") + dispatcher.join_post_handlers(timeout=5) + assert calls == ["unknown"] + finally: + del sys.modules["test_int_hook3"] + + def test_spec_hooks_merge_with_global(self, tmp_path) -> None: + mod = types.ModuleType("test_int_hook4") + mod.handle = lambda ctx: type( + "R", (), {"allowed": True, "reason": None, "context_patch": None} + )() + sys.modules["test_int_hook4"] = mod + try: + global_hooks = [HookConfig(point="pre_step", handler="test_int_hook4.handle")] + spec_hooks = [HookConfig(point="post_step", handler="test_int_hook4.handle")] + dispatcher = HookDispatcher(global_hooks=global_hooks, spec_hooks=spec_hooks) + engine = self._make_engine(hook_dispatcher=dispatcher) + + spec_path = tmp_path / "SPEC.md" + spec_path.write_text(SPEC_TEXT) + + ctx = engine.run(spec_path, stop_at="SPEC_VALIDATION") + assert len(ctx.hooks_active) == 2 + finally: + del sys.modules["test_int_hook4"] diff --git a/tests/unit/test_spec_validator_hooks.py b/tests/unit/test_spec_validator_hooks.py new file mode 100644 index 0000000..be05a48 --- /dev/null +++ b/tests/unit/test_spec_validator_hooks.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import pytest + +from synapse_os.specs.validator import ( + SpecValidationError, + _validate_hooks_in_raw_metadata, +) + + +class TestValidateHooksInRawMetadata: + def test_no_hooks_field_passes(self) -> None: + _validate_hooks_in_raw_metadata({"id": "F1", "type": "feature"}) + + def test_empty_hooks_list_passes(self) -> None: + _validate_hooks_in_raw_metadata({"hooks": []}) + + def test_valid_hook_passes(self) -> None: + _validate_hooks_in_raw_metadata( + {"hooks": [{"point": "pre_step", "handler": "some.module.func"}]} + ) + + def test_hooks_not_list_raises(self) -> None: + with pytest.raises(SpecValidationError, match="hooks must be a list"): + _validate_hooks_in_raw_metadata({"hooks": "not_a_list"}) + + def test_hook_not_dict_raises(self) -> None: + with pytest.raises(SpecValidationError, match=r"hooks\[0\] must be a dict"): + _validate_hooks_in_raw_metadata({"hooks": ["string"]}) + + def test_hook_missing_handler_raises(self) -> None: + with pytest.raises(SpecValidationError, match="handler is required"): + _validate_hooks_in_raw_metadata({"hooks": [{"point": "pre_step"}]}) + + def test_hook_empty_handler_raises(self) -> None: + with pytest.raises(SpecValidationError, match="handler is required"): + _validate_hooks_in_raw_metadata({"hooks": [{"point": "pre_step", "handler": ""}]}) + + def test_hook_missing_point_raises(self) -> None: + with pytest.raises(SpecValidationError, match="point is required"): + _validate_hooks_in_raw_metadata({"hooks": [{"handler": "some.func"}]}) + + def test_hook_invalid_point_raises(self) -> None: + with pytest.raises(SpecValidationError, match="point 'invalid' is not valid"): + _validate_hooks_in_raw_metadata( + {"hooks": [{"point": "invalid", "handler": "some.func"}]} + ) + + def test_all_valid_points_accepted(self) -> None: + for point in ( + "pre_step", + "post_step", + "pre_state_transition", + "post_state_transition", + ): + _validate_hooks_in_raw_metadata({"hooks": [{"point": point, "handler": "some.func"}]}) diff --git a/tests/unit/test_worker_runtime.py b/tests/unit/test_worker_runtime.py index 5f24ffd..3d5d1f0 100644 --- a/tests/unit/test_worker_runtime.py +++ b/tests/unit/test_worker_runtime.py @@ -2,6 +2,7 @@ from importlib import import_module from pathlib import Path +from unittest.mock import MagicMock def _write_valid_spec(path: Path, feature_id: str) -> None: @@ -103,7 +104,9 @@ def test_runtime_worker_ignores_locked_or_finalized_runs(tmp_path: Path) -> None assert repository.get_run(completed_run_id).status == "completed" -def test_runtime_worker_fails_pending_run_when_spec_hash_changes(tmp_path: Path) -> None: +def test_runtime_worker_fails_pending_run_when_spec_hash_changes( + tmp_path: Path, +) -> None: persistence = import_module("synapse_os.persistence") worker_module = import_module("synapse_os.runtime.worker") @@ -189,7 +192,9 @@ def test_runtime_worker_skips_incompatible_owner_and_processes_next_compatible( assert "run_initiated_by=operator-b" in incompatible_events[0].message -def test_runtime_worker_accepts_legacy_run_for_authenticated_runtime(tmp_path: Path) -> None: +def test_runtime_worker_accepts_legacy_run_for_authenticated_runtime( + tmp_path: Path, +) -> None: persistence = import_module("synapse_os.persistence") worker_module = import_module("synapse_os.runtime.worker") runtime_state_module = import_module("synapse_os.runtime.state") @@ -267,3 +272,101 @@ def test_runtime_worker_deduplicates_same_owner_skip_message(tmp_path: Path) -> assert first_processed is None assert second_processed is None assert [event.event_type for event in incompatible_events] == ["runtime_owner_skip"] + + +def test_runtime_worker_sleeps_when_idle(tmp_path: Path) -> None: + from unittest.mock import patch + + persistence = import_module("synapse_os.persistence") + worker_module = import_module("synapse_os.runtime.worker") + + repository = persistence.RunRepository(tmp_path / "runs.sqlite3") + artifact_store = persistence.ArtifactStore(tmp_path / "artifacts") + runner = persistence.PersistedPipelineRunner( + repository=repository, + artifact_store=artifact_store, + ) + worker = worker_module.RuntimeWorker( + repository=repository, + runner=runner, + poll_interval_seconds=0.05, + ) + + with patch("synapse_os.runtime.worker.time.sleep") as mock_sleep: + worker.sleep_when_idle() + + mock_sleep.assert_called_once_with(0.05) + + +def test_build_runtime_worker_constructs_with_correct_poll_interval( + tmp_path: Path, +) -> None: + from synapse_os.config import AppSettings + + settings = AppSettings() + settings.workspace_root = tmp_path + settings.runtime_state_dir = tmp_path / "runtime" + settings.runs_db_path = tmp_path / "runs.sqlite3" + settings.artifacts_dir = tmp_path / "artifacts" + settings.runtime_poll_interval_seconds = 1.5 + + worker = import_module("synapse_os.runtime.worker").build_runtime_worker(settings) + + assert worker.poll_interval_seconds == 1.5 + assert worker.repository is not None + assert worker.runner is not None + + +def test_runtime_owner_returns_none_when_provider_is_none(tmp_path: Path) -> None: + persistence = import_module("synapse_os.persistence") + worker_module = import_module("synapse_os.runtime.worker") + + repository = persistence.RunRepository(tmp_path / "runs.sqlite3") + artifact_store = persistence.ArtifactStore(tmp_path / "artifacts") + runner = persistence.PersistedPipelineRunner( + repository=repository, + artifact_store=artifact_store, + ) + worker = worker_module.RuntimeWorker( + repository=repository, + runner=runner, + runtime_state_provider=None, + ) + + owner = worker._runtime_owner() + + assert owner is None + + +def test_runtime_worker_handles_runner_exception_gracefully(tmp_path: Path) -> None: + + persistence = import_module("synapse_os.persistence") + worker_module = import_module("synapse_os.runtime.worker") + + repository = persistence.RunRepository(tmp_path / "runs.sqlite3") + runner = MagicMock() + worker = worker_module.RuntimeWorker(repository=repository, runner=runner) + + spec_path = tmp_path / "SPEC.md" + _write_valid_spec(spec_path, "F56-error-handling") + run_id = repository.create_run( + spec_path=spec_path, + initial_state="REQUEST", + stop_at="SPEC_VALIDATION", + ) + + def failing_run_existing(run_id: str, **kwargs): + repository.mark_run_failed( + run_id, + current_state="REQUEST", + failure_message="pipeline crashed", + ) + raise RuntimeError("pipeline crashed") + + runner.run_existing.side_effect = failing_run_existing + + processed_run_id = worker.poll_once() + + assert processed_run_id == run_id + run_record = repository.get_run(run_id) + assert run_record.status == "failed"