diff --git a/config/hosts.yaml b/config/hosts.yaml new file mode 100644 index 0000000..73b3965 --- /dev/null +++ b/config/hosts.yaml @@ -0,0 +1,7 @@ +hosts: + - id: desktop + host: 192.168.50.185 + cdp_port: 9223 + - id: laptop + host: 192.168.50.152 + cdp_port: 9222 diff --git a/src/guide/__init__.py b/src/guide/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guide/app/__init__.py b/src/guide/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guide/app/actions/__init__.py b/src/guide/app/actions/__init__.py new file mode 100644 index 0000000..812753c --- /dev/null +++ b/src/guide/app/actions/__init__.py @@ -0,0 +1,20 @@ +from guide.app.actions.base import ActionRegistry, DemoAction +from guide.app.actions.intake import FillIntakeBasicAction +from guide.app.actions.sourcing import AddThreeSuppliersAction + + +def default_registry() -> ActionRegistry: + actions: list[DemoAction] = [ + FillIntakeBasicAction(), + AddThreeSuppliersAction(), + ] + return ActionRegistry(actions) + + +__all__ = [ + "ActionRegistry", + "DemoAction", + "default_registry", + "FillIntakeBasicAction", + "AddThreeSuppliersAction", +] diff --git a/src/guide/app/actions/base.py b/src/guide/app/actions/base.py new file mode 100644 index 0000000..04693d0 --- /dev/null +++ b/src/guide/app/actions/base.py @@ -0,0 +1,31 @@ +from typing import Iterable, Protocol + +from playwright.async_api import Page + +from guide.app.domain.models import ActionContext, ActionMetadata, ActionResult + + +class DemoAction(Protocol): + id: str + description: str + category: str + + async def run(self, page: Page, context: ActionContext) -> ActionResult: ... + + +class ActionRegistry: + """Simple mapping of action id -> action instance.""" + + def __init__(self, actions: Iterable[DemoAction]): + self._actions: dict[str, DemoAction] = {action.id: action for action in actions} + + def get(self, action_id: str) -> DemoAction: + if action_id not in self._actions: + raise KeyError(f"Unknown action '{action_id}'") + return self._actions[action_id] + + def list_metadata(self) -> list[ActionMetadata]: + return [ + ActionMetadata(id=action.id, description=action.description, category=action.category) + for action in self._actions.values() + ] diff --git a/src/guide/app/actions/intake/__init__.py b/src/guide/app/actions/intake/__init__.py new file mode 100644 index 0000000..77e570f --- /dev/null +++ b/src/guide/app/actions/intake/__init__.py @@ -0,0 +1,3 @@ +from guide.app.actions.intake.basic import FillIntakeBasicAction + +__all__ = ["FillIntakeBasicAction"] diff --git a/src/guide/app/actions/intake/basic.py b/src/guide/app/actions/intake/basic.py new file mode 100644 index 0000000..c733ce3 --- /dev/null +++ b/src/guide/app/actions/intake/basic.py @@ -0,0 +1,16 @@ +from playwright.async_api import Page + +from guide.app.actions.base import DemoAction +from guide.app.domain.models import ActionContext, ActionResult +from guide.app.strings import demo_texts, selectors + + +class FillIntakeBasicAction(DemoAction): + id = "fill-intake-basic" + description = "Fill the intake description and advance to the next step." + category = "intake" + + async def run(self, page: Page, context: ActionContext) -> ActionResult: + await page.fill(selectors.INTAKE.DESCRIPTION_FIELD, demo_texts.INTAKE.CONVEYOR_BELT_REQUEST) + await page.click(selectors.INTAKE.NEXT_BUTTON) + return ActionResult(details={"message": "Intake filled"}) diff --git a/src/guide/app/actions/sourcing/__init__.py b/src/guide/app/actions/sourcing/__init__.py new file mode 100644 index 0000000..cc91fbb --- /dev/null +++ b/src/guide/app/actions/sourcing/__init__.py @@ -0,0 +1,3 @@ +from guide.app.actions.sourcing.add_suppliers import AddThreeSuppliersAction + +__all__ = ["AddThreeSuppliersAction"] diff --git a/src/guide/app/actions/sourcing/add_suppliers.py b/src/guide/app/actions/sourcing/add_suppliers.py new file mode 100644 index 0000000..15f7676 --- /dev/null +++ b/src/guide/app/actions/sourcing/add_suppliers.py @@ -0,0 +1,17 @@ +from playwright.async_api import Page + +from guide.app.actions.base import DemoAction +from guide.app.domain.models import ActionContext, ActionResult +from guide.app.strings import demo_texts, selectors + + +class AddThreeSuppliersAction(DemoAction): + id = "add-three-suppliers" + description = "Adds three default suppliers to the sourcing event." + category = "sourcing" + + async def run(self, page: Page, context: ActionContext) -> ActionResult: + for supplier in demo_texts.SUPPLIERS.DEFAULT_TRIO: + await page.fill(selectors.SOURCING.SUPPLIER_SEARCH_INPUT, supplier) + await page.click(selectors.SOURCING.ADD_SUPPLIER_BUTTON) + return ActionResult(details={"added_suppliers": demo_texts.SUPPLIERS.DEFAULT_TRIO}) diff --git a/src/guide/app/api/__init__.py b/src/guide/app/api/__init__.py new file mode 100644 index 0000000..1bf28de --- /dev/null +++ b/src/guide/app/api/__init__.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from guide.app.api.routes import actions, config, health + +router = APIRouter() +router.include_router(health.router) +router.include_router(actions.router) +router.include_router(config.router) + +__all__ = ["router"] diff --git a/src/guide/app/api/routes/__init__.py b/src/guide/app/api/routes/__init__.py new file mode 100644 index 0000000..04ca95d --- /dev/null +++ b/src/guide/app/api/routes/__init__.py @@ -0,0 +1,3 @@ +from guide.app.api.routes import actions, config, health + +__all__ = ["actions", "config", "health"] diff --git a/src/guide/app/api/routes/actions.py b/src/guide/app/api/routes/actions.py new file mode 100644 index 0000000..d2a4e7b --- /dev/null +++ b/src/guide/app/api/routes/actions.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from guide.app.actions import ActionRegistry +from guide.app.browser.client import BrowserClient +from guide.app.domain.models import ActionContext, ActionRequest, ActionResponse + +router = APIRouter() + + +def _registry(request: Request) -> ActionRegistry: + return request.app.state.action_registry + + +def _browser_client(request: Request) -> BrowserClient: + return request.app.state.browser_client + + +@router.get("/actions") +async def list_actions(registry: ActionRegistry = Depends(_registry)): + return registry.list_metadata() + + +@router.post("/actions/{action_id}/execute", response_model=ActionResponse) +async def execute_action( + action_id: str, + payload: ActionRequest, + registry: ActionRegistry = Depends(_registry), + browser_client: BrowserClient = Depends(_browser_client), +): + try: + action = registry.get(action_id) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Unknown action '{action_id}'", + ) from exc + + context = ActionContext( + browser_host_id=payload.browser_host_id or browser_client.settings.default_browser_host_id, + params=payload.params or {}, + ) + + try: + async with browser_client.open_page(context.browser_host_id) as page: + result = await action.run(page, context) + except Exception as exc: + return ActionResponse( + status="error", + action_id=action_id, + browser_host_id=context.browser_host_id, + correlation_id=context.correlation_id, + error=str(exc), + ) + + return ActionResponse( + status=result.status, + action_id=action_id, + browser_host_id=context.browser_host_id, + correlation_id=context.correlation_id, + details=result.details, + error=result.error, + ) diff --git a/src/guide/app/api/routes/config.py b/src/guide/app/api/routes/config.py new file mode 100644 index 0000000..83c5225 --- /dev/null +++ b/src/guide/app/api/routes/config.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, Request + +from guide.app.domain.models import BrowserHostsResponse + +router = APIRouter() + + +def _settings(request: Request): + return request.app.state.settings + + +@router.get("/config/browser-hosts", response_model=BrowserHostsResponse) +async def list_browser_hosts(settings=Depends(_settings)) -> BrowserHostsResponse: # type: ignore[override] + return BrowserHostsResponse( + default_browser_host_id=settings.default_browser_host_id, + browser_hosts=settings.browser_hosts, + ) diff --git a/src/guide/app/api/routes/health.py b/src/guide/app/api/routes/health.py new file mode 100644 index 0000000..f5058c9 --- /dev/null +++ b/src/guide/app/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/healthz") +async def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/src/guide/app/browser/__init__.py b/src/guide/app/browser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guide/app/browser/client.py b/src/guide/app/browser/client.py new file mode 100644 index 0000000..316b58f --- /dev/null +++ b/src/guide/app/browser/client.py @@ -0,0 +1,52 @@ +import contextlib +from typing import AsyncIterator + +from playwright.async_api import Browser, Page, async_playwright + +from guide.app.core.config import AppSettings, BrowserHost + + +class BrowserClient: + """CDP-aware connector that returns the active Raindrop page.""" + + def __init__(self, settings: AppSettings) -> None: + self.settings = settings + + def _resolve_host(self, host_id: str | None) -> BrowserHost: + resolved_id = host_id or self.settings.default_browser_host_id + host = self.settings.browser_hosts.get(resolved_id) + if not host: + known = ", ".join(self.settings.browser_hosts.keys()) or "" + raise ValueError(f"Unknown browser host '{resolved_id}'. Known: {known}") + return host + + @contextlib.asynccontextmanager + async def open_page(self, host_id: str | None = None) -> AsyncIterator[Page]: + host = self._resolve_host(host_id) + cdp_url = f"http://{host.host}:{host.cdp_port}" + playwright = await async_playwright().start() + browser: Browser | None = None + try: + browser = await playwright.chromium.connect_over_cdp(cdp_url) + page = self._pick_raindrop_page(browser) + if not page: + raise RuntimeError("No Raindrop page found in connected browser.") + yield page + finally: + # Avoid killing the remote browser; just drop the connection. + with contextlib.suppress(Exception): + if browser: + browser.disconnect() + with contextlib.suppress(Exception): + await playwright.stop() + + def _pick_raindrop_page(self, browser: Browser) -> Page | None: + target_substr = self.settings.raindrop_base_url + pages = [] + for context in browser.contexts: + pages.extend(context.pages) + pages = pages or [page for page in browser.contexts[0].pages] if browser.contexts else [] + for page in reversed(pages): + if target_substr in (page.url or ""): + return page + return pages[-1] if pages else None diff --git a/src/guide/app/core/__init__.py b/src/guide/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guide/app/core/config.py b/src/guide/app/core/config.py new file mode 100644 index 0000000..2a2e4a9 --- /dev/null +++ b/src/guide/app/core/config.py @@ -0,0 +1,69 @@ +import json +import os +from pathlib import Path + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +CONFIG_DIR = Path(__file__).resolve().parents[4] / "config" +HOSTS_FILE = CONFIG_DIR / "hosts.yaml" + + +class BrowserHost(BaseModel): + id: str + host: str + cdp_port: int + + +class AppSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="RAINDROP_DEMO_") + + raindrop_base_url: str = "https://app.raindrop.com" + default_browser_host_id: str = "desktop" + browser_hosts: dict[str, BrowserHost] = Field(default_factory=dict) + + +def _load_hosts_file(path: Path) -> dict[str, BrowserHost]: + if not path.exists(): + return {} + + try: + import yaml # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + "hosts.yaml found but PyYAML is not installed. " + "Add 'pyyaml' to dependencies or remove hosts.yaml." + ) from exc + + data = yaml.safe_load(path.read_text()) or {} + hosts_raw = data.get("hosts") or data + hosts: dict[str, BrowserHost] = {} + for item in hosts_raw: + host = BrowserHost.model_validate(item) + hosts[host.id] = host + return hosts + + +def _parse_hosts_json(value: str) -> dict[str, BrowserHost]: + decoded = json.loads(value) + hosts: dict[str, BrowserHost] = {} + for item in decoded: + host = BrowserHost.model_validate(item) + hosts[host.id] = host + return hosts + + +def load_settings() -> AppSettings: + settings = AppSettings() + merged_hosts: dict[str, BrowserHost] = {} + + from_file = _load_hosts_file(HOSTS_FILE) + merged_hosts.update(from_file) + + if env_json := os.environ.get("RAINDROP_DEMO_BROWSER_HOSTS_JSON"): + merged_hosts.update(_parse_hosts_json(env_json)) + + if merged_hosts: + settings = settings.model_copy(update={"browser_hosts": merged_hosts}) + + return settings diff --git a/src/guide/app/core/logging.py b/src/guide/app/core/logging.py new file mode 100644 index 0000000..96e61b7 --- /dev/null +++ b/src/guide/app/core/logging.py @@ -0,0 +1,11 @@ +import logging +from typing import Optional + + +def configure_logging(level: int | str = logging.INFO, correlation_id: Optional[str] = None) -> None: + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + ) + if correlation_id: + logging.LoggerAdapter(logging.getLogger(), {"correlation_id": correlation_id}) diff --git a/src/guide/app/domain/__init__.py b/src/guide/app/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/guide/app/domain/models.py b/src/guide/app/domain/models.py new file mode 100644 index 0000000..9e35ce9 --- /dev/null +++ b/src/guide/app/domain/models.py @@ -0,0 +1,43 @@ +import uuid +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + +from guide.app.core.config import BrowserHost + + +class ActionRequest(BaseModel): + browser_host_id: Optional[str] = None + params: dict[str, Any] = Field(default_factory=dict) + + +class ActionContext(BaseModel): + browser_host_id: str + params: dict[str, Any] = Field(default_factory=dict) + correlation_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + + +class ActionResult(BaseModel): + status: Literal["ok", "error"] = "ok" + details: dict[str, Any] = Field(default_factory=dict) + error: Optional[str] = None + + +class ActionMetadata(BaseModel): + id: str + description: str + category: str + + +class ActionResponse(BaseModel): + status: Literal["ok", "error"] + action_id: str + browser_host_id: str + correlation_id: Optional[str] = None + details: dict[str, Any] | None = None + error: Optional[str] = None + + +class BrowserHostsResponse(BaseModel): + default_browser_host_id: str + browser_hosts: dict[str, BrowserHost] diff --git a/src/guide/app/main.py b/src/guide/app/main.py new file mode 100644 index 0000000..73ccf89 --- /dev/null +++ b/src/guide/app/main.py @@ -0,0 +1,26 @@ +from fastapi import FastAPI + +from guide.app.actions import default_registry +from guide.app.browser.client import BrowserClient +from guide.app.core.config import load_settings +from guide.app.core.logging import configure_logging +from guide.app.api import router as api_router + + +def create_app() -> FastAPI: + configure_logging() + + settings = load_settings() + registry = default_registry() + browser_client = BrowserClient(settings) + + app = FastAPI(title="Raindrop Demo Automation", version="0.1.0") + app.state.settings = settings + app.state.action_registry = registry + app.state.browser_client = browser_client + + app.include_router(api_router) + return app + + +app = create_app() diff --git a/src/guide/app/strings/__init__.py b/src/guide/app/strings/__init__.py new file mode 100644 index 0000000..d02e939 --- /dev/null +++ b/src/guide/app/strings/__init__.py @@ -0,0 +1,12 @@ +from guide.app.strings.demo_texts import DemoTexts, texts +from guide.app.strings.labels import Labels, labels +from guide.app.strings.selectors import Selectors, selectors + +__all__ = [ + "DemoTexts", + "Labels", + "Selectors", + "texts", + "labels", + "selectors", +] diff --git a/src/guide/app/strings/demo_texts/__init__.py b/src/guide/app/strings/demo_texts/__init__.py new file mode 100644 index 0000000..28e20a1 --- /dev/null +++ b/src/guide/app/strings/demo_texts/__init__.py @@ -0,0 +1,14 @@ +from guide.app.strings.demo_texts.events import EventTexts +from guide.app.strings.demo_texts.intake import IntakeTexts +from guide.app.strings.demo_texts.suppliers import SupplierTexts + + +class DemoTexts: + INTAKE = IntakeTexts + SUPPLIERS = SupplierTexts + EVENTS = EventTexts + + +texts = DemoTexts() + +__all__ = ["DemoTexts", "texts", "IntakeTexts", "SupplierTexts", "EventTexts"] diff --git a/src/guide/app/strings/demo_texts/events.py b/src/guide/app/strings/demo_texts/events.py new file mode 100644 index 0000000..eb3619c --- /dev/null +++ b/src/guide/app/strings/demo_texts/events.py @@ -0,0 +1,2 @@ +class EventTexts: + THREE_BIDS_EVENT_NAME = "Three Bids and a Buy – Demo" diff --git a/src/guide/app/strings/demo_texts/intake.py b/src/guide/app/strings/demo_texts/intake.py new file mode 100644 index 0000000..3250173 --- /dev/null +++ b/src/guide/app/strings/demo_texts/intake.py @@ -0,0 +1,5 @@ +class IntakeTexts: + CONVEYOR_BELT_REQUEST = ( + "Requesting 500 tons of replacement conveyor belts for Q4 maintenance window." + ) + ALT_REQUEST = "Intake for rapid supplier onboarding and risk review." diff --git a/src/guide/app/strings/demo_texts/suppliers.py b/src/guide/app/strings/demo_texts/suppliers.py new file mode 100644 index 0000000..5c3ad08 --- /dev/null +++ b/src/guide/app/strings/demo_texts/suppliers.py @@ -0,0 +1,7 @@ +class SupplierTexts: + DEFAULT_TRIO = [ + "Demo Supplier A", + "Demo Supplier B", + "Demo Supplier C", + ] + NOTES = "Sourced automatically via demo action." diff --git a/src/guide/app/strings/labels/__init__.py b/src/guide/app/strings/labels/__init__.py new file mode 100644 index 0000000..ac72885 --- /dev/null +++ b/src/guide/app/strings/labels/__init__.py @@ -0,0 +1,13 @@ +from guide.app.strings.labels.intake import IntakeLabels +from guide.app.strings.labels.sourcing import SourcingLabels + + +class Labels: + INTAKE = IntakeLabels + SOURCING = SourcingLabels + NEXT_BUTTON = "Next" + + +labels = Labels() + +__all__ = ["Labels", "labels", "IntakeLabels", "SourcingLabels"] diff --git a/src/guide/app/strings/labels/intake.py b/src/guide/app/strings/labels/intake.py new file mode 100644 index 0000000..40e20cc --- /dev/null +++ b/src/guide/app/strings/labels/intake.py @@ -0,0 +1,6 @@ +"""User-visible labels for intake.""" + + +class IntakeLabels: + DESCRIPTION_PLACEHOLDER = "Describe what you need" + NEXT_BUTTON = "Next" diff --git a/src/guide/app/strings/labels/sourcing.py b/src/guide/app/strings/labels/sourcing.py new file mode 100644 index 0000000..057d719 --- /dev/null +++ b/src/guide/app/strings/labels/sourcing.py @@ -0,0 +1,6 @@ +"""Labels for sourcing screens.""" + + +class SourcingLabels: + SUPPLIERS_TAB = "Suppliers" + ADD_BUTTON = "Add" diff --git a/src/guide/app/strings/selectors/__init__.py b/src/guide/app/strings/selectors/__init__.py new file mode 100644 index 0000000..91c42e0 --- /dev/null +++ b/src/guide/app/strings/selectors/__init__.py @@ -0,0 +1,14 @@ +from guide.app.strings.selectors.intake import IntakeSelectors +from guide.app.strings.selectors.navigation import NavigationSelectors +from guide.app.strings.selectors.sourcing import SourcingSelectors + + +class Selectors: + INTAKE = IntakeSelectors + SOURCING = SourcingSelectors + NAVIGATION = NavigationSelectors + + +selectors = Selectors() + +__all__ = ["Selectors", "selectors", "IntakeSelectors", "SourcingSelectors", "NavigationSelectors"] diff --git a/src/guide/app/strings/selectors/intake.py b/src/guide/app/strings/selectors/intake.py new file mode 100644 index 0000000..82c4c9a --- /dev/null +++ b/src/guide/app/strings/selectors/intake.py @@ -0,0 +1,6 @@ +"""Selectors for the intake flow.""" + + +class IntakeSelectors: + DESCRIPTION_FIELD = '[data-test="intake-description"]' + NEXT_BUTTON = '[data-test="intake-next"]' diff --git a/src/guide/app/strings/selectors/navigation.py b/src/guide/app/strings/selectors/navigation.py new file mode 100644 index 0000000..433da87 --- /dev/null +++ b/src/guide/app/strings/selectors/navigation.py @@ -0,0 +1,6 @@ +"""Selectors used for simple navigation helpers.""" + + +class NavigationSelectors: + GLOBAL_SEARCH = '[data-test="global-search"]' + FIRST_RESULT = '[data-test="search-result"]:first-child' diff --git a/src/guide/app/strings/selectors/sourcing.py b/src/guide/app/strings/selectors/sourcing.py new file mode 100644 index 0000000..5d8fe03 --- /dev/null +++ b/src/guide/app/strings/selectors/sourcing.py @@ -0,0 +1,7 @@ +"""Selectors for sourcing event setup.""" + + +class SourcingSelectors: + SUPPLIER_SEARCH_INPUT = '[data-test="supplier-search"]' + ADD_SUPPLIER_BUTTON = '[data-test="add-supplier"]' + SUPPLIER_ROW = '[data-test="supplier-row"]'