This commit is contained in:
2025-11-22 00:54:25 +00:00
parent abca5b612a
commit d54eeeaeeb
29 changed files with 919 additions and 609 deletions

View File

@@ -1,498 +1,595 @@
Heres a PRD you can hand to FutureYou (or a code assistant) and not want to scream at it later.
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
---
# PRD: Raindrop Demo Automation Service (FastAPI + Playwright/CDP)
# Spec Update: Errors, Utilities, and Direct GraphQL Operations
* **Owner:** You
* **Service Name (working):** `raindrop-demo-automation`
* **Primary Host:** `192.168.50.151` (homelab server)
* **Clients:** Stream Deck (HTTP actions), CLI tools, or other automation
## 0. Current Layout (for context)
---
## 1. Background & Problem
You run live Raindrop demos and already:
* Launch Chrome with **CDP (remote debugging)** enabled.
* Manually drive the UI, assisted by **Stream Deck** macros today.
* Have **Python/Playwright** scripts that can attach to that browser and perform actions.
Limitations right now:
* Scripts run **locally**, tied to one machine.
* There isnt a **single, extensible service** orchestrating “demo actions” (fill forms, advance steps, etc.).
* Strings, selectors, and values are **hardcoded and scattered** across scripts, making it easy for a code assistant to duplicate or diverge.
You want:
* A **FastAPI service** on your homelab (`192.168.50.151`) that exposes HTTP endpoints for demo actions.
* Config that supports **multiple demo machines** (desktop, laptop via VPN) and is easy to switch.
* A **modular architecture**: small packages, low line length, clear facades, zero magic values in-line.
* All **input strings / UI texts / selectors** in a **single package** to avoid duplication and make future tooling straightforward.
---
## 2. Goals
### Functional Goals
1. **Trigger demo actions via HTTP**
* Stream Deck calls endpoints like `POST /actions/fill-intake` or `POST /actions/add-suppliers`.
* Actions attach to an existing CDP-enabled browser and run against the current Raindrop tab.
2. **Support multiple browser hosts**
* Each demo machine (desktop, laptop via VPN) is modeled as a **Browser Host** with:
* ID (e.g., `desktop`, `laptop`, `lab-vm`)
* Host/IP (e.g., `192.168.50.100`)
* CDP port (e.g., `9222`)
* The API caller can specify which host to target per request (query param or payload).
3. **Encapsulate all demo strings and selectors**
* No “free-floating” strings in action code for:
* Input text (demo narratives, supplier names, event names).
* UI labels / button text / placeholder strings.
* CSS/XPath selectors.
* These live in a central `strings` package.
4. **Extensible actions architecture**
* Easy to add new actions (e.g., “create 3-bids-and-buy event”, “run 3-way match”) without copy-paste.
* A registry/facade manages all actions by ID.
### NonGoals
* No UI or dashboard for now (everything via HTTP / Stream Deck).
* No multi-tenant security model beyond basic network trust.
* No scheduling / long-running workflows (actions are short-lived scripts).
---
## 3. Users & Usage
### Primary User
* **Demo Host (you)**
Use Stream Deck to trigger HTTP calls to the service while driving a Raindrop demo.
### Usage Scenarios
1. **Simple Intake Fill**
* You navigate to the Raindrop intake screen.
* Hit a Stream Deck button that calls `POST /actions/fill-intake-basic` targeting your current machine.
* The service attaches to your browser and fills out description + moves to the next step.
2. **Three Suppliers Auto-Add**
* On the “Suppliers” step of a sourcing event, you hit a Stream Deck button.
* The service adds three predefined suppliers.
3. **Multi-host Setup**
* Some days you demo from desktop; some days from laptop via VPN.
* You switch the Stream Deck config or action payload to target `browser_host_id=laptop`.
---
## 4. High-Level Architecture
### Components
1. **FastAPI Application (`app`)**
* Exposes REST endpoints.
* Does auth (if/when needed), routing, validation, and returns structured responses.
2. **Config & Settings (`app.core.config`)**
* Uses Pydantic `BaseSettings` + optional YAML for:
* Known browser hosts (id, host, port).
* Default browser host.
* Raindrop tenant URL.
* No hardcoded values in code; read from config/env.
3. **Strings Package (`app.strings`)**
* Contains **all**:
* Demo text (descriptions, comments).
* UI labels & button names.
* CSS/Playwright selectors.
* Structured by domain (intake, sourcing, payables).
4. **Browser Connector (`app.browser`)**
* Encapsulates Playwright CDP connections.
* Provides a `BrowserClient` or `BrowserFacade`:
* `connect(browser_host)` → returns a connection.
* `get_raindrop_page()` → returns a `Page` to act on (e.g., the tab whose URL matches Raindrop host).
* This is the only layer that knows about CDP endpoints.
5. **Action Framework (`app.actions`)**
* Base `DemoAction` protocol/class:
* `id: str`
* `run(page, context) -> ActionResult`
* Each domain package implements focused actions:
* `intake.py` (fill forms, advance steps).
* `sourcing.py` (add suppliers, configure event).
* `navigation.py` (jump to certain pages).
* An `ActionRegistry` maps action IDs → action objects.
6. **Domain Models (`app.domain`)**
* Typed models with Pydantic for:
* `ActionRequest`, `ActionResponse`.
* `BrowserHost` and config structures.
* `ActionContext` (host id, session info, optional parameters).
---
## 5. Detailed Design
### 5.1 Directory Layout (Python Package)
Example layout (you can tweak):
You currently have:
```text
app/
__init__.py
main.py # FastAPI app instance
api/
__init__.py
routes_actions.py # /actions endpoints
routes_config.py # optional: expose config/browser hosts
routes_health.py # /healthz
core/
__init__.py
config.py # Pydantic settings and config loading
logging.py # logging setup
domain/
__init__.py
models.py # ActionRequest, ActionResponse, BrowserHost, ActionContext
enums.py # Action IDs, maybe host status enums
browser/
__init__.py
client.py # BrowserFacade/BrowerClient implementation
page_selector.py # logic to pick the correct Raindrop tab
actions/
__init__.py
base.py # DemoAction interface, ActionRegistry
intake.py # intake-related actions
sourcing.py # sourcing-related actions
navigation.py # navigation actions
strings/
__init__.py
selectors.py # all CSS/xpath selectors
labels.py # visible UI labels/button names
demo_texts.py # all pre-baked text content
config/
strings.yaml # optional external strings source
hosts.yaml # browser host definitions (desktop, laptop, etc.)
.
├── config
│ └── hosts.yaml
└── src
└── guide
├── app
│ ├── actions
│ ├── api
│ ├── browser
│ ├── core
├── domain
├── strings
│ ├── __init__.py
│ ├── main.py
├── __init__.py
└── main.py
```
**Guideline:**
Each module should be small, focused, and under ~200300 lines. If a module grows, split it further (e.g., `intake_create.py`, `intake_approve.py`).
Plus the more detailed subpackages already defined in the prior spec (auth, personas, etc.).
---
### 5.2 Config & Settings
## 1. High-Level Requirements to Append to Project State
Use Pydantic `BaseSettings` to load:
Append the following to the projects PRD / requirements:
* Environment variables (for secrets, host IP).
* YAML/JSON for structured config (hosts, string groups).
1. **Centralized Error Model**
Example conceptual 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 Raindrops **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
# app/core/config.py
from pydantic import BaseSettings
from typing import Dict
from typing import Any, Optional
class BrowserHostConfig(BaseSettings):
id: str
host: str
cdp_port: int
class GuideError(Exception):
code: str = "UNKNOWN_ERROR"
class AppSettings(BaseSettings):
raindrop_base_url: str
default_browser_host_id: str
browser_hosts: Dict[str, BrowserHostConfig] # keyed by id
def __init__(self, message: str, *, details: Optional[dict[str, Any]] = None):
super().__init__(message)
self.message = message
self.details = details or {}
class Config:
env_prefix = "RAINDROP_DEMO_"
# Optionally load from config/hosts.yaml
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:**
* No raw IPs or ports in code. IP `192.168.50.151` is used at deployment level (e.g., uvicorn bind host), not in business logic.
* Changing default host or adding a laptop host should mean:
* 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.
* Update `hosts.yaml` and/or env var.
* Restart service, no code changes.
#### `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.
---
### 5.3 Strings Package
### 2.2 `utils` package
**Objective:** Any textual thing that might be typed into or read from the UI lives here.
Create:
Submodules:
```text
src/guide/app/utils/
__init__.py
ids.py
timing.py
retry.py
env.py
```
1. `selectors.py`
#### `ids.py`
* All selectors used by Playwright:
* Provide correlation ID / job ID helpers:
* e.g., `INTAKE_DESCRIPTION_FIELD`, `BUTTON_NEXT`, `SUPPLIER_SEARCH_INPUT`.
* Prefer centrally named constants:
```python
import uuid
* `selectors.INTAKE.DESCRIPTION_FIELD`
* `selectors.SOURCING.SUPPLIER_SEARCH_INPUT`
* Keep selectors DRY, referenced by actions.
def new_correlation_id() -> str:
return str(uuid.uuid4())
2. `labels.py`
def new_job_id(prefix: str = "job") -> str:
return f"{prefix}-{uuid.uuid4()}"
```
* Just the user-visible string labels: e.g., `"Next"`, `"Submit"`, `"Suppliers"`.
* Some selectors may be derived from labels (e.g., Playwright `get_by_role("button", name=labels.NEXT_BUTTON)`).
#### `timing.py`
3. `demo_texts.py`
* Provide a decorator/context manager for timing:
* All “scripted” text you want to appear in demos:
```python
import time
from collections.abc import Callable
from functools import wraps
from typing import TypeVar
* Intakes (“500 tons of conveyor belts…”).
* Event names.
* Supplier names.
* Grouped by scenario:
T = TypeVar("T")
* `INTAKE.CONVEYOR_BELT_REQUEST`
* `SOURCING.THREE_BIDS_EVENT_NAME`
* `SUPPLIERS.DEFAULT_TRIO = ["Demo Supplier A", "Demo Supplier B", "Demo Supplier C"]`
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
```
Optional: load from external `strings.yaml` but always surfaced through `app.strings` to keep a single import point.
#### `retry.py`
**Rule:**
Actions must **never** use raw strings for content or selectors directly; they import from `app.strings`.
* 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
```
---
### 5.4 Browser Connector
## 3. GraphQL Integration (Direct API Operations)
`app.browser.client.BrowserClient` (or `BrowserFacade`):
### 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:
* Resolve `BrowserHost` config by id.
* Connect to CDP endpoint:
* Build and send GraphQL HTTP requests.
* Attach appropriate auth (persona-based token, service token, etc.).
* Parse responses and raise typed `GraphQL*` errors if needed.
* `http://{host}:{cdp_port}` via `playwright.chromium.connect_over_cdp`.
* Resolve the correct **Raindrop page**:
**Key design points:**
* Prefer a page whose URL contains `settings.raindrop_base_url`.
* If multiple, pick the last active or last created (simple heuristic).
* Provide a simple interface to actions:
* Endpoint URL and any static headers come from `core.config` / env, not hardcoded.
* Query strings and operation names live in `strings` (see 3.3).
* `get_page()` returns a Playwright `Page`.
* It should handle errors gracefully: no host, cannot connect, no pages.
Actions use **only** this abstraction; they never touch raw CDP URLs.
---
### 5.5 Action Framework
**Base interface** (`app.actions.base`):
Example shape:
```python
class DemoAction(Protocol):
id: str
from typing import Any
import httpx
def run(self, page: Page, context: ActionContext) -> ActionResult:
...
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
```
* `ActionContext` includes:
You can add retries here using `utils.retry.retry` if desired.
* `browser_host_id: str`
* Optional parameters (e.g., override event name).
* Correlation id for logging.
#### `operations/intake.py` & `operations/sourcing.py`
**Action Registry:**
* Provide **higher-level functions** like:
* Maintains a mapping of `action_id → DemoAction` instance.
* Prevents duplication: all action IDs are declared in **one** place.
* Provides methods:
```python
async def get_intake_request(client: GraphQLClient, persona: DemoPersona, request_id: str) -> IntakeRequest: ...
async def create_sourcing_event(...): ...
```
* `get(action_id: str) -> DemoAction`
* `list() -> List[ActionMetadata]`
* They:
**Example Actions:**
* Import the raw query strings from `strings`.
* Call `GraphQLClient.execute`.
* Map the result into typed domain models (or simple dicts for now).
* `FillIntakeBasicAction`
* Uses `selectors.INTAKE.DESCRIPTION_FIELD`.
* Uses `demo_texts.INTAKE.CONVEYOR_BELT_REQUEST`.
* Calls `page.fill()` + `page.click()`.
* `AddThreeSuppliersAction`
* Uses `demo_texts.SUPPLIERS.DEFAULT_TRIO`.
Each action file should be short (one or a few related actions) and import everything from `strings` + `browser`.
* Actions then call these operations instead of crafting GraphQL queries.
---
### 5.6 FastAPI Layer & API Design
### 3.2 Config additions for GraphQL
#### Endpoints
In `src/guide/app/core/config.py`:
1. `GET /healthz`
* Extend `AppSettings` with:
* Returns `{ "status": "ok" }`.
```python
class AppSettings(BaseSettings):
# existing fields...
raindrop_graphql_url: str # e.g. https://tenant.raindropapp.com/graphql
```
2. `GET /actions`
* Returns a list of available actions:
* `id`, `description`, `category` (e.g., `intake`, `sourcing`).
3. `POST /actions/{action_id}/execute`
* **Request body:**
* `browser_host_id` (optional → use default).
* `params` (optional dict, action-specific).
* **Behavior:**
* Look up host; create `ActionContext`.
* Use `BrowserClient` to connect to host and get Raindrop page.
* Run the action; return result status + optional metadata.
* **Response body:**
* `status` (`ok` / `error`).
* `action_id`.
* `browser_host_id`.
* `details` (optional).
4. `GET /config/browser-hosts` (optional)
* Returns configured browser hosts and default host.
**Telemetry & Error Handling:**
* Every request logs:
* `action_id`, `browser_host_id`, `correlation_id`, `duration_ms`, `result`.
* In case of error, return `4xx/5xx` with structured error JSON:
* e.g., `{"error": "BROWSER_CONNECT_FAILED", "message": "Cannot connect to 192.168.50.100:9222"}`.
* This value should be set via env or a config file; **never** hardcoded.
---
## 6. Networking & Deployment
### 3.3 `strings` for GraphQL queries
* **Backend Location:** FastAPI app running on your homelab server (`192.168.50.151`).
Add a new folder:
* **Bind Address:** `0.0.0.0` (so local network & VPN clients can hit it).
```text
src/guide/app/strings/graphql/
__init__.py
intake.py
sourcing.py
```
* **Port:** configurable via env (e.g., default 8000).
Each file contains string constants for queries/mutations and (optionally) operation names, for example:
* **Demo Machines:**
```python
# intake.py
GET_INTAKE_REQUEST = """
query GetIntakeRequest($id: ID!) {
intakeRequest(id: $id) {
id
title
status
# ...
}
}
"""
* Desktop/laptop each runs Chrome with:
CREATE_INTAKE_REQUEST = """
mutation CreateIntakeRequest($input: IntakeRequestInput!) {
createIntakeRequest(input: $input) {
id
# ...
}
}
"""
```
* `--remote-debugging-port=9222`
* Known IP or DNS reachable from `192.168.50.151`.
* `hosts.yaml` defines each as a `BrowserHost`.
**Rules:**
* **Steam Deck:**
* Configured to call e.g.:
* `http://192.168.50.151:8000/actions/fill-intake-basic/execute?browser_host_id=desktop`
* or with JSON body specifying host.
* No inline GraphQL strings in actions or service code.
* All GraphQL query strings and operation names live in `strings/graphql/*`.
---
## 7. Extensibility & Code-Assistant Friendliness
## 4. Error Handling Integration Across Layers
### Extensibility
### 4.1 Domain models & ActionResponse
* To add a new action:
In `src/guide/app/domain/models.py`:
1. Add any new strings/selectors to `app.strings`.
2. Implement a small `DemoAction` in the right `actions/*` module.
3. Register it in the `ActionRegistry`.
4. Optionally expose documentation via `/actions`.
* Extend `ActionResponse` with error fields:
* To support a new demo machine:
```python
class ActionStatus(str, Enum):
SUCCESS = "success"
ERROR = "error"
* Add `BrowserHostConfig` entry in `hosts.yaml`.
* Restart service.
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
```
### Code-Assistant Guardrails
* 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`.
* All core primitive operations (connect to browser, select Raindrop page, use selectors, pick texts) are **centralized**:
### 4.2 FastAPI route error handling
* `browser.client` for CDP.
* `strings` for text and selectors.
* `actions.base` & `ActionRegistry` for action definitions.
* PRD requirement: modules must be small and self-contained; code assistants should:
In `src/guide/app/api/routes/actions.py`:
* Prefer using existing `BrowserClient` instead of re-implementing CDP logic.
* Prefer using `strings` instead of writing raw strings.
* Use `ActionRegistry` for action lookup.
* 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.
---
## 8. Security & Safety
## 5. How Actions Decide Between Browser vs GraphQL
* Runs on trusted internal network/VPN.
* Optional enhancements:
Add these architectural guidelines (comments/docs for the assistant):
* Simple API key header for Stream Deck / other clients.
* IP allowlist (only allow local subnet/VPN).
* No credentials in code:
1. **Browser-first for interactive flows**
* Any secrets (e.g., if you *ever* login from the service) stored in env, not in repo.
* Actions should **never** perform destructive or irreversible operations in Raindrop unless explicitly designed and named that way (e.g., `submit-event`, `delete-draft`).
* 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 dont 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.
---
## 9. Acceptance Criteria
## 6. Concrete TODOs for the Code Assistant
1. **End-to-end happy path:**
You can give your agent this checklist:
* From desktop, with Chrome CDP running, you trigger `fill-intake-basic` via Stream Deck and the intake form is filled and advanced.
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:**
2. **Multi-host support:**
* 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:**
* You can successfully run the same action against `desktop` and `laptop` by only changing `browser_host_id`.
3. **Strings centralization:**
* A grep for known demo text (`"conveyor belts"`, `"Demo Supplier A"`) returns only `app/strings/*` files.
* No selectors appear directly in `actions/*`.
4. **Small module sizes:**
* No file exceeds an agreed limit (e.g. 300 LoC), except possibly `domain/models.py` if thats acceptable.
5. **Extensibility check:**
* You can add a new action (e.g., `open-intake-dashboard`) following documented steps without touching existing actions, and it appears in `/actions`.
* 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 **PEP604 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.