docs(api-hooks): add guide_api_hooks.md
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user