diff --git a/docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md b/docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md new file mode 100644 index 0000000..945ffba --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md @@ -0,0 +1,1937 @@ +# Cloud Scout Creation Flow (Gmail) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Gmail cloud scouts a slim creation flow (name + focus + auto-trash + label/sender filter, OAuth during creation) fitting the two-stage HITL pipeline, with full edit parity in the config panel — and build the foundational cloud-scout CRUD backend routes that were never implemented. + +**Architecture:** Backend gains the missing `CloudScoutConfig` CRUD routes (`POST/GET/PUT/DELETE /api/v1/scouts/cloud`) plus a `gmail_address` column, Gmail label-listing and disconnect endpoints. The Electron stepper is split into a thin router that delegates to `LocalScoutCreationFlow` (unchanged) or a new `CloudScoutCreationFlow`. The cloud config panel is rewritten for edit parity. + +**Tech Stack:** Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic / Pydantic v2 / Google API client. Electron / TypeScript / React 19 / tRPC v11 / zod / i18next. + +**Spec:** [docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-design.md](2026-05-16-cloud-scout-creation-flow-design.md) + +**CRITICAL CONTEXT — discovered during planning:** The backend cloud-scout CRUD routes (`/api/v1/scouts/cloud`) **do not exist**. The Electron tRPC `scout.cloud.{list,create,update,delete}` procedures call them but they 404. Cloud scouts are currently non-functional end-to-end. Phase A below builds these foundational routes; the slim-flow work (Phases C–D) depends on them. This is a larger scope than the spec literally implied ("verify/extend") because there is nothing to extend yet. + +**Repository layout:** monorepo with submodules `api/` (FastAPI) and `adiuvAI/` (Electron), both on branch `develop`. Monorepo on `main`. Don't bump submodule pointers until the final task of each submodule's work. + +**Working dir:** `c:\Users\PC-Roby\Documents\_adiuvai_workspace`. Windows; use the Bash tool for git/pytest. Pre-existing flaky tests to ignore throughout: `test_eval_runner`, `test_eval_journey`, `test_home_request_calls_memory_middleware` (hangs), `test_stream_end_serializes`. + +--- + +## File Structure + +### Backend (`api/`) + +| Path | Responsibility | New/Modified | +|------|----------------|--------------| +| `api/alembic/versions/009_cloud_scout_gmail_address.py` | Add `gmail_address` column | New | +| `api/app/models.py` | `CloudScoutConfig.gmail_address` field | Modified | +| `api/app/schemas/__init__.py` | Cloud create/update/response Pydantic models | Modified | +| `api/app/api/routes/scouts.py` | Cloud CRUD routes + serializer + label-list + disconnect + gmail_address persistence | Modified | +| `api/app/scouts/connectors/gmail.py` | `list_labels` + `stop_watch` | Modified | +| `api/tests/test_scout_cloud_crud.py` | Cloud CRUD route tests | New | +| `api/tests/test_scout_connectors_gmail.py` | `list_labels` + `stop_watch` tests | Modified | + +### Electron (`adiuvAI/`) + +| Path | Responsibility | New/Modified | +|------|----------------|--------------| +| `adiuvAI/src/shared/api-types.ts` | `CloudScoutConfigSchema` new fields | Modified | +| `adiuvAI/src/main/router/index.ts` | tRPC cloud input changes + `gmailLabels` + `disconnectGmail` | Modified | +| `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx` | Thin router: shared template step → delegate | Modified | +| `adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx` | Extracted current local flow | New | +| `adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx` | New Gmail slim flow | New | +| `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` | Rewrite for parity | Modified | +| `adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx` | Disable Teams/Outlook | Modified | +| `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` | New scout keys | Modified | + +--- + +# PHASE A — Backend Cloud CRUD Routes (foundational) + +## Task 1: Cloud scout Pydantic schemas + +**Files:** +- Modify: `api/app/schemas/__init__.py` + +**Context:** No request/response schemas exist for cloud-scout CRUD. Add them near the other scout schemas (`ScoutCatalogItem`, `ScoutCreationCheckResponse`, etc). The response mirrors the `CloudScoutConfig` ORM columns, camelCased by the existing serialization layer (the route returns a plain dict; the Electron `proxyGet` camelCases keys, so we return snake_case keys and let the client convert — verify by checking how `ScoutRunLogResponse` is returned). + +- [ ] **Step 1: Read the existing scout schemas to match style** + +Read `api/app/schemas/__init__.py` around the `ScoutCatalogItem` / `ScoutCreationCheckResponse` / `ScoutRunLogResponse` definitions. Note whether response models use `alias_generator`/camelCase or snake_case + client conversion. (The Electron `proxyGet`/`proxyPost` camelCases responses and snake_cases requests, so backend speaks snake_case.) + +- [ ] **Step 2: Add the cloud schemas** + +Add to `api/app/schemas/__init__.py`: + +```python +class CloudScoutCreateRequest(BaseModel): + name: str + provider: Literal["gmail", "teams", "outlook"] + data_types: list[str] = Field(default_factory=list) + prompt_template: str = "" + schedule_cron: str | None = None # None → server default + filter_config: dict | None = None + auto_trash_spam: bool = False + + +class CloudScoutUpdateRequest(BaseModel): + name: str | None = None + data_types: list[str] | None = None + prompt_template: str | None = None + schedule_cron: str | None = None + filter_config: dict | None = None + auto_trash_spam: bool | None = None + enabled: bool | None = None + + +class CloudScoutResponse(BaseModel): + id: str + user_id: str + provider: str + name: str + data_types: list[str] + prompt_template: str + schedule_cron: str + filter_config: dict | None + auto_trash_spam: bool + enabled: bool + last_run_at: int | None + gmail_address: str | None + oauth_connected: bool + created_at: int + updated_at: int +``` + +If `Literal` / `Field` aren't imported in this file, add them: `from typing import Literal` and `from pydantic import BaseModel, Field`. + +- [ ] **Step 3: Verify import** + +```bash +cd api +python -c "from app.schemas import CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse; print('ok')" +``` +Expected: `ok`. + +- [ ] **Step 4: Commit** + +```bash +cd api +git add app/schemas/__init__.py +git commit -m "feat(scouts): add cloud scout CRUD pydantic schemas" +``` + +--- + +## Task 2: Cloud scout serializer + CRUD routes + +**Files:** +- Modify: `api/app/api/routes/scouts.py` +- Test: `api/tests/test_scout_cloud_crud.py` + +**Context:** `scouts.py` already has `_dt_ms` (line 60), `_dt_ms_opt` (line 64), `_to_data_types` (line 68), `_enforce_agent_limit` (line 99), and uses `Depends(get_session)` + `Depends(get_current_user)` returning `UserProfile`. Mirror the existing route style. The serializer computes `oauth_connected` from `oauth_token_encrypted is not None`. + +- [ ] **Step 1: Write the failing tests** + +Create `api/tests/test_scout_cloud_crud.py`: + +```python +"""Tests for cloud scout CRUD routes.""" + +from __future__ import annotations + +import uuid +from unittest.mock import patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app +from app.models import CloudScoutConfig +from tests.conftest import _TestSessionLocal, make_jwt + + +def _auth_headers(tier: str = "power") -> dict: + return {"Authorization": f"Bearer {make_jwt(tier)}"} + + +@pytest.fixture(autouse=True) +def _patch_session(): + # Route handlers use app.db.get_session; point it at the test session. + with patch("app.api.routes.scouts.get_session", new=_test_get_session): + yield + + +async def _test_get_session(): + async with _TestSessionLocal() as session: + yield session + + +@pytest.mark.asyncio +async def test_create_cloud_scout_defaults_schedule(): + payload = { + "name": "Inbox", + "provider": "gmail", + "data_types": [], + "prompt_template": "client requests", + "auto_trash_spam": True, + # schedule_cron omitted → server default + } + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + resp = await client.post("/api/v1/scouts/cloud", json=payload, headers=_auth_headers()) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["name"] == "Inbox" + assert body["provider"] == "gmail" + assert body["auto_trash_spam"] is True + assert body["prompt_template"] == "client requests" + assert body["schedule_cron"] # non-empty default applied + assert body["oauth_connected"] is False + assert body["gmail_address"] is None + + +@pytest.mark.asyncio +async def test_list_cloud_scouts_returns_only_own(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + await client.post( + "/api/v1/scouts/cloud", + json={"name": "A", "provider": "gmail"}, + headers=_auth_headers(), + ) + resp = await client.get("/api/v1/scouts/cloud", headers=_auth_headers()) + assert resp.status_code == 200 + rows = resp.json() + assert all(r["provider"] == "gmail" for r in rows) + assert any(r["name"] == "A" for r in rows) + + +@pytest.mark.asyncio +async def test_update_cloud_scout_applies_filter_and_autotrash(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + created = (await client.post( + "/api/v1/scouts/cloud", + json={"name": "B", "provider": "gmail"}, + headers=_auth_headers(), + )).json() + sid = created["id"] + resp = await client.put( + f"/api/v1/scouts/cloud/{sid}", + json={"filter_config": {"labels": ["INBOX"], "senders": ["@client.co"]}, "auto_trash_spam": True, "prompt_template": "invoices"}, + headers=_auth_headers(), + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["filter_config"] == {"labels": ["INBOX"], "senders": ["@client.co"]} + assert body["auto_trash_spam"] is True + assert body["prompt_template"] == "invoices" + + +@pytest.mark.asyncio +async def test_delete_cloud_scout(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + created = (await client.post( + "/api/v1/scouts/cloud", + json={"name": "C", "provider": "gmail"}, + headers=_auth_headers(), + )).json() + sid = created["id"] + resp = await client.delete(f"/api/v1/scouts/cloud/{sid}", headers=_auth_headers()) + assert resp.status_code == 200 + listing = (await client.get("/api/v1/scouts/cloud", headers=_auth_headers())).json() + assert all(r["id"] != sid for r in listing) +``` + +Note: the `_patch_session` fixture assumes the route reads `get_session` as a module-level name in `scouts.py`. If `get_session` is imported differently, adjust the patch target. Confirm via `grep -n "get_session" api/app/api/routes/scouts.py`. + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd api +pytest tests/test_scout_cloud_crud.py -v +``` +Expected: 404s (routes don't exist) → assertion failures. + +- [ ] **Step 3: Add the serializer + routes to `scouts.py`** + +Add near the other helpers in `api/app/api/routes/scouts.py`: + +```python +def _to_cloud_response(scout: CloudScoutConfig) -> dict: + return { + "id": scout.id, + "user_id": scout.user_id, + "provider": scout.provider, + "name": scout.name, + "data_types": scout.data_types or [], + "prompt_template": scout.prompt_template or "", + "schedule_cron": scout.schedule_cron, + "filter_config": scout.filter_config, + "auto_trash_spam": scout.auto_trash_spam, + "enabled": scout.enabled, + "last_run_at": _dt_ms_opt(scout.last_run_at), + "gmail_address": scout.gmail_address, + "oauth_connected": scout.oauth_token_encrypted is not None, + "created_at": _dt_ms(scout.created_at), + "updated_at": _dt_ms(scout.updated_at), + } +``` + +Then add the routes (place after the existing scout routes, before the OAuth section): + +```python +_DEFAULT_CLOUD_SCHEDULE = "0 */6 * * *" + + +@router.get("/cloud", response_model=list[CloudScoutResponse]) +async def list_cloud_scouts( + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + rows = (await db.execute( + select(CloudScoutConfig).where(CloudScoutConfig.user_id == current_user.id) + )).scalars().all() + return [_to_cloud_response(s) for s in rows] + + +@router.post("/cloud", response_model=CloudScoutResponse, status_code=status.HTTP_201_CREATED) +async def create_cloud_scout( + body: CloudScoutCreateRequest, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = CloudScoutConfig( + id=str(uuid.uuid4()), + user_id=current_user.id, + provider=body.provider, + name=body.name, + data_types=body.data_types, + prompt_template=body.prompt_template, + filter_config=body.filter_config, + schedule_cron=body.schedule_cron or _DEFAULT_CLOUD_SCHEDULE, + auto_trash_spam=body.auto_trash_spam, + enabled=True, + ) + db.add(scout) + await db.commit() + await db.refresh(scout) + return _to_cloud_response(scout) + + +@router.put("/cloud/{scout_id}", response_model=CloudScoutResponse) +async def update_cloud_scout( + scout_id: str, + body: CloudScoutUpdateRequest, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + if body.name is not None: + scout.name = body.name + if body.data_types is not None: + scout.data_types = body.data_types + if body.prompt_template is not None: + scout.prompt_template = body.prompt_template + if body.schedule_cron is not None: + scout.schedule_cron = body.schedule_cron + if body.filter_config is not None: + scout.filter_config = body.filter_config + if body.auto_trash_spam is not None: + scout.auto_trash_spam = body.auto_trash_spam + if body.enabled is not None: + scout.enabled = body.enabled + await db.commit() + await db.refresh(scout) + return _to_cloud_response(scout) + + +@router.delete("/cloud/{scout_id}") +async def delete_cloud_scout( + scout_id: str, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + await db.delete(scout) + await db.commit() + return {"ok": True} +``` + +Ensure imports at the top of `scouts.py` include: `uuid`, `select` (from sqlalchemy), and the three new schemas `CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse`. Add to the existing `from app.schemas import (...)` block and `from sqlalchemy import select` if missing. + +- [ ] **Step 4: Run the tests** + +```bash +cd api +pytest tests/test_scout_cloud_crud.py -v +``` +Expected: 4 PASS. If the `get_session` patch target is wrong, fix it and re-run. + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py +git commit -m "feat(scouts): add cloud scout CRUD routes + serializer" +``` + +--- + +# PHASE B — gmail_address + connector additions + +## Task 3: Alembic 009 — gmail_address column + model field + +**Files:** +- Create: `api/alembic/versions/009_cloud_scout_gmail_address.py` +- Modify: `api/app/models.py` + +- [ ] **Step 1: Create the migration** + +```python +"""Add gmail_address to cloud_scout_configs. + +Revision ID: 009 +Revises: 008 +Create Date: 2026-05-16 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "009" +down_revision: Union[str, None] = "008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("cloud_scout_configs", sa.Column("gmail_address", sa.String(320), nullable=True)) + + +def downgrade() -> None: + op.drop_column("cloud_scout_configs", "gmail_address") +``` + +- [ ] **Step 2: Verify revision graph** + +```bash +cd api +python -c "from alembic.script import ScriptDirectory; from alembic.config import Config; sd = ScriptDirectory.from_config(Config('alembic.ini')); print([s.revision for s in sd.walk_revisions()][:3])" +``` +Expected: `['009', '008', '007']`. + +- [ ] **Step 3: Add the model field** + +In `api/app/models.py`, inside `CloudScoutConfig` (after `device_inactivity_pause_days`): + +```python +gmail_address: Mapped[str | None] = mapped_column(String(320), nullable=True) +``` + +- [ ] **Step 4: Run scout tests to confirm create_all still works** + +```bash +cd api +pytest tests/test_scout_cloud_crud.py tests/test_scout_engine.py -v +``` +Expected: green (the test DB uses `create_all`, picks up the new column). + +- [ ] **Step 5: Commit** + +```bash +cd api +git add alembic/versions/009_cloud_scout_gmail_address.py app/models.py +git commit -m "feat(scouts): add gmail_address column to cloud_scout_configs" +``` + +--- + +## Task 4: Persist gmail_address on OAuth callback + +**Files:** +- Modify: `api/app/api/routes/scouts.py` (the `scout_gmail_oauth_callback` handler, lines 370-440) + +**Context:** After token exchange, fetch the Gmail profile to get `emailAddress` and store it. The connector's `_get_gmail_service` can build a service, but at callback time the token is freshly minted — simplest is to call Gmail's `getProfile` via a one-off service built from `creds_dict`. + +- [ ] **Step 1: Add gmail_address persistence after token storage** + +In `scout_gmail_oauth_callback`, after `scout.oauth_token_encrypted = encrypted` and before the first `await db.commit()`, add: + +```python + # Fetch the connected Gmail address for display. + try: + import asyncio + from googleapiclient.discovery import build + from google.oauth2.credentials import Credentials + + def _fetch_email() -> str | None: + creds = Credentials( + token=creds_dict["token"], + refresh_token=creds_dict.get("refresh_token"), + token_uri=creds_dict["token_uri"], + client_id=creds_dict["client_id"], + client_secret=creds_dict["client_secret"], + scopes=creds_dict["scopes"], + ) + service = build("gmail", "v1", credentials=creds, cache_discovery=False) + profile = service.users().getProfile(userId="me").execute() + return profile.get("emailAddress") + + scout.gmail_address = await asyncio.to_thread(_fetch_email) + except Exception: + logger.exception("failed to fetch gmail address for scout %s", scout_id) +``` + +- [ ] **Step 2: Verify the handler still imports/compiles** + +```bash +cd api +python -c "import app.api.routes.scouts; print('ok')" +``` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +cd api +git add app/api/routes/scouts.py +git commit -m "feat(scouts): persist connected gmail_address on oauth callback" +``` + +--- + +## Task 5: GmailConnector.list_labels + stop_watch + +**Files:** +- Modify: `api/app/scouts/connectors/gmail.py` +- Modify: `api/tests/test_scout_connectors_gmail.py` + +**Context:** `_get_gmail_service(scout)` (line 48) returns a sync Google API service. Mirror the existing `archive` / `fetch_metadata` `asyncio.to_thread(_sync)` pattern. + +- [ ] **Step 1: Write the failing tests** + +Append to `api/tests/test_scout_connectors_gmail.py`: + +```python +@pytest.mark.asyncio +async def test_list_labels_returns_id_and_name(): + scout = _make_scout() + conn = GmailConnector() + fake = {"labels": [ + {"id": "INBOX", "name": "INBOX", "type": "system"}, + {"id": "Label_1", "name": "Work", "type": "user"}, + ]} + with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc: + mock_svc.return_value.users().labels().list().execute.return_value = fake + labels = await conn.list_labels(scout) + assert {"id": "INBOX", "name": "INBOX"} in labels + assert {"id": "Label_1", "name": "Work"} in labels + + +@pytest.mark.asyncio +async def test_stop_watch_calls_stop(): + scout = _make_scout() + conn = GmailConnector() + with patch("app.scouts.connectors.gmail._get_gmail_service") as mock_svc: + await conn.stop_watch(scout) + mock_svc.return_value.users().stop.assert_called() +``` + +(`_make_scout` already exists in this test file from Phase 3.) + +- [ ] **Step 2: Run to confirm fail** + +```bash +cd api +pytest tests/test_scout_connectors_gmail.py -k "list_labels or stop_watch" -v +``` +Expected: AttributeError (methods missing). + +- [ ] **Step 3: Implement the two methods** + +Add to the `GmailConnector` class in `api/app/scouts/connectors/gmail.py`: + +```python + async def list_labels(self, scout) -> list[dict]: + """Return the account's Gmail labels as [{id, name}]. Empty if no token.""" + if not scout.oauth_token_encrypted: + return [] + + def _sync() -> list[dict]: + service = _get_gmail_service(scout) + resp = service.users().labels().list(userId="me").execute() + return [{"id": lbl["id"], "name": lbl["name"]} for lbl in resp.get("labels", [])] + + return await asyncio.to_thread(_sync) + + async def stop_watch(self, scout) -> None: + """Stop Gmail push notifications. Swallows errors (watch may be gone).""" + if not scout.oauth_token_encrypted: + return + + def _sync() -> None: + service = _get_gmail_service(scout) + service.users().stop(userId="me").execute() + + try: + await asyncio.to_thread(_sync) + except Exception: + logger.exception("stop_watch failed for scout %s", scout.id) +``` + +(`asyncio` and `logger` are already imported in this file.) + +- [ ] **Step 4: Run the tests** + +```bash +cd api +pytest tests/test_scout_connectors_gmail.py -v +``` +Expected: all PASS (3 existing + 2 new). + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/scouts/connectors/gmail.py tests/test_scout_connectors_gmail.py +git commit -m "feat(scouts): add GmailConnector list_labels + stop_watch" +``` + +--- + +## Task 6: Gmail label-list + disconnect routes + +**Files:** +- Modify: `api/app/api/routes/scouts.py` +- Test: `api/tests/test_scout_cloud_crud.py` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `api/tests/test_scout_cloud_crud.py`: + +```python +@pytest.mark.asyncio +async def test_gmail_labels_route_returns_labels(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + created = (await client.post( + "/api/v1/scouts/cloud", + json={"name": "L", "provider": "gmail"}, + headers=_auth_headers(), + )).json() + sid = created["id"] + + with patch("app.api.routes.scouts.get_connector") as mock_get: + from unittest.mock import AsyncMock + mock_get.return_value.list_labels = AsyncMock(return_value=[{"id": "INBOX", "name": "INBOX"}]) + resp = await client.get(f"/api/v1/scouts/cloud/{sid}/gmail-labels", headers=_auth_headers()) + assert resp.status_code == 200 + assert resp.json() == [{"id": "INBOX", "name": "INBOX"}] + + +@pytest.mark.asyncio +async def test_gmail_disconnect_clears_token(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + created = (await client.post( + "/api/v1/scouts/cloud", + json={"name": "D", "provider": "gmail"}, + headers=_auth_headers(), + )).json() + sid = created["id"] + # mark it connected directly in the DB + async with _TestSessionLocal() as session: + row = await session.get(CloudScoutConfig, sid) + row.oauth_token_encrypted = "blob" + row.gmail_address = "a@b.com" + await session.commit() + + with patch("app.api.routes.scouts.get_connector") as mock_get: + from unittest.mock import AsyncMock + mock_get.return_value.stop_watch = AsyncMock() + resp = await client.post(f"/api/v1/scouts/cloud/{sid}/gmail-disconnect", headers=_auth_headers()) + assert resp.status_code == 200 + body = resp.json() + assert body["oauth_connected"] is False + assert body["gmail_address"] is None + assert body["enabled"] is False +``` + +- [ ] **Step 2: Run to confirm fail** + +```bash +cd api +pytest tests/test_scout_cloud_crud.py -k "labels or disconnect" -v +``` +Expected: 404. + +- [ ] **Step 3: Add the routes** + +Add to `api/app/api/routes/scouts.py` (after the cloud CRUD routes). Ensure `from app.scouts.connectors.registry import get_connector` is imported at module top (or import inside the handler): + +```python +@router.get("/cloud/{scout_id}/gmail-labels") +async def list_gmail_labels( + scout_id: str, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + try: + connector = get_connector("gmail") + except KeyError: + return [] + return await connector.list_labels(scout) + + +@router.post("/cloud/{scout_id}/gmail-disconnect", response_model=CloudScoutResponse) +async def disconnect_gmail( + scout_id: str, + db: AsyncSession = Depends(get_session), + current_user: UserProfile = Depends(get_current_user), +): + scout = await db.get(CloudScoutConfig, scout_id) + if scout is None or scout.user_id != current_user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Scout not found") + try: + connector = get_connector("gmail") + await connector.stop_watch(scout) + except KeyError: + pass + scout.oauth_token_encrypted = None + scout.gmail_history_id = None + scout.gmail_watch_expires_at = None + scout.gmail_address = None + scout.enabled = False + await db.commit() + await db.refresh(scout) + return _to_cloud_response(scout) +``` + +Add `from app.scouts.connectors.registry import get_connector` to the imports if not present. + +- [ ] **Step 4: Run the tests** + +```bash +cd api +pytest tests/test_scout_cloud_crud.py -v +``` +Expected: all PASS (6 total). + +- [ ] **Step 5: Commit** + +```bash +cd api +git add app/api/routes/scouts.py tests/test_scout_cloud_crud.py +git commit -m "feat(scouts): add gmail label-list + disconnect routes" +``` + +--- + +# PHASE C — tRPC + shared types + +## Task 7: Shared CloudScoutConfig type fields + +**Files:** +- Modify: `adiuvAI/src/shared/api-types.ts` + +- [ ] **Step 1: Extend `CloudScoutConfigSchema`** + +In `adiuvAI/src/shared/api-types.ts`, update the schema (currently ends with `oauthConnected: z.boolean().optional()`): + +```typescript +export const CloudScoutConfigSchema = z.object({ + id: z.string(), + userId: z.string(), + provider: z.enum(['gmail', 'teams', 'outlook']), + name: z.string(), + dataTypes: z.array(z.string()), + promptTemplate: z.string(), + scheduleCron: z.string(), + filterConfig: z.object({ + labels: z.array(z.string()).optional(), + senders: z.array(z.string()).optional(), + }).optional(), + autoTrashSpam: z.boolean().optional(), + enabled: z.boolean(), + lastRunAt: z.number().int().nullable().optional(), + gmailAddress: z.string().nullable().optional(), + oauthConnected: z.boolean().optional(), + createdAt: z.number().int(), + updatedAt: z.number().int(), +}); +export type CloudScoutConfig = z.infer; +``` + +- [ ] **Step 2: Typecheck** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep "api-types" || echo "no api-types errors" +``` +Expected: `no api-types errors`. + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add src/shared/api-types.ts +git commit -m "feat(scouts): add cloud scout config fields to shared type" +``` + +--- + +## Task 8: tRPC cloud router — input changes + new procedures + +**Files:** +- Modify: `adiuvAI/src/main/router/index.ts` (the `scoutCloudRouter`, lines 1155-1239) + +- [ ] **Step 1: Update `create` input — schedule optional, add autoTrashSpam** + +Replace the `create` procedure's input schema: + +```typescript + create: publicProcedure + .input(z.object({ + name: z.string(), + provider: z.enum(['gmail', 'teams', 'outlook']), + dataTypes: z.array(z.string()).default([]), + promptTemplate: z.string().default(''), + scheduleCron: z.string().optional(), + filterConfig: z.object({ + labels: z.array(z.string()).optional(), + senders: z.array(z.string()).optional(), + }).optional(), + autoTrashSpam: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + try { + const result = await getBackendClient().proxyPost( + '/api/v1/scouts/cloud', + input as Record, + ); + return { data: result, error: null }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to create cloud scout'; + return { data: null, error: msg }; + } + }), +``` + +- [ ] **Step 2: Update `update` input — add autoTrashSpam, type filterConfig** + +```typescript + update: publicProcedure + .input(z.object({ + id: z.string(), + name: z.string().optional(), + dataTypes: z.array(z.string()).optional(), + promptTemplate: z.string().optional(), + scheduleCron: z.string().optional(), + filterConfig: z.object({ + labels: z.array(z.string()).optional(), + senders: z.array(z.string()).optional(), + }).optional(), + autoTrashSpam: z.boolean().optional(), + enabled: z.boolean().optional(), + })) + .mutation(async ({ input }) => { + const { id, ...updates } = input; + try { + const result = await getBackendClient().proxyPut( + `/api/v1/scouts/cloud/${id}`, + updates as Record, + ); + return { data: result, error: null }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to update cloud scout'; + return { data: null, error: msg }; + } + }), +``` + +- [ ] **Step 3: Add `gmailLabels` + `disconnectGmail` procedures** + +Add inside `scoutCloudRouter`, after `completeGmailOAuth`: + +```typescript + gmailLabels: publicProcedure + .input(z.object({ scoutId: z.string() })) + .query(async ({ input }) => { + try { + return await getBackendClient().proxyGet<{ id: string; name: string }[]>( + `/api/v1/scouts/cloud/${input.scoutId}/gmail-labels`, + ); + } catch (err) { + console.error('[Scout] gmailLabels error:', err instanceof Error ? err.message : err); + return []; + } + }), + + disconnectGmail: publicProcedure + .input(z.object({ scoutId: z.string() })) + .mutation(async ({ input }) => { + try { + const result = await getBackendClient().proxyPost( + `/api/v1/scouts/cloud/${input.scoutId}/gmail-disconnect`, + {}, + ); + return { data: result, error: null }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to disconnect Gmail'; + return { data: null, error: msg }; + } + }), +``` + +- [ ] **Step 4: Typecheck** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep "router/index" || echo "no router errors" +``` +Expected: `no router errors`. + +- [ ] **Step 5: Commit** + +```bash +cd adiuvAI +git add src/main/router/index.ts +git commit -m "feat(scouts): tRPC cloud input changes + gmailLabels + disconnectGmail" +``` + +--- + +# PHASE D — Electron UI + +## Task 9: Extract LocalScoutCreationFlow from the stepper + +**Files:** +- Create: `adiuvAI/src/renderer/components/settings/LocalScoutCreationFlow.tsx` +- Modify: `adiuvAI/src/renderer/components/settings/InlineScoutCreationStepper.tsx` + +**Context:** This is a pure refactor — move the current local 3-step body (config + review for `local_directory`) into its own component without behavior change. The stepper keeps Step 1 (template pick) and delegates. This isolates the local flow before the cloud flow is added, keeping each file focused. + +- [ ] **Step 1: Create `LocalScoutCreationFlow.tsx`** + +Move the local-specific logic (directory picker, data-types, schedule, prompt builder, review, create-local mutation) out of `InlineScoutCreationStepper.tsx` into a new component: + +```typescript +import { useState } from 'react'; +import { FolderOpen, X, Sparkles } from 'lucide-react'; +import { trpc } from '@/lib/trpc'; +import { useNotify } from '@/hooks/useNotify'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import type { AgentCatalogItem } from '../../../shared/api-types'; +import { DATA_TYPE_CONFIG, SCHEDULE_OPTIONS } from './types'; +import { PromptBuilderChat } from './PromptBuilderChat'; + +export function LocalScoutCreationFlow({ + template, + onCancel, + onCreated, +}: { + template: AgentCatalogItem; + onCancel: () => void; + onCreated: () => void; +}) { + const createLocalMutation = trpc.scout.local.create.useMutation(); + const { notify, notifyError } = useNotify(); + + const [step, setStep] = useState<2 | 3>(2); + const [name, setName] = useState(template.name); + const [directory, setDirectory] = useState(''); + const [promptDialogOpen, setPromptDialogOpen] = useState(false); + const [dataTypes, setDataTypes] = useState((template.supportedDataTypes ?? []).slice(0, 2)); + const [schedule, setSchedule] = useState('0 * * * *'); + const [promptTemplate, setPromptTemplate] = useState(''); + const [scoutConfig, setScoutConfig] = useState | null>(null); + const [error, setError] = useState(''); + + const isSubmitting = createLocalMutation.isPending; + + async function pickDirectory() { + try { + const result = await window.electronDialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select directory for scout to watch', + }); + if (!result.canceled && result.filePaths.length > 0) setDirectory(result.filePaths[0]!); + } catch { /* noop */ } + } + + function toggleDataType(type: string) { + setDataTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]); + } + + function nextFromConfig() { + if (!name.trim()) { setError('Scout name is required.'); return; } + if (!directory) { setError('Select a directory.'); return; } + if (dataTypes.length === 0) { setError('Select at least one data type.'); return; } + setError(''); setStep(3); + } + + function handleCreate() { + setError(''); + createLocalMutation.mutate( + { name, directory, dataTypes, scheduleCron: schedule, agentConfig: scoutConfig ?? null }, + { + onSuccess: () => { notify('success', 'toast.scout.created'); onCreated(); }, + onError: (err) => { notifyError('toast.scout.createError', err); setError(err.message); }, + }, + ); + } + + const unlocked = !!directory && dataTypes.length > 0; + + return ( +
+ {step === 2 && ( + <> +
+

Step 2 of 3

+

+ Configure
+ setName(e.target.value)} + className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary" + placeholder="scout name." + spellCheck={false} + /> +

+

Point it at your files, pick what to extract, and set the run schedule.

+
+ +
+ + {directory && ( +
+ + {directory} + +
+ )} + +
+ +
+ +
+ {DATA_TYPE_CONFIG.map(({ value, label, Icon }) => { + const active = dataTypes.includes(value); + return ( + + ); + })} +
+
+ +
+ + +
+ +
+ + + +
+ setPromptTemplate(p)} + onConfigUpdate={(c) => setScoutConfig(c)} + /> +
+
+ + +
+
+
+
+ + )} + + {step === 3 && ( +
+
+

Step 3 of 3

+

+ Review and
+ create your scout. +

+

Everything looks good? Hit create and your scout will start running on schedule.

+
+ + +
+

Template

+

{template.name}

+
+
+

Name: {name}

+

Data types: {dataTypes.join(', ') || 'None'}

+

Schedule: {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}

+

Directory: {directory}

+ {scoutConfig &&

Extraction config: Added

} +
+
+
+
+ )} + + {error &&

{error}

} + +
+ + {step === 2 && } + {step === 3 && } +
+
+ ); +} +``` + +- [ ] **Step 2: Slim down `InlineScoutCreationStepper.tsx` to a router** + +Replace the whole file body with the shared template-pick step that delegates: + +```typescript +import { useState } from 'react'; +import { Bot } from 'lucide-react'; +import type { AgentCatalogItem } from '../../../shared/api-types'; +import { TemplateSelectCard } from './TemplateSelectCard'; +import { LocalScoutCreationFlow } from './LocalScoutCreationFlow'; +import { CloudScoutCreationFlow } from './CloudScoutCreationFlow'; + +export function InlineScoutCreationStepper({ + catalog, + isLoadingCatalog, + onCancel, + onCreated, +}: { + catalog: AgentCatalogItem[]; + isLoadingCatalog: boolean; + onCancel: () => void; + onCreated: () => void; +}) { + const [selectedTemplate, setSelectedTemplate] = useState(null); + + if (selectedTemplate) { + return selectedTemplate.type === 'local_directory' + ? setSelectedTemplate(null)} onCreated={onCreated} /> + : setSelectedTemplate(null)} onCreated={onCreated} />; + } + + return ( +
+
+

Step 1 of 3

+

+ Choose your
+ starting template. +

+

Pick a starting point — you can customize everything before the scout goes live.

+
+ + {isLoadingCatalog && ( +
Loading templates...
+ )} + + {!isLoadingCatalog && catalog.length === 0 && ( +
+ +

No templates available yet. Add your server URL in Account settings, then try again.

+
+ )} + +
+ {catalog.map((item) => ( + setSelectedTemplate(item)} /> + ))} +
+ +
+ +
+
+ ); +} +``` + +Add the missing `Button` import: `import { Button } from '@/components/ui/button';`. + +- [ ] **Step 3: Typecheck (CloudScoutCreationFlow not yet created — expect that one import error only)** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep -iE "InlineScoutCreationStepper|LocalScoutCreationFlow" | grep -v "CloudScoutCreationFlow" || echo "only the expected missing-CloudFlow import remains" +``` +Expected: only the `CloudScoutCreationFlow` missing-module error (resolved in Task 10). + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/settings/LocalScoutCreationFlow.tsx src/renderer/components/settings/InlineScoutCreationStepper.tsx +git commit -m "refactor(scouts): extract LocalScoutCreationFlow, stepper becomes router" +``` + +--- + +## Task 10: CloudScoutCreationFlow (Gmail slim flow) + +**Files:** +- Create: `adiuvAI/src/renderer/components/settings/CloudScoutCreationFlow.tsx` + +**Context:** Two steps. Step A: name + focus + auto-trash + Connect Gmail (creates dormant scout, runs OAuth). Step B (after connect): label multi-select + sender chips + Finish. The OAuth deep-link callback is delivered via `window.electronAI.onScoutGmailOAuthCallback` (already wired in Phase 3, used by `CloudScoutConfigPanel`). + +- [ ] **Step 1: Create the component** + +```typescript +import { useEffect, useRef, useState } from 'react'; +import { Mail, X, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { trpc } from '@/lib/trpc'; +import { useNotify } from '@/hooks/useNotify'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Switch } from '@/components/ui/switch'; +import type { AgentCatalogItem } from '../../../shared/api-types'; + +export function CloudScoutCreationFlow({ + template, + onCancel, + onCreated, +}: { + template: AgentCatalogItem; + onCancel: () => void; + onCreated: () => void; +}) { + const { t } = useTranslation(); + const { notify, notifyError } = useNotify(); + + const createMutation = trpc.scout.cloud.create.useMutation(); + const updateMutation = trpc.scout.cloud.update.useMutation(); + const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation(); + const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation(); + + const [step, setStep] = useState<'basics' | 'filter'>('basics'); + const [name, setName] = useState(template.name); + const [focus, setFocus] = useState(''); + const [autoTrash, setAutoTrash] = useState(false); + const [scoutId, setScoutId] = useState(null); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(''); + + const [labels, setLabels] = useState([]); + const [senderInput, setSenderInput] = useState(''); + const [senders, setSenders] = useState([]); + + const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery( + { scoutId: scoutId ?? '' }, + { enabled: step === 'filter' && !!scoutId }, + ); + + const scoutIdRef = useRef(null); + scoutIdRef.current = scoutId; + + // OAuth deep-link callback → complete, then advance to filter step. + useEffect(() => { + const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI; + if (!electronAI?.onScoutGmailOAuthCallback) return; + const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => { + try { + await completeOAuth.mutateAsync({ code, state }); + notify('success', 'toast.scout.gmailConnected'); + setConnecting(false); + setStep('filter'); + } catch (err) { + setConnecting(false); + notifyError('toast.scout.createError', err instanceof Error ? err : new Error(String(err))); + } + }); + return () => { off?.(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleConnect() { + if (!name.trim()) { setError(t('scouts.nameRequired')); return; } + setError(''); + setConnecting(true); + try { + const res = await createMutation.mutateAsync({ + name, + provider: 'gmail', + dataTypes: [], + promptTemplate: focus, + autoTrashSpam: autoTrash, + filterConfig: {}, + }); + if (res.error || !res.data) throw new Error(res.error ?? 'create failed'); + setScoutId(res.data.id); + await startOAuth.mutateAsync({ scoutId: res.data.id }); + // Wait for the deep-link callback (handled in the effect). + } catch (err) { + setConnecting(false); + setError(err instanceof Error ? err.message : 'Failed to connect'); + } + } + + function toggleLabel(id: string) { + setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]); + } + + function addSender() { + const v = senderInput.trim(); + if (v && !senders.includes(v)) setSenders(prev => [...prev, v]); + setSenderInput(''); + } + + async function handleFinish() { + if (!scoutId) return; + try { + await updateMutation.mutateAsync({ + id: scoutId, + filterConfig: { labels, senders }, + }); + notify('success', 'toast.scout.created'); + onCreated(); + } catch (err) { + notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err))); + } + } + + if (step === 'basics') { + return ( +
+
+

Step 2 of 3

+

+ Configure
+ setName(e.target.value)} + className="text-muted-foreground/50 bg-transparent outline-none border-none w-full placeholder:text-muted-foreground/30 caret-primary" + placeholder={t('scouts.namePlaceholder')} + spellCheck={false} + /> +

+

{t('scouts.gmailBasicsHint')}

+
+ +
+ + setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} /> +
+ +
+
+

{t('scouts.autoTrashSpam')}

+

{t('scouts.autoTrashHint')}

+
+ +
+ + {error &&

{error}

} + +
+ + +
+
+ ); + } + + // step === 'filter' + return ( +
+
+

Step 3 of 3

+

+ Narrow the
+ emails it watches. +

+

{t('scouts.filterHint')}

+
+ +
+ + {labelsQuery.isPending &&

Loading labels…

} +
+ {(labelsQuery.data ?? []).map(lbl => ( + + ))} +
+
+ +
+ +
+ setSenderInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }} + placeholder={t('scouts.filterSendersPlaceholder')} + /> + +
+ {senders.length > 0 && ( +
+ {senders.map(s => ( + + {s} + + + ))} +
+ )} +

{t('scouts.watchAllInbox')}

+
+ +
+ + +
+
+ ); +} +``` + +If `Switch` isn't an existing shadcn component, add it: `cd adiuvAI && npx shadcn@latest add switch`. Verify first with `ls src/renderer/components/ui/switch.tsx`. + +- [ ] **Step 2: Typecheck** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep -iE "CloudScoutCreationFlow|InlineScoutCreationStepper" || echo "no creation-flow errors" +``` +Expected: `no creation-flow errors` (i18n keys are runtime, not typechecked). + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/settings/CloudScoutCreationFlow.tsx src/renderer/components/ui/switch.tsx +git commit -m "feat(scouts): add CloudScoutCreationFlow Gmail slim flow" +``` + +--- + +## Task 11: Rewrite CloudScoutConfigPanel for parity + +**Files:** +- Modify: `adiuvAI/src/renderer/components/settings/CloudScoutConfigPanel.tsx` + +- [ ] **Step 1: Replace the panel body** + +```typescript +import { useEffect, useState } from 'react'; +import { Mail, X, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { trpc } from '@/lib/trpc'; +import { useNotify } from '@/hooks/useNotify'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Switch } from '@/components/ui/switch'; +import type { CloudScoutConfig } from '../../../shared/api-types'; + +export function CloudScoutConfigPanel({ + scout, +}: { + scout: CloudScoutConfig & { scoutType: 'cloud' }; + onOpenJourney: () => void; +}) { + const { t } = useTranslation(); + const utils = trpc.useUtils(); + const updateMutation = trpc.scout.cloud.update.useMutation(); + const startOAuth = trpc.scout.cloud.startGmailOAuth.useMutation(); + const completeOAuth = trpc.scout.cloud.completeGmailOAuth.useMutation(); + const disconnect = trpc.scout.cloud.disconnectGmail.useMutation(); + const { notify, notifyError } = useNotify(); + + const [focus, setFocus] = useState(scout.promptTemplate ?? ''); + const [autoTrash, setAutoTrash] = useState(scout.autoTrashSpam ?? false); + const [labels, setLabels] = useState(scout.filterConfig?.labels ?? []); + const [senders, setSenders] = useState(scout.filterConfig?.senders ?? []); + const [senderInput, setSenderInput] = useState(''); + + const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery( + { scoutId: scout.id }, + { enabled: !!scout.oauthConnected }, + ); + + useEffect(() => { + const electronAI = (window as unknown as { electronAI?: { onScoutGmailOAuthCallback?: (cb: (d: { code: string; state: string }) => void) => (() => void) } }).electronAI; + if (!electronAI?.onScoutGmailOAuthCallback) return; + const off = electronAI.onScoutGmailOAuthCallback(async ({ code, state }) => { + try { + await completeOAuth.mutateAsync({ code, state }); + notify('success', 'toast.scout.gmailConnected'); + void utils.scout.cloud.list.invalidate(); + } catch (err) { + notifyError('toast.scout.updateError', err instanceof Error ? err : new Error(String(err))); + } + }); + return () => { off?.(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function toggleLabel(id: string) { + setLabels(prev => prev.includes(id) ? prev.filter(l => l !== id) : [...prev, id]); + } + function addSender() { + const v = senderInput.trim(); + if (v && !senders.includes(v)) setSenders(prev => [...prev, v]); + setSenderInput(''); + } + + function handleSave() { + updateMutation.mutate( + { id: scout.id, promptTemplate: focus, autoTrashSpam: autoTrash, filterConfig: { labels, senders } }, + { + onSuccess: () => { notify('success', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); }, + onError: (err) => notifyError('toast.scout.updateError', err), + }, + ); + } + + function handleDisconnect() { + disconnect.mutate({ scoutId: scout.id }, { + onSuccess: () => { notify('warning', 'toast.scout.updated'); void utils.scout.cloud.list.invalidate(); }, + onError: (err) => notifyError('toast.scout.updateError', err), + }); + } + + const connected = !!scout.oauthConnected; + + return ( +
+
+ {scout.provider} +
+ + {/* Connection status */} + {connected ? ( +
+ + {t('scouts.connectedAs')} {scout.gmailAddress ?? '—'} + + +
+ ) : ( +
+ {t('scouts.gmailAccessRequired')} + +
+ )} + + {/* Focus */} +
+ + setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} /> +
+ + {/* Filter — labels */} + {connected && ( +
+ + {labelsQuery.isPending &&

Loading labels…

} +
+ {(labelsQuery.data ?? []).map(lbl => ( + + ))} +
+
+ )} + + {/* Filter — senders */} +
+ +
+ setSenderInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }} + placeholder={t('scouts.filterSendersPlaceholder')} /> + +
+ {senders.length > 0 && ( +
+ {senders.map(s => ( + + {s} + + ))} +
+ )} +
+ + {/* Auto-trash */} +
+
+

{t('scouts.autoTrashSpam')}

+

{t('scouts.autoTrashHint')}

+
+ +
+ +
+ +
+
+ ); +} +``` + +Note: `onOpenJourney` is kept in the prop type for compatibility with `ScoutRow` but no longer used — verify `ScoutRow` still passes it; if `ScoutRow` errors on an unused prop, leave the prop in the signature (it's accepted, just ignored). If TypeScript complains about the unused prop, prefix with `_`: `onOpenJourney: _onOpenJourney`. Simplest: keep the prop in the destructure but don't reference it — TS won't error on an unused destructured prop. + +- [ ] **Step 2: Typecheck** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep -i "CloudScoutConfigPanel" || echo "no panel errors" +``` +Expected: `no panel errors`. + +- [ ] **Step 3: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/settings/CloudScoutConfigPanel.tsx +git commit -m "refactor(scouts): rewrite CloudScoutConfigPanel for slim-model parity" +``` + +--- + +## Task 12: Disable Teams/Outlook template cards + +**Files:** +- Modify: `adiuvAI/src/renderer/components/settings/TemplateSelectCard.tsx` + +- [ ] **Step 1: Read the card to find the catalog-item type discriminator** + +```bash +cd adiuvAI +cat src/renderer/components/settings/TemplateSelectCard.tsx +``` +Note the `item` prop shape and how `onSelect` is wired. Cloud non-Gmail providers are identified by `item.provider === 'teams' || item.provider === 'outlook'` (or `item.type` — confirm from the file). + +- [ ] **Step 2: Gate selection + add "coming soon"** + +In the card component, compute: +```typescript +const comingSoon = item.provider === 'teams' || item.provider === 'outlook'; +``` +Apply to the root element: when `comingSoon`, add `opacity-50 pointer-events-none` to the className and render a small badge `{comingSoon && {t('scouts.comingSoon')}}` near the title. Guard `onSelect`: `onClick={comingSoon ? undefined : onSelect}`. + +Use `useTranslation` if not already imported. Confirm the actual field (`item.provider` vs `item.type`) from Step 1 and use the correct one. + +- [ ] **Step 3: Typecheck** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep -i "TemplateSelectCard" || echo "no card errors" +``` +Expected: `no card errors`. + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/components/settings/TemplateSelectCard.tsx +git commit -m "feat(scouts): disable Teams/Outlook template cards (coming soon)" +``` + +--- + +## Task 13: i18n keys (5 languages) + +**Files:** +- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` + +- [ ] **Step 1: Add the keys under the `scouts` namespace in each file** + +English (`en/translation.json`), add to the `scouts` object: +```json +"nameRequired": "Scout name is required.", +"namePlaceholder": "scout name.", +"gmailBasicsHint": "Name it, tell it what to focus on, then connect your Gmail.", +"focusLabel": "What should this scout watch for?", +"focusPlaceholder": "e.g. client requests, invoices, project updates", +"autoTrashSpam": "Auto-trash spam", +"autoTrashHint": "Move emails it judges as spam to Gmail Trash (recoverable for 30 days).", +"connecting": "Connecting…", +"filterHint": "Leave empty to watch your whole inbox, or narrow by label and sender.", +"filterLabels": "Labels", +"filterSenders": "Senders", +"filterSendersPlaceholder": "alice@example.com or @client.co", +"watchAllInbox": "No filter = watch the whole inbox.", +"skipFilter": "Skip", +"finish": "Finish", +"connectedAs": "Connected as", +"reconnect": "Reconnect", +"disconnect": "Disconnect", +"gmailAccessRequired": "Gmail access required to start receiving email suggestions.", +"comingSoon": "Coming soon" +``` + +Italian (`it`): +```json +"nameRequired": "Il nome dello scout è obbligatorio.", +"namePlaceholder": "nome scout.", +"gmailBasicsHint": "Dagli un nome, indica su cosa concentrarsi, poi connetti Gmail.", +"focusLabel": "Su cosa deve vigilare questo scout?", +"focusPlaceholder": "es. richieste clienti, fatture, aggiornamenti progetto", +"autoTrashSpam": "Cestina spam automaticamente", +"autoTrashHint": "Sposta le email giudicate spam nel Cestino di Gmail (recuperabili per 30 giorni).", +"connecting": "Connessione…", +"filterHint": "Lascia vuoto per monitorare tutta la casella, o restringi per etichetta e mittente.", +"filterLabels": "Etichette", +"filterSenders": "Mittenti", +"filterSendersPlaceholder": "alice@example.com oppure @client.co", +"watchAllInbox": "Nessun filtro = monitora tutta la casella.", +"skipFilter": "Salta", +"finish": "Fine", +"connectedAs": "Connesso come", +"reconnect": "Riconnetti", +"disconnect": "Disconnetti", +"gmailAccessRequired": "Accesso a Gmail necessario per ricevere suggerimenti email.", +"comingSoon": "Prossimamente" +``` + +Spanish (`es`): +```json +"nameRequired": "El nombre del scout es obligatorio.", +"namePlaceholder": "nombre del scout.", +"gmailBasicsHint": "Ponle nombre, indícale en qué centrarse y conecta tu Gmail.", +"focusLabel": "¿Qué debe vigilar este scout?", +"focusPlaceholder": "p. ej. solicitudes de clientes, facturas, novedades de proyectos", +"autoTrashSpam": "Enviar spam a la papelera automáticamente", +"autoTrashHint": "Mueve los correos que considere spam a la Papelera de Gmail (recuperables 30 días).", +"connecting": "Conectando…", +"filterHint": "Déjalo vacío para vigilar toda la bandeja, o filtra por etiqueta y remitente.", +"filterLabels": "Etiquetas", +"filterSenders": "Remitentes", +"filterSendersPlaceholder": "alice@example.com o @client.co", +"watchAllInbox": "Sin filtro = vigila toda la bandeja de entrada.", +"skipFilter": "Omitir", +"finish": "Finalizar", +"connectedAs": "Conectado como", +"reconnect": "Reconectar", +"disconnect": "Desconectar", +"gmailAccessRequired": "Se requiere acceso a Gmail para recibir sugerencias de correo.", +"comingSoon": "Próximamente" +``` + +French (`fr`): +```json +"nameRequired": "Le nom du scout est requis.", +"namePlaceholder": "nom du scout.", +"gmailBasicsHint": "Nommez-le, indiquez sur quoi se concentrer, puis connectez votre Gmail.", +"focusLabel": "Que doit surveiller ce scout ?", +"focusPlaceholder": "ex. demandes clients, factures, mises à jour de projet", +"autoTrashSpam": "Mettre le spam à la corbeille automatiquement", +"autoTrashHint": "Déplace les e-mails jugés indésirables vers la corbeille Gmail (récupérables 30 jours).", +"connecting": "Connexion…", +"filterHint": "Laissez vide pour surveiller toute la boîte, ou filtrez par libellé et expéditeur.", +"filterLabels": "Libellés", +"filterSenders": "Expéditeurs", +"filterSendersPlaceholder": "alice@example.com ou @client.co", +"watchAllInbox": "Aucun filtre = surveille toute la boîte de réception.", +"skipFilter": "Ignorer", +"finish": "Terminer", +"connectedAs": "Connecté en tant que", +"reconnect": "Reconnecter", +"disconnect": "Déconnecter", +"gmailAccessRequired": "Accès Gmail requis pour recevoir des suggestions d'e-mails.", +"comingSoon": "Bientôt disponible" +``` + +German (`de`): +```json +"nameRequired": "Scout-Name ist erforderlich.", +"namePlaceholder": "Scout-Name.", +"gmailBasicsHint": "Benenne ihn, lege den Fokus fest und verbinde dann dein Gmail.", +"focusLabel": "Worauf soll dieser Scout achten?", +"focusPlaceholder": "z. B. Kundenanfragen, Rechnungen, Projekt-Updates", +"autoTrashSpam": "Spam automatisch in den Papierkorb", +"autoTrashHint": "Verschiebt als Spam eingestufte E-Mails in den Gmail-Papierkorb (30 Tage wiederherstellbar).", +"connecting": "Verbinde…", +"filterHint": "Leer lassen, um den ganzen Posteingang zu überwachen, oder nach Label und Absender filtern.", +"filterLabels": "Labels", +"filterSenders": "Absender", +"filterSendersPlaceholder": "alice@example.com oder @client.co", +"watchAllInbox": "Kein Filter = überwacht den gesamten Posteingang.", +"skipFilter": "Überspringen", +"finish": "Fertig", +"connectedAs": "Verbunden als", +"reconnect": "Neu verbinden", +"disconnect": "Trennen", +"gmailAccessRequired": "Gmail-Zugriff erforderlich, um E-Mail-Vorschläge zu erhalten.", +"comingSoon": "Demnächst" +``` + +Merge these into each file's existing `scouts` object (don't create a second `scouts` key). Keep valid JSON (commas). + +- [ ] **Step 2: Validate JSON** + +```bash +cd adiuvAI +for f in en it es fr de; do node -e "JSON.parse(require('fs').readFileSync('src/renderer/locales/$f/translation.json','utf8')); console.log('$f ok')"; done +``` +Expected: `en ok` … `de ok`. + +- [ ] **Step 3: Confirm `common.save` exists** (used by the config panel Save button) + +```bash +cd adiuvAI +node -e "const j=require('./src/renderer/locales/en/translation.json'); console.log(j.common && j.common.save ? 'has common.save' : 'MISSING')" +``` +If `MISSING`, add `"save": "Save"` (and translations: it `"Salva"`, es `"Guardar"`, fr `"Enregistrer"`, de `"Speichern"`) to the `common` object in each file. + +- [ ] **Step 4: Commit** + +```bash +cd adiuvAI +git add src/renderer/locales/ +git commit -m "i18n: add cloud scout creation + config keys (5 languages)" +``` + +--- + +## Task 14: Full typecheck + manual smoke + +**Files:** none (verification) + +- [ ] **Step 1: Full Electron typecheck** + +```bash +cd adiuvAI +npx tsc --noEmit 2>&1 | grep -iE "Scout|settings/" || echo "no scout-surface errors" +``` +Expected: `no scout-surface errors`. Pre-existing unrelated errors (drizzle-executor, ChatChartBlock, etc.) are acceptable. + +- [ ] **Step 2: Lint** + +```bash +cd adiuvAI +npm run lint 2>&1 | tail -5 +``` +Expected: no new errors in touched files. + +- [ ] **Step 3: Manual smoke (document result, don't block on it)** + +Start the app, open Settings → Scouts → Create scout → Gmail. Verify: only name + focus + auto-trash on first cloud step; Connect Gmail opens consent; after consent, label/sender filter step appears; Finish creates a scout row showing "Connected as ". Open the scout's config panel: focus/filter/auto-trash editable, Disconnect works. Verify Teams/Outlook cards are disabled. Verify local-directory creation is unchanged. + +--- + +# PHASE E — Submodule bumps + +## Task 15: Bump submodule pointers + +- [ ] **Step 1: Confirm both submodules committed** + +```bash +cd c:/Users/PC-Roby/Documents/_adiuvai_workspace +git -C api status --short +git -C adiuvAI status --short +``` +Expected: clean (all work committed in prior tasks). + +- [ ] **Step 2: Bump pointers** + +```bash +cd c:/Users/PC-Roby/Documents/_adiuvai_workspace +git add api adiuvAI +git commit -m "chore: bump submodules — cloud scout creation flow (Gmail)" +``` + +--- + +## Plan Self-Review + +**Spec coverage:** +- Stepper branch (local untouched, cloud slim) — Tasks 9, 10. +- OAuth during creation, scout created at connect step — Task 10. +- Label + sender filter — Tasks 5, 6, 10. +- Focus → prompt_template → scout_purpose — Tasks 2, 10 (created with `promptTemplate: focus`). +- Auto-trash toggle — Tasks 1, 2, 10. +- Config panel parity — Task 11. +- BE label-list + disconnect routes — Task 6. +- Serializer `oauthConnected` + `filterConfig` + `gmail_address` — Tasks 2, 4. +- `gmail_address` column (Alembic 009) — Task 3. +- Catalog gating Teams/Outlook — Task 12. +- i18n 5 langs — Task 13. +- **Added beyond spec (necessary):** foundational cloud CRUD routes (Tasks 1, 2) — the spec assumed these existed; they don't. + +**Placeholder scan:** no TBDs. Where a real symbol name might differ (e.g. `get_session` patch target, `item.provider` vs `item.type`), the task gives a grep/cat to confirm and adapt — task-time verification, not a placeholder. + +**Type consistency:** `CloudScoutResponse` (snake_case, BE) ↔ `CloudScoutConfigSchema` (camelCase, TS) fields align: `auto_trash_spam`↔`autoTrashSpam`, `filter_config`↔`filterConfig`, `gmail_address`↔`gmailAddress`, `oauth_connected`↔`oauthConnected`. `filterConfig` shape `{ labels?, senders? }` consistent across Tasks 2, 7, 8, 10, 11. `scout.cloud.create` returns `{ data, error }` — consumed correctly in Task 10 (`res.data.id`). + +**Scope:** one coherent track — BE foundational CRUD + Gmail connector additions + Electron flow split. Shippable as one PR. Phase 4 (categorization/brief) remains out of scope. + +--- + +## Execution Handoff + +**Plan complete and saved to** `docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md`. **Two execution options:** + +**1. Subagent-Driven (recommended)** — fresh subagent per task, review between, fast iteration. + +**2. Inline Execution** — execute in this session with checkpoints. + +**Note:** planning uncovered that the backend cloud-CRUD routes never existed (cloud scouts 404 today). Tasks 1–2 build them — larger than the spec implied but required for any cloud scout to function. + +**Which approach?**