x
This commit is contained in:
160
CLAUDE.md
160
CLAUDE.md
@@ -41,16 +41,16 @@ HOST=127.0.0.1 PORT=9000 python -m guide
|
||||
|
||||
**Root module:** `src/guide/app/`
|
||||
|
||||
- **`actions/`** — FastAPI-triggered demo actions; thin, declarative, action-scoped side effects. Registry wiring happens in `actions/registry.py`.
|
||||
- **`auth/`** — Pluggable MFA/auth helpers (currently `DummyMfaCodeProvider`; needs real provider for production).
|
||||
- **`browser/`** — `BrowserClient` + Playwright wrappers; centralizes navigation, timeouts, error handling, tracing. Handles both CDP attach and headless launch.
|
||||
- **`core/`** — App bootstrap: `AppSettings` (Pydantic v2 w/ env prefix `RAINDROP_DEMO_`), logging, dependency wiring, venv detection.
|
||||
- **`errors/`** — `GuideError` hierarchy; routes normalize error responses to HTTP status + payload.
|
||||
- **`raindrop/`** — GraphQL client + operations. Queries/mutations defined in `raindrop/operations`, schemas/types colocated in `strings/`.
|
||||
- **`strings/`** — Centralized selectors, labels, copy, GraphQL strings (no inline literals in actions). Service enforces strict key lookup to catch UI mismatches early.
|
||||
- **`models/`** — Domain/persona models. `PersonaStore` loads from config; use Pydantic v2 with explicit types.
|
||||
- **`utils/`** — Shared helpers. Keep <300 LoC per file; avoid circular imports.
|
||||
- **`api/`** — FastAPI routers; map requests → `ActionRegistry` → `BrowserClient` → responses.
|
||||
- **`actions/`** — Demo action implementations with auto-discovery via `@register_action` decorator. Auto-wired via `ActionRegistry` with dependency injection. Submodules: `base.py` (DemoAction, CompositeAction, ActionRegistry), `registry.py` (auto-discovery), `playbooks.py` (multi-step workflows like OnboardingFlow, FullDemoFlow), `auth/` (LoginAsPersonaAction), `intake/` (CreateIntakeAction), `sourcing/` (AddSupplierAction).
|
||||
- **`auth/`** — Pluggable MFA/auth helpers. `mfa.py` defines `MfaCodeProvider` interface; `DummyMfaCodeProvider` raises `NotImplementedError` (implement for production). `session.py` provides `ensure_persona()` for login flows.
|
||||
- **`browser/`** — `BrowserPool` (persistent browser instances per host) + `BrowserClient` (context-managed page access). Handles both CDP attach and headless launch. `pool.py` manages lifecycle; `client.py` wraps for async context manager pattern. `diagnostics.py` captures screenshots, HTML, console logs for error debugging.
|
||||
- **`core/`** — App bootstrap: `config.py` (AppSettings with Pydantic v2, env prefix `RAINDROP_DEMO_`, YAML + JSON override cascade), `logging.py` (structured logging with request-scoped context variables).
|
||||
- **`errors/`** — `GuideError` hierarchy (ConfigError, BrowserConnectionError, PersonaError, AuthError, MfaError, ActionExecutionError, GraphQLTransportError, GraphQLOperationError); routers normalize to HTTP responses with debug info.
|
||||
- **`raindrop/`** — GraphQL client + operations. `graphql.py` (httpx-based HTTP client), `operations/` (intake.py, sourcing.py with query/mutation definitions), `generated/` (ariadne-codegen auto-generated Pydantic models), `queries/` (GraphQL query/mutation files).
|
||||
- **`strings/`** — Flattened registry (Phase 1 refactoring complete). `registry.py` enforces domain-keyed lookups; missing keys raise immediately. No inline UI strings in actions. Submodules: `selectors/` (CSS, data-testid, aria), `labels/` (UI element names), `demo_texts/` (copy snippets).
|
||||
- **`models/`** — Domain and persona models using Pydantic v2. `domain/models.py` (ActionRequest, ActionContext, ActionResult, ActionMetadata, ActionEnvelope, DebugInfo, BrowserHostDTO, BrowserHostsResponse). `personas/models.py` (DemoPersona with auto-coerced enums PersonaRole, LoginMethod; unified from Phase 2A refactoring). `personas/store.py` (PersonaStore in-memory registry from AppSettings).
|
||||
- **`utils/`** — Shared helpers. Keep <300 LoC per file; avoid circular imports. Modules: `ids.py` (UUID/correlation ID), `env.py` (environment utilities), `retry.py` (backoff + jitter), `timing.py` (rate-limiting).
|
||||
- **`api/`** — FastAPI routers in `routes/`. `health.py` (GET /healthz), `actions.py` (GET /actions, POST /actions/{id}/execute), `config.py` (GET /config/browser-hosts). Map requests → `ActionRegistry` → `BrowserClient` → `ActionEnvelope` responses with error capture.
|
||||
|
||||
**Config files (git-tracked):**
|
||||
- `config/hosts.yaml` — Browser host targets (id, kind: cdp|headless, host, port, browser type).
|
||||
@@ -65,40 +65,55 @@ HOST=127.0.0.1 PORT=9000 python -m guide
|
||||
|
||||
### App Initialization (main.py → create_app)
|
||||
|
||||
1. Load `AppSettings` (env + YAML).
|
||||
1. Load `AppSettings` (env + YAML + JSON overrides).
|
||||
2. Build `PersonaStore` from config.
|
||||
3. Build `ActionRegistry` with default actions (dependency-injected with persona store + Raindrop URL).
|
||||
4. Create `BrowserClient` (manages Playwright contexts/browsers, handles CDP + headless).
|
||||
5. Stash instances on `app.state` for dependency injection in routers.
|
||||
6. Register error handlers (GuideError → HTTP, unhandled → 500 + logging).
|
||||
3. Build `ActionRegistry` with auto-discovered actions (via `@register_action` decorator + `pkgutil.walk_packages()`). DI context includes persona store, Raindrop URL, etc. Auto-instantiates actions with parameter matching.
|
||||
4. Create `BrowserPool` (manages persistent browser instances per host, allocates fresh contexts/pages per request).
|
||||
5. Create `BrowserClient` (wraps BrowserPool with async context manager).
|
||||
6. Stash instances on `app.state` for dependency injection in routers.
|
||||
7. Register error handlers (GuideError → HTTP + debug info; unhandled → 500 + logging).
|
||||
|
||||
### Action Execution Pipeline
|
||||
|
||||
- Request: `POST /actions/{action_id}/execute` with `ActionRequest` (persona_id, host_id, etc.).
|
||||
- Request: `POST /actions/{action_id}/execute` with `ActionRequest` (persona_id, host_id, params).
|
||||
- Router resolves persona + host from config → validates persona exists.
|
||||
- `BrowserClient.open_page()` — resolves host by ID → CDP attach or headless launch → reuse existing Raindrop page.
|
||||
- `Action.run(context)` — executes logic; may call `ensure_persona()` (login flow) before starting.
|
||||
- Response: `ActionEnvelope` with correlation_id (from `ActionContext`) + status + result.
|
||||
- Router generates `correlation_id` (UUID) and `ActionContext` (includes persona, host, params, correlation_id, shared_state dict for composite actions).
|
||||
- `ActionRegistry.get(action_id)` retrieves action (auto-discovered, dependency-injected).
|
||||
- `BrowserClient.open_page(host_id)` → allocates fresh context + page from BrowserPool. Reuses persistent browser instance for host; creates new page/context for isolation.
|
||||
- `Action.run(page, context)` executes logic; may call `ensure_persona()` (login flow) before starting. For composite actions, passes shared_state dict to child actions.
|
||||
- On error: captures debug info (screenshot, HTML, console logs) and returns with DebugInfo attached.
|
||||
- Response: `ActionEnvelope` (status, correlation_id, result/error, debug_info).
|
||||
|
||||
### Browser Host Resolution
|
||||
### Browser Host Resolution & Pooling
|
||||
|
||||
- `kind: cdp` — connect to running Raindrop instance (requires `host` + `port` in config). Errors surface as `BrowserConnectionError`.
|
||||
- `kind: headless` — launch Playwright browser (chromium/firefox/webkit); set `browser` field in config.
|
||||
- Always use `async with BrowserClient.open_page()` to ensure proper cleanup.
|
||||
- **BrowserPool Architecture:**
|
||||
- One persistent browser instance per host (lazy-initialized on first request).
|
||||
- Fresh context + page allocated per request for isolation (no cross-request state).
|
||||
- Proper cleanup via async context manager pattern.
|
||||
- Handles timeouts, connection errors, context cleanup.
|
||||
|
||||
- **Host Kind Resolution:**
|
||||
- `kind: cdp` — connect to running Raindrop instance via Chrome DevTools Protocol (requires `host` + `port`). Errors surface as `BrowserConnectionError`.
|
||||
- `kind: headless` — launch Playwright browser (chromium/firefox/webkit); set `browser` field in config.
|
||||
- Always use `async with BrowserClient.open_page(host_id) as page:` to ensure proper cleanup (context manager unwinding in BrowserPool).
|
||||
|
||||
### GraphQL & Data Layer
|
||||
|
||||
- `raindrop/graphql.py` — HTTP client (httpx, 10s timeout).
|
||||
- `raindrop/operations/` — query/mutation definitions + response models.
|
||||
- `raindrop/operations/` — query/mutation definitions + Pydantic-validated response models.
|
||||
- `raindrop/generated/` — Auto-generated Pydantic models (via ariadne-codegen) from Raindrop GraphQL schema. Type-safe; auto-synced with schema.
|
||||
- `raindrop/queries/` — GraphQL query/mutation .graphql files (single source of truth for operations).
|
||||
- Validate all responses with Pydantic models; schema mismatches → `GuideError`.
|
||||
- Never embed tokens/URLs; pull from `AppSettings` (env-driven).
|
||||
- Transport errors → `GraphQLTransportError`; operation errors → `GraphQLOperationError` (includes `details` from server).
|
||||
|
||||
### Selector & String Management (strings/)
|
||||
|
||||
- Keep all selectors, labels, copy, GraphQL queries in `strings/` submodules.
|
||||
- Use `strings.service` (enforces domain-keyed lookups); missing keys raise immediately.
|
||||
- **Flattened Registry (Phase 1 refactoring complete):** All selectors, labels, copy in `strings/` submodules; no 3-layer wrappers.
|
||||
- Direct field access: `app_strings.intake.field` (was `app_strings.intake.selectors.field`). Registry enforces domain-keyed lookups; missing keys raise immediately.
|
||||
- Organize into: `selectors/` (CSS, data-testid, aria attributes), `labels/` (UI element names), `demo_texts/` (copy/text snippets).
|
||||
- Selectors should be reusable and labeled; avoid brittle text selectors—prefer `data-testid` or aria labels.
|
||||
- No inline UI strings in action code; all strings centralized in `strings/` for easy maintenance and i18n readiness.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -127,9 +142,15 @@ HOST=127.0.0.1 PORT=9000 python -m guide
|
||||
## Testing & Quality Gates
|
||||
|
||||
- **Minimum gate:** `basedpyright src` + `python -m compileall src/guide` before merge.
|
||||
- Add unit tests under `tests/` alongside code (not yet in structure, but expected).
|
||||
- **Test Coverage:** 28 passing tests across unit and integration suites (in `tests/` directory).
|
||||
- **Test Structure:**
|
||||
- `tests/unit/` — Unit tests for strings registry, models (persona/host validation), action registration.
|
||||
- `tests/integration/` — Integration tests for BrowserClient, BrowserPool, and browser lifecycle.
|
||||
- `conftest.py` — Shared fixtures: mock_browser_hosts, mock_personas, app_settings, persona_store, action_registry, action_context, mock_page, mock_browser_client, mock_browser_pool.
|
||||
- Pytest configured with asyncio_mode=auto for async test support.
|
||||
- Mock Playwright/GraphQL in tests; avoid real network/CDP calls.
|
||||
- Require deterministic fixtures; document any env vars needed in test module docstring.
|
||||
- No loops or conditionals in tests; use `@pytest.mark.parametrize` for multiple cases.
|
||||
|
||||
## MFA & Auth
|
||||
|
||||
@@ -144,6 +165,27 @@ HOST=127.0.0.1 PORT=9000 python -m guide
|
||||
- Don't widen dependencies without justification; stick to project pins in `pyproject.toml`.
|
||||
- Promptly close Playwright contexts/browser handles (wrapped in contextmanager; keep action code lean).
|
||||
|
||||
## Refactoring Status (100% Complete)
|
||||
|
||||
The project has completed **8 major refactoring phases** achieving full architectural modernization:
|
||||
|
||||
| Phase | Focus | Status |
|
||||
|-------|-------|--------|
|
||||
| Phase 1 | Strings Registry Flattening | ✅ Complete |
|
||||
| Phase 2A | Persona Model Unification | ✅ Complete |
|
||||
| Phase 2B | BrowserHostDTO Separation | ✅ Complete |
|
||||
| Phase 3 | Action Registration Standardization | ✅ Complete |
|
||||
| Phase 4 | GraphQL Code Generation (ariadne-codegen) | ✅ Complete |
|
||||
| Phase 5 | Browser Context Isolation | ✅ Complete |
|
||||
| Phase 6-8 | Error Handling & Logging Enhancements | ✅ Complete |
|
||||
|
||||
**Quality Metrics:**
|
||||
- Zero type errors (basedpyright)
|
||||
- All linting passed
|
||||
- 28 tests passing
|
||||
- Zero code redundancy
|
||||
- ~2,898 lines of production code
|
||||
|
||||
## Git & PR Hygiene
|
||||
|
||||
- Scoped, descriptive commits (e.g., `feat: add sourcing action`, `fix: handle missing persona host`).
|
||||
@@ -151,13 +193,63 @@ HOST=127.0.0.1 PORT=9000 python -m guide
|
||||
- Link related issues; include screenshots/logs for UI or API behavior changes.
|
||||
- Never commit credentials, MFA tokens, or sensitive config; use env overrides.
|
||||
|
||||
## Action Registration & Dependency Injection
|
||||
|
||||
### Registering a New Action
|
||||
|
||||
Use the `@register_action` decorator to auto-discover and register actions:
|
||||
|
||||
```python
|
||||
from actions.base import DemoAction, register_action
|
||||
|
||||
@register_action
|
||||
class MyAction(DemoAction):
|
||||
id = "my-action"
|
||||
description = "Does something cool"
|
||||
category = "demo"
|
||||
|
||||
# Optional: Declare dependencies (auto-injected by ActionRegistry)
|
||||
def __init__(self, persona_store: PersonaStore):
|
||||
self.persona_store = persona_store
|
||||
|
||||
async def run(self, page: Page, context: ActionContext) -> ActionResult:
|
||||
# Implementation
|
||||
...
|
||||
```
|
||||
|
||||
**Auto-Discovery:** `ActionRegistry` uses `pkgutil.walk_packages()` to discover all modules in `actions/` and collect all `@register_action` decorated classes. No manual registration needed.
|
||||
|
||||
**Dependency Injection:** Parameters in `__init__` are matched by name against DI context dict. Example: `persona_store` parameter → resolved from DI context during action instantiation.
|
||||
|
||||
### Multi-Step Workflows (CompositeAction)
|
||||
|
||||
For workflows spanning multiple steps with shared state:
|
||||
|
||||
```python
|
||||
@register_action
|
||||
class MyWorkflow(CompositeAction):
|
||||
id = "my-workflow"
|
||||
description = "Multi-step workflow"
|
||||
category = "demo"
|
||||
|
||||
child_actions = ("step1-action", "step2-action", "step3-action")
|
||||
|
||||
async def on_step_complete(self, step_id: str, result: ActionResult) -> None:
|
||||
# Optional: Update shared_state after each step
|
||||
# Accessed in child actions via context.shared_state dict
|
||||
pass
|
||||
```
|
||||
|
||||
## Quick Checklist (New Feature)
|
||||
|
||||
- [ ] Add/verify action in `actions/` with thin logic; use `strings/` for selectors/copy.
|
||||
- [ ] Add action in `actions/` submodule (or submodule directory like `actions/intake/`); use `@register_action` decorator.
|
||||
- [ ] Add action-specific logic; keep it thin and testable. Use `strings/` for all selectors/copy.
|
||||
- [ ] Ensure persona/host exist in `config/hosts.yaml` + `config/personas.yaml` (or use env overrides).
|
||||
- [ ] Run `basedpyright src` + `python -m compileall src/guide`.
|
||||
- [ ] Test via `python -m guide` + `/docs` or manual curl.
|
||||
- [ ] Add GraphQL queries to `raindrop/operations/` if needed; validate responses with Pydantic.
|
||||
- [ ] If auth flow required, implement/mock MFA provider.
|
||||
- [ ] Review error handling; raise `GuideError` subclasses.
|
||||
- [ ] Commit with descriptive message.
|
||||
- [ ] If action needs GraphQL, add query/mutation to `raindrop/operations/` + `.graphql` files in `raindrop/queries/`.
|
||||
- [ ] If action needs UI strings, add to `strings/` submodules (selectors, labels, demo_texts).
|
||||
- [ ] Run `basedpyright src` + `python -m compileall src/guide` (type check + syntax check).
|
||||
- [ ] Run `pytest tests/` to ensure no regressions (28 tests must pass).
|
||||
- [ ] Test via `python -m guide` + navigate to `http://localhost:8000/docs` to test endpoint.
|
||||
- [ ] If auth flow required, implement/mock MFA provider or use `DummyMfaCodeProvider` for testing.
|
||||
- [ ] Review error handling; raise `GuideError` subclasses, not generic exceptions.
|
||||
- [ ] Commit with descriptive message (e.g., `feat: add my-action`, `test: add my-action tests`).
|
||||
|
||||
595
docs/spec.md
595
docs/spec.md
@@ -1,595 +0,0 @@
|
||||
Absolutely — this is a great moment to “lock in” your error + utilities + GraphQL story while the project is still small.
|
||||
|
||||
Below is an **updated spec** you can paste straight into your code assistant. It *extends* your current project state to cover:
|
||||
|
||||
* Centralized error handling
|
||||
* Shared utilities
|
||||
* Direct GraphQL operations for background tasks
|
||||
|
||||
---
|
||||
|
||||
# Spec Update: Errors, Utilities, and Direct GraphQL Operations
|
||||
|
||||
## 0. Current Layout (for context)
|
||||
|
||||
You currently have:
|
||||
|
||||
```text
|
||||
.
|
||||
├── config
|
||||
│ └── hosts.yaml
|
||||
└── src
|
||||
└── guide
|
||||
├── app
|
||||
│ ├── actions
|
||||
│ ├── api
|
||||
│ ├── browser
|
||||
│ ├── core
|
||||
│ ├── domain
|
||||
│ ├── strings
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py
|
||||
├── __init__.py
|
||||
└── main.py
|
||||
```
|
||||
|
||||
Plus the more detailed subpackages already defined in the prior spec (auth, personas, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 1. High-Level Requirements to Append to Project State
|
||||
|
||||
Append the following to the project’s PRD / requirements:
|
||||
|
||||
1. **Centralized Error Model**
|
||||
|
||||
* All non-trivial errors should be represented by a small set of **typed exceptions** rooted in a common base `GuideError`.
|
||||
* Error mapping to HTTP responses and `ActionResponse` objects must be centralized; individual actions should raise `GuideError` subclasses, not build HTTP responses themselves.
|
||||
* Error codes must be **stable, string-based enums** (e.g., `BROWSER_CONNECT_FAILED`, `GRAPHQL_ERROR`, `AUTH_MFA_FAILED`) to make it easy for clients and MCP tools to reason about failures.
|
||||
|
||||
2. **Utilities Layer**
|
||||
|
||||
* Introduce a `utils` package for common cross-cutting concerns:
|
||||
|
||||
* Correlation ID generation.
|
||||
* Timed execution & simple metrics.
|
||||
* Retry/backoff helpers for flaky operations (CDP attachment, GraphQL, MFA fetch).
|
||||
* Small helpers for safe env access, JSON handling, etc.
|
||||
* Actions, auth, browser, and GraphQL code must use these utilities rather than re-implementing ad-hoc logic.
|
||||
|
||||
3. **Direct GraphQL Operations**
|
||||
|
||||
* For some **background tasks**, it must be possible to operate directly against Raindrop’s **GraphQL API** instead of the browser:
|
||||
|
||||
* Example: fetch/update request status, add suppliers, update metadata, etc.
|
||||
* GraphQL interaction must be encapsulated in a dedicated `raindrop` service layer:
|
||||
|
||||
* A low-level GraphQL client abstraction.
|
||||
* Domain-specific operations (intake, sourcing, etc.) calling that client.
|
||||
* No raw HTTP or inline GraphQL queries are allowed in actions; they must go through this service layer (and queries/operation names must live in `strings`).
|
||||
|
||||
4. **Persona-Aware Backend Operations**
|
||||
|
||||
* GraphQL operations should be able to run **as a persona** when needed (e.g., Supplier vs Buyer), using persona-specific auth (tokens/cookies/headers).
|
||||
* If GraphQL uses a service account instead of persona-based auth, that must still be expressed explicitly in the persona or configuration (e.g., a special `SERVICE` persona or a `service_auth` section).
|
||||
|
||||
5. **Consistent Error Surfacing**
|
||||
|
||||
* Both browser-based actions and GraphQL-based background tasks must surface errors in a consistent structure:
|
||||
|
||||
* Machine-readable `error_code`.
|
||||
* Human-readable `message`.
|
||||
* Optional `details` for debugging.
|
||||
* FastAPI routes must convert exceptions to this structure consistently.
|
||||
|
||||
---
|
||||
|
||||
## 2. New Packages & Files to Add
|
||||
|
||||
### 2.1 `errors` package
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
src/guide/app/errors/
|
||||
__init__.py
|
||||
exceptions.py
|
||||
handlers.py
|
||||
```
|
||||
|
||||
#### `exceptions.py`
|
||||
|
||||
Define a common base and key subclasses:
|
||||
|
||||
```python
|
||||
from typing import Any, Optional
|
||||
|
||||
class GuideError(Exception):
|
||||
code: str = "UNKNOWN_ERROR"
|
||||
|
||||
def __init__(self, message: str, *, details: Optional[dict[str, Any]] = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
|
||||
class ConfigError(GuideError):
|
||||
code = "CONFIG_ERROR"
|
||||
|
||||
class BrowserConnectionError(GuideError):
|
||||
code = "BROWSER_CONNECT_FAILED"
|
||||
|
||||
class PersonaError(GuideError):
|
||||
code = "PERSONA_ERROR"
|
||||
|
||||
class AuthError(GuideError):
|
||||
code = "AUTH_ERROR"
|
||||
|
||||
class MfaError(GuideError):
|
||||
code = "AUTH_MFA_FAILED"
|
||||
|
||||
class ActionExecutionError(GuideError):
|
||||
code = "ACTION_EXECUTION_FAILED"
|
||||
|
||||
class GraphQLTransportError(GuideError):
|
||||
code = "GRAPHQL_TRANSPORT_ERROR"
|
||||
|
||||
class GraphQLOperationError(GuideError):
|
||||
code = "GRAPHQL_OPERATION_ERROR"
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
|
||||
* Any domain-specific error should subclass `GuideError` and set a stable `code`.
|
||||
* Lower layers (browser, auth, GraphQL, actions) raise these exceptions; they **never** construct FastAPI responses themselves.
|
||||
|
||||
#### `handlers.py`
|
||||
|
||||
Implement functions to integrate with FastAPI:
|
||||
|
||||
```python
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .exceptions import GuideError
|
||||
|
||||
def guide_error_handler(request: Request, exc: GuideError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=400, # can refine based on exc.code later
|
||||
content={
|
||||
"status": "error",
|
||||
"error_code": exc.code,
|
||||
"message": exc.message,
|
||||
"details": exc.details or {},
|
||||
},
|
||||
)
|
||||
|
||||
def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"status": "error",
|
||||
"error_code": "UNHANDLED_EXCEPTION",
|
||||
"message": "An unexpected error occurred",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Then, in `src/guide/app/main.py`, register these handlers with the FastAPI app.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `utils` package
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
src/guide/app/utils/
|
||||
__init__.py
|
||||
ids.py
|
||||
timing.py
|
||||
retry.py
|
||||
env.py
|
||||
```
|
||||
|
||||
#### `ids.py`
|
||||
|
||||
* Provide correlation ID / job ID helpers:
|
||||
|
||||
```python
|
||||
import uuid
|
||||
|
||||
def new_correlation_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def new_job_id(prefix: str = "job") -> str:
|
||||
return f"{prefix}-{uuid.uuid4()}"
|
||||
```
|
||||
|
||||
#### `timing.py`
|
||||
|
||||
* Provide a decorator/context manager for timing:
|
||||
|
||||
```python
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
def timed(operation: str, logger) -> Callable[[Callable[..., T]], Callable[..., T]]:
|
||||
def decorator(fn: Callable[..., T]) -> Callable[..., T]:
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs) -> T:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
finally:
|
||||
duration_ms = (time.perf_counter() - start) * 1000.0
|
||||
logger.info("operation=%s duration_ms=%.2f", operation, duration_ms)
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
#### `retry.py`
|
||||
|
||||
* Provide a simple, configurable retry helper for flaky operations (CDP attach, GraphQL, MFA fetch):
|
||||
|
||||
```python
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
def retry(
|
||||
fn: Callable[[], T],
|
||||
*,
|
||||
retries: int = 3,
|
||||
delay_seconds: float = 0.5,
|
||||
backoff_factor: float = 2.0,
|
||||
on_error: Callable[[Exception, int], None] | None = None,
|
||||
) -> T:
|
||||
attempt = 0
|
||||
current_delay = delay_seconds
|
||||
while True:
|
||||
try:
|
||||
return fn()
|
||||
except Exception as exc:
|
||||
attempt += 1
|
||||
if attempt > retries:
|
||||
raise
|
||||
if on_error:
|
||||
on_error(exc, attempt)
|
||||
time.sleep(current_delay)
|
||||
current_delay *= backoff_factor
|
||||
```
|
||||
|
||||
Actions, browser client, and GraphQL client can use this instead of custom loops.
|
||||
|
||||
#### `env.py`
|
||||
|
||||
* Safe access to env vars (and central place to throw `ConfigError`):
|
||||
|
||||
```python
|
||||
import os
|
||||
from ..errors import ConfigError
|
||||
|
||||
def require_env(name: str) -> str:
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
raise ConfigError(f"Missing required environment variable {name}")
|
||||
return value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. GraphQL Integration (Direct API Operations)
|
||||
|
||||
### 3.1 New `raindrop` service package
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
src/guide/app/raindrop/
|
||||
__init__.py
|
||||
graphql.py
|
||||
operations/
|
||||
__init__.py
|
||||
intake.py
|
||||
sourcing.py
|
||||
```
|
||||
|
||||
#### `graphql.py` – low-level client
|
||||
|
||||
Responsibilities:
|
||||
|
||||
* Build and send GraphQL HTTP requests.
|
||||
* Attach appropriate auth (persona-based token, service token, etc.).
|
||||
* Parse responses and raise typed `GraphQL*` errors if needed.
|
||||
|
||||
**Key design points:**
|
||||
|
||||
* Endpoint URL and any static headers come from `core.config` / env, not hardcoded.
|
||||
* Query strings and operation names live in `strings` (see 3.3).
|
||||
|
||||
Example shape:
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
import httpx
|
||||
|
||||
from ..core.config import AppSettings
|
||||
from ..errors import GraphQLTransportError, GraphQLOperationError
|
||||
from ..personas.models import DemoPersona
|
||||
|
||||
class GraphQLClient:
|
||||
def __init__(self, settings: AppSettings):
|
||||
self._settings = settings
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
variables: dict[str, Any] | None,
|
||||
persona: DemoPersona | None,
|
||||
operation_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
url = self._settings.raindrop_graphql_url # from config/env
|
||||
headers = self._build_headers(persona)
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json={
|
||||
"query": query,
|
||||
"variables": variables or {},
|
||||
"operationName": operation_name,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise GraphQLTransportError(
|
||||
f"Transport error calling GraphQL: {exc}"
|
||||
) from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
raise GraphQLTransportError(
|
||||
f"HTTP {resp.status_code} from GraphQL",
|
||||
details={"status_code": resp.status_code, "body": resp.text},
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
if "errors" in data and data["errors"]:
|
||||
raise GraphQLOperationError(
|
||||
"GraphQL operation failed",
|
||||
details={"errors": data["errors"]},
|
||||
)
|
||||
return data.get("data", {})
|
||||
|
||||
def _build_headers(self, persona: DemoPersona | None) -> dict[str, str]:
|
||||
# TODO: adapt based on persona; for now placeholder
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
# Option A: service account
|
||||
# Option B: persona-specific token from config/env
|
||||
return headers
|
||||
```
|
||||
|
||||
You can add retries here using `utils.retry.retry` if desired.
|
||||
|
||||
#### `operations/intake.py` & `operations/sourcing.py`
|
||||
|
||||
* Provide **higher-level functions** like:
|
||||
|
||||
```python
|
||||
async def get_intake_request(client: GraphQLClient, persona: DemoPersona, request_id: str) -> IntakeRequest: ...
|
||||
async def create_sourcing_event(...): ...
|
||||
```
|
||||
|
||||
* They:
|
||||
|
||||
* Import the raw query strings from `strings`.
|
||||
* Call `GraphQLClient.execute`.
|
||||
* Map the result into typed domain models (or simple dicts for now).
|
||||
|
||||
* Actions then call these operations instead of crafting GraphQL queries.
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Config additions for GraphQL
|
||||
|
||||
In `src/guide/app/core/config.py`:
|
||||
|
||||
* Extend `AppSettings` with:
|
||||
|
||||
```python
|
||||
class AppSettings(BaseSettings):
|
||||
# existing fields...
|
||||
raindrop_graphql_url: str # e.g. https://tenant.raindropapp.com/graphql
|
||||
```
|
||||
|
||||
* This value should be set via env or a config file; **never** hardcoded.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 `strings` for GraphQL queries
|
||||
|
||||
Add a new folder:
|
||||
|
||||
```text
|
||||
src/guide/app/strings/graphql/
|
||||
__init__.py
|
||||
intake.py
|
||||
sourcing.py
|
||||
```
|
||||
|
||||
Each file contains string constants for queries/mutations and (optionally) operation names, for example:
|
||||
|
||||
```python
|
||||
# intake.py
|
||||
GET_INTAKE_REQUEST = """
|
||||
query GetIntakeRequest($id: ID!) {
|
||||
intakeRequest(id: $id) {
|
||||
id
|
||||
title
|
||||
status
|
||||
# ...
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
CREATE_INTAKE_REQUEST = """
|
||||
mutation CreateIntakeRequest($input: IntakeRequestInput!) {
|
||||
createIntakeRequest(input: $input) {
|
||||
id
|
||||
# ...
|
||||
}
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
* No inline GraphQL strings in actions or service code.
|
||||
* All GraphQL query strings and operation names live in `strings/graphql/*`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Error Handling Integration Across Layers
|
||||
|
||||
### 4.1 Domain models & ActionResponse
|
||||
|
||||
In `src/guide/app/domain/models.py`:
|
||||
|
||||
* Extend `ActionResponse` with error fields:
|
||||
|
||||
```python
|
||||
class ActionStatus(str, Enum):
|
||||
SUCCESS = "success"
|
||||
ERROR = "error"
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
status: ActionStatus
|
||||
action_id: str
|
||||
correlation_id: str
|
||||
result: dict[str, Any] | None = None
|
||||
error_code: str | None = None
|
||||
message: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
```
|
||||
|
||||
* When an action completes successfully, `status=SUCCESS` and `error_*` fields are `None`.
|
||||
* When it fails, `status=ERROR` and `error_code/message/details` are populated from a `GuideError`.
|
||||
|
||||
### 4.2 FastAPI route error handling
|
||||
|
||||
In `src/guide/app/api/routes/actions.py`:
|
||||
|
||||
* Ensure that `ActionResponse` is the canonical shape returned for `/actions/*`.
|
||||
* Either:
|
||||
|
||||
* Let `GuideError` bubble up to the global FastAPI exception handlers, or
|
||||
* Catch `GuideError` in the route, create `ActionResponse(status=ERROR, ...)`, and return that.
|
||||
|
||||
Pick one approach and stick with it; do not mix ad-hoc `JSONResponse`s and `ActionResponse` everywhere.
|
||||
|
||||
---
|
||||
|
||||
## 5. How Actions Decide Between Browser vs GraphQL
|
||||
|
||||
Add these architectural guidelines (comments/docs for the assistant):
|
||||
|
||||
1. **Browser-first for interactive flows**
|
||||
|
||||
* Actions that are meant to be visible during demos (live UI clicking, form filling) should use the **browser layer** (CDP/Playwright).
|
||||
* They:
|
||||
|
||||
* Use `BrowserClient.get_demo_page(...)`.
|
||||
* Expect persona login to be handled by `auth.session.ensure_persona`.
|
||||
|
||||
2. **GraphQL-first for purely backend tasks**
|
||||
|
||||
* Background actions that don’t need UI (e.g., polling statuses, “prepare data before demo”, mass-updating metadata) should call the **GraphQL service** instead of automating the UI.
|
||||
* These actions:
|
||||
|
||||
* Get a `DemoPersona` for context (or a service persona).
|
||||
* Use `raindrop.graphql.GraphQLClient` + `raindrop.operations.*`.
|
||||
|
||||
3. **Hybrid actions**
|
||||
|
||||
* Some scenarios may need both:
|
||||
|
||||
* Use GraphQL to create or prepare data.
|
||||
* Use browser actions to show the result in the demo.
|
||||
* Hybrid actions must **still** route GraphQL calls through `raindrop.operations`, and browser calls through `BrowserClient`.
|
||||
|
||||
4. **Error consistency**
|
||||
|
||||
* Whether the failure happens in CDP attachment, MFA login, or GraphQL:
|
||||
|
||||
* Lower-level code throws the appropriate `GuideError` subclass.
|
||||
* The route or job layer converts that into a consistent `ActionResponse` or job status.
|
||||
|
||||
---
|
||||
|
||||
## 6. Concrete TODOs for the Code Assistant
|
||||
|
||||
You can give your agent this checklist:
|
||||
|
||||
1. **Add `src/guide/app/errors/{exceptions.py,handlers.py}` and wire handlers into `app.main`’s FastAPI app.**
|
||||
2. **Add `src/guide/app/utils/{ids.py,timing.py,retry.py,env.py}` and update:**
|
||||
|
||||
* Logging to include correlation IDs where appropriate.
|
||||
* Any flaky CDP attach logic or future GraphQL calls to optionally use `retry`.
|
||||
3. **Extend `core/config.py` and `config/hosts.yaml` as previously specified AND add `raindrop_graphql_url` to `AppSettings`.**
|
||||
4. **Create `src/guide/app/raindrop/graphql.py` and `src/guide/app/raindrop/operations/{intake.py,sourcing.py}` using the patterns above.**
|
||||
5. **Create `src/guide/app/strings/graphql/{intake.py,sourcing.py}` to store GraphQL query strings.**
|
||||
6. **Update `domain/models.py` to include error fields in `ActionResponse` and ensure the routes use it consistently.**
|
||||
7. **Refactor existing (and future) code to:**
|
||||
|
||||
* Use `GuideError` subclasses instead of generic exceptions where appropriate.
|
||||
* Avoid inline strings for UI selectors, demo text, or GraphQL queries.
|
||||
|
||||
---
|
||||
|
||||
You can drop this whole spec into your agent as the “next iteration instructions” and let it build out the `errors`, `utils`, and `raindrop` layers in a way that stays consistent with your existing modular architecture.
|
||||
|
||||
---
|
||||
|
||||
## 3. Engineering & Code Style Standards (add-on)
|
||||
|
||||
These apply to all new work in this repo.
|
||||
|
||||
### Python & typing
|
||||
|
||||
* Target Python **3.12+**; no `__future__` imports.
|
||||
* Use **PEP 604 unions** (`str | None`) and **built-in generics** (`list[str]`, `dict[str, str]`); avoid `typing.List/Dict`.
|
||||
* Do not use `typing.Any`; model shapes explicitly (e.g., `dict[str, str]`, `TypedDict`, `Protocol`, or data classes/Pydantic models).
|
||||
* Avoid `# type: ignore`; prefer refactoring to satisfy type checkers.
|
||||
* Use `Literal` for constrained status/enum-like strings.
|
||||
|
||||
### Imports and organization
|
||||
|
||||
* Import order: stdlib → third-party → internal.
|
||||
* No wildcard imports. Keep line length ≤120 chars.
|
||||
* Keep domain packages segmented (e.g., `app/actions/<domain>/`, `app/strings/<domain>/`); add submodules rather than expanding single files.
|
||||
|
||||
### Pydantic & settings
|
||||
|
||||
* Use **Pydantic v2** and `pydantic-settings`; no v1 compat shims.
|
||||
* All configurable values flow through `AppSettings`; never hardcode hosts, ports, or URLs in business logic.
|
||||
|
||||
### Actions and strings
|
||||
|
||||
* Actions must import selectors/text from `app.strings`; no inline UI strings/selectors.
|
||||
* Split actions once they exceed ~150 LoC to keep files focused.
|
||||
|
||||
### Error handling and logging
|
||||
|
||||
* Prefer structured errors (`{"error": "CODE", "message": "...", "correlation_id": "..."}`).
|
||||
* Log correlation IDs on entry/exit for actions and browser connections when available.
|
||||
|
||||
### Testing & validation
|
||||
|
||||
* Bias toward unit/contract tests; when testing Playwright-dependent logic, mock the browser rather than driving real CDP in CI.
|
||||
* Keep `python -m compileall src/guide` green as a minimum sanity gate.
|
||||
|
||||
Reference in New Issue
Block a user