diff --git a/docs/guide_api_hooks.md b/docs/guide_api_hooks.md new file mode 100644 index 00000000..369e4544 --- /dev/null +++ b/docs/guide_api_hooks.md @@ -0,0 +1,395 @@ +# `src/api_hooks.py` & `src/api_hook_client.py` — Hook API + +[Top](../README.md) | [Architecture](guide_architecture.md) | [Testing](guide_testing.md) + +--- + +## Overview + +The Hook API is the **bridge between external automation and the running app**. It exposes Manual Slop's internal state over HTTP on `127.0.0.1:8999`, allowing tests, scripts, and external agents to drive the GUI without direct Python access. + +Two files: +- **`src/api_hooks.py`** (~38KB) — the server side: `HookServer`, `ApiHookClient` (the server-side helper class) +- **`src/api_hook_client.py`** (~31KB) — the client side: `ApiHookClient` (the Python client wrapper) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Manual Slop (sloppy.py) │ +│ - api_hooks.HookServer started in background │ +│ - Listens on http://127.0.0.1:8999 │ +└─────────────────┬───────────────────────────────┘ + │ serves + ▼ +┌─────────────────────────────────────────────────┐ +│ HTTP Endpoints │ +│ GET /status - health │ +│ GET /api/mma_status - MMA │ +│ GET /api/gui - GUI │ +│ POST /api/gui - push task │ +│ POST /api/ask - blocking │ +│ (synchronous HITL via HTTP) │ +│ ... etc │ +└─────────────────┬───────────────────────────────┘ + │ consumed by + ▼ +┌─────────────────────────────────────────────────┐ +│ src/api_hook_client.py: ApiHookClient │ +│ Python wrapper. Used by: │ +│ - live_gui test fixture (251 test files) │ +│ - External scripts and agents │ +│ - MMA conftest tooling │ +└─────────────────────────────────────────────────┘ +``` + +The `/api/ask` endpoint is special — it implements the **Remote Confirmation Protocol** for HITL approvals from external contexts. When the AI wants to run a script, the GUI can call `/api/ask` on a remote agent to get a yes/no answer. + +--- + +## The Server: `HookServer` (in `src/api_hooks.py`) + +### Lifecycle + +The server is started by `app_controller.py` (or `sloppy.py` when `--enable-test-hooks` is passed): + +```python +# In app_controller.py or sloppy.py +from src.api_hooks import HookServer +server = HookServer() +server.start() # Background thread, non-blocking +``` + +The server runs in a daemon thread. It stops when the process exits (or via `server.stop()`). + +### Endpoints + +| Method | Path | Purpose | +|---|---|---| +| `GET` | `/status` | Health check. Returns JSON with `status`, `pid`, `version`, etc. | +| `GET` | `/api/mma_status` | Full MMA orchestration state (tickets, progress, ETA) | +| `GET` | `/api/gui` | GUI debug info (button IDs, fields, etc.) | +| `POST` | `/api/gui` | Push a GUI task (click, set_value, custom_callback, etc.) | +| `POST` | `/api/ask` | Synchronous HITL approval (blocking request/response) | +| `GET` | `/api/performance` | Performance metrics (FPS, frame time) | +| `GET` | `/api/comms` | Communication log | +| `GET` | `/api/diagnostics` | Diagnostics state | + +(Full endpoint list may grow; check the live server for the canonical list.) + +### Request/Response Format + +All endpoints accept and return JSON: + +```json +// POST /api/gui +{ + "action": "click", + "item": "btn_reset" +} + +// Response +{ + "status": "queued" +} +``` + +For blocking endpoints (`/api/ask`): + +```json +// POST /api/ask +{ + "prompt": "Allow the AI to run 'rm -rf /'?", + "timeout": 30 +} + +// Response (blocks until human responds) +{ + "approved": true, + "response": "yes go ahead" +} +``` + +### The `predefined_callbacks` Mechanism + +The server supports invoking any App method via the `custom_callback` action: + +```python +# Server side (in gui_2.py __init__) +self.controller._predefined_callbacks['_my_method'] = self._my_method +self.controller._gettable_fields['show_my_thing'] = 'show_my_thing' +``` + +```python +# Client side +client.push_event("custom_callback", { + "callback": "_my_method", + "args": [arg1, arg2], +}) +``` + +This is the standard way to expose new App methods to the Hook API. + +--- + +## The Client: `ApiHookClient` (in `src/api_hook_client.py`) + +### Connection + +```python +from src.api_hook_client import ApiHookClient +client = ApiHookClient() # connects to http://127.0.0.1:8999 by default +``` + +Custom host/port: +```python +client = ApiHookClient(host="192.168.1.100", port=8999) +``` + +### Key Methods + +#### `click(item, user_data=None)` + +Simulates a button click. The button must be registered as a clickable widget in the GUI. + +```python +client.click("btn_reset") +client.click("btn_save_project", user_data={"path": "/path/to/file"}) +``` + +#### `set_value(item, value)` + +Sets a widget's value (e.g., a text input). + +```python +client.set_value("ui_ai_input", "Hello world") +client.set_value("provider_select", "anthropic") +``` + +#### `select_tab(item, value)` + +Selects a tab in a tab bar. + +```python +client.select_tab("main_tabs", "AI Settings") +``` + +#### `select_list_item(item, value)` + +Selects an item in a listbox or combo. + +```python +client.select_list_item("model_select", "gemini-3-flash-preview") +``` + +#### `push_event(action, payload)` + +Generic GUI task push. The action is dispatched to the appropriate handler. + +```python +client.push_event("custom_callback", { + "callback": "_my_method", + "args": [arg1, arg2], +}) +``` + +#### `get_value(item)` + +Reads a gettable field's current value. + +```python +value = client.get_value("show_command_palette") # Returns bool +value = client.get_value("current_provider") # Returns str +``` + +#### `get_status()` + +Calls `/status`. Returns the parsed JSON. + +```python +status = client.get_status() +assert status["status"] == "running" +``` + +#### `reset_session()` + +Calls the reset endpoint. Clears the GUI state for a fresh test. + +```python +client.reset_session() +``` + +#### `wait_for_event(event_type, timeout=10)` + +Polls the event queue for a specific event type. Useful for waiting for async AI responses. + +```python +event = client.wait_for_event("ai_response", timeout=30) +assert event is not None +``` + +### Robustness + +The client has: +- **Retry logic** for transient connection failures +- **Health check polling** — auto-waits for the server to be ready +- **Timeout configuration** per call + +--- + +## The `/api/ask` Protocol (Remote Confirmation) + +The `/api/ask` endpoint implements **non-blocking, ID-based challenge/response** for HITL approvals from external contexts. + +### Use Case + +A headless service (running in a container, e.g., on Unraid) needs to ask the human "should the AI run this command?" The human is monitoring via a web client. The flow: + +``` +1. Headless service: POST /api/ask with prompt +2. Server: creates a challenge with unique ID +3. Server: pushes challenge to GUI's pending-ask queue +4. GUI: renders ask modal (or queues for later) +5. Human: clicks Approve or Reject in GUI +6. Server: unblocks the /api/ask call with the answer +7. Headless service: receives the response +``` + +This decouples the headless service from the GUI's render loop. The headless service doesn't need a direct connection to the GUI process. + +--- + +## Connection to the GUI + +The `App` class has a few integration points with the Hook API: + +### In `gui_2.py` `__init__` + +```python +# Register predefined callbacks +self.controller._predefined_callbacks['save_context_preset'] = self.save_context_preset +self.controller._predefined_callbacks['load_context_preset'] = self.load_context_preset +self.controller._predefined_callbacks['_toggle_command_palette'] = self._toggle_command_palette +# ... etc + +# Register gettable fields +self.controller._gettable_fields['show_command_palette'] = 'show_command_palette' +``` + +### In `app_controller.py` + +`AppController._init_ai_and_hooks()` starts the `HookServer` if `--enable-test-hooks` is set. + +### In `sloppy.py` + +Parses `--enable-test-hooks` from CLI args and passes it to the controller. + +--- + +## Common Patterns + +### Test Pattern (from `live_gui` tests) + +```python +def test_my_feature(live_gui): + client = ApiHookClient() + assert client.wait_for_server(timeout=10) + + # Open the command palette + client.push_event("custom_callback", { + "callback": "_toggle_command_palette", + "args": [], + }) + time.sleep(0.5) + + # Verify state + assert client.get_value("show_command_palette") is True + + # Close + client.push_event("custom_callback", { + "callback": "_toggle_command_palette", + "args": [], + }) +``` + +### External Script Pattern + +```python +import requests +import time + +def wait_for_app(url="http://127.0.0.1:8999", timeout=30): + start = time.time() + while time.time() - start < timeout: + try: + r = requests.get(f"{url}/status", timeout=1) + if r.status_code == 200: + return r.json() + except requests.ConnectionError: + pass + time.sleep(0.5) + raise RuntimeError("App did not start in time") + +def trigger_hot_reload(url="http://127.0.0.1:8999"): + r = requests.post(f"{url}/api/gui", json={"action": "click", "item": "btn_hot_reload"}) + r.raise_for_status() + return r.json() +``` + +### Synchronous Ask Pattern + +```python +def ask_human_approval(prompt, url="http://127.0.0.1:8999", timeout=60): + r = requests.post( + f"{url}/api/ask", + json={"prompt": prompt, "timeout": timeout}, + timeout=timeout + 5, + ) + r.raise_for_status() + data = r.json() + return data.get("approved", False), data.get("response", "") +``` + +--- + +## Testing + +### Unit Tests (`tests/test_api_hook_client.py`) + +Tests the `ApiHookClient` class methods in isolation (mocked HTTP). + +### Live GUI Tests + +Use the `live_gui` fixture which auto-launches `sloppy.py --enable-test-hooks`. The `ApiHookClient` then connects to the running app. + +```python +def test_via_hooks(live_gui): + client = ApiHookClient() + # ... use the client ... +``` + +### Headless Service Tests + +`tests/test_headless_service.py` and `tests/test_headless_verification.py` test the full server lifecycle without launching the GUI. + +--- + +## Limitations + +1. **Single GUI instance per port**: The server binds to a specific port. Multiple instances on the same port fail. +2. **No authentication**: The Hook API has no auth. It's intended for localhost use. For remote access, use a reverse proxy with auth. +3. **Synchronous `/api/ask` blocks the server thread**: Long timeouts can starve other requests. Use reasonable timeouts. +4. **JSON-only payloads**: All endpoints take and return JSON. Binary data is not supported. + +--- + +## See Also + +- **[guide_architecture.md](guide_architecture.md#the-task-pipeline-producer-consumer-synchronization)** — Threading model +- **[guide_testing.md](guide_testing.md#the-hook-api-for-integration-tests)** — How tests use this API +- **[guide_meta_boundary.md](guide_meta_boundary.md)** — Application vs Meta-Tooling domain separation +- **`tests/conftest.py:live_gui`** — The session fixture that uses this API +- **`tests/test_api_hook_client.py`** — Client unit tests +- **`tests/test_hooks.py`** — Basic HookServer smoke tests