Files
workspace/docs/superpowers/specs/2026-05-16-cloud-scout-creation-flow-plan.md
Roberto 93353c7867 docs: add cloud scout creation flow implementation plan
15 tasks across BE cloud CRUD (foundational — routes never existed),
gmail_address + connector list_labels/stop_watch, tRPC + shared types,
Electron stepper split (Local/Cloud flows) + config panel parity +
catalog gating + i18n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:10:52 +02:00

1938 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 CD) 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<typeof CloudScoutConfigSchema>;
```
- [ ] **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<CloudScoutConfig>(
'/api/v1/scouts/cloud',
input as Record<string, unknown>,
);
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<CloudScoutConfig>(
`/api/v1/scouts/cloud/${id}`,
updates as Record<string, unknown>,
);
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<CloudScoutConfig>(
`/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<string[]>((template.supportedDataTypes ?? []).slice(0, 2));
const [schedule, setSchedule] = useState('0 * * * *');
const [promptTemplate, setPromptTemplate] = useState('');
const [scoutConfig, setScoutConfig] = useState<Record<string, unknown> | 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 (
<div className="flex flex-col gap-7 w-full">
{step === 2 && (
<>
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Configure<br />
<input
value={name}
onChange={(e) => 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}
/>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Point it at your files, pick what to extract, and set the run schedule.</p>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Directory</label>
{directory && (
<div className="flex items-center gap-2 bg-muted/40 rounded-lg px-3 py-2 text-xs font-mono mb-2">
<FolderOpen className="size-3 text-muted-foreground shrink-0" />
<span className="flex-1 truncate">{directory}</span>
<button onClick={() => setDirectory('')} className="text-muted-foreground hover:text-foreground transition-colors">
<X className="size-3" />
</button>
</div>
)}
<Button size="sm" variant="outline" onClick={pickDirectory}>
<FolderOpen className="size-3.5 mr-1.5" />
{directory ? 'Change directory' : 'Select directory'}
</Button>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-3">What to extract</label>
<div className="flex flex-wrap gap-2">
{DATA_TYPE_CONFIG.map(({ value, label, Icon }) => {
const active = dataTypes.includes(value);
return (
<button key={value} onClick={() => toggleDataType(value)}
className={cn(
'inline-flex items-center gap-2 px-3.5 py-2 rounded-xl border text-sm font-medium transition-all duration-150',
active ? 'bg-foreground text-background border-foreground'
: 'bg-background text-muted-foreground border-border/60 hover:border-border hover:text-foreground',
)}>
<Icon className="size-3.5" />
{label}
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">Batch interval</label>
<Select value={schedule} onValueChange={setSchedule}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{SCHEDULE_OPTIONS.map(opt => <SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Button variant={promptTemplate || scoutConfig ? 'outline' : 'default'} size="sm" disabled={!unlocked} onClick={() => setPromptDialogOpen(true)}>
<Sparkles className="size-3.5 mr-1.5" />
{promptTemplate || scoutConfig ? 'Edit extraction prompt' : 'Build extraction prompt'}
</Button>
<Dialog open={promptDialogOpen} onOpenChange={setPromptDialogOpen}>
<DialogContent showCloseButton={false} className="w-[95vw] max-w-[1100px] h-[75vh] max-h-[780px] flex flex-col gap-0 p-0 overflow-hidden">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<PromptBuilderChat
autoStart
agentType="local_directory"
dataTypes={dataTypes}
directory={directory}
onPromptUpdate={(p) => setPromptTemplate(p)}
onConfigUpdate={(c) => setScoutConfig(c)}
/>
</div>
<div className="flex justify-end gap-2 px-5 py-4 border-t shrink-0">
<Button variant="outline" size="sm" onClick={() => setPromptDialogOpen(false)}>Close</Button>
<Button size="sm" onClick={() => setPromptDialogOpen(false)}>Confirm</Button>
</div>
</DialogContent>
</Dialog>
</div>
</>
)}
{step === 3 && (
<div className="flex flex-col gap-5">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Review and<br />
<span className="text-muted-foreground/50">create your scout.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Everything looks good? Hit create and your scout will start running on schedule.</p>
</div>
<Card className="rounded-xl gap-0 py-0 shadow-none border-border/70">
<CardContent className="p-5 flex flex-col gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Template</p>
<p className="text-sm font-medium">{template.name}</p>
</div>
<div className="border rounded-lg px-4 py-3 grid gap-2 text-sm bg-background">
<p><span className="text-muted-foreground">Name:</span> {name}</p>
<p><span className="text-muted-foreground">Data types:</span> {dataTypes.join(', ') || 'None'}</p>
<p><span className="text-muted-foreground">Schedule:</span> {SCHEDULE_OPTIONS.find(s => s.value === schedule)?.label ?? schedule}</p>
<p><span className="text-muted-foreground">Directory:</span> {directory}</p>
{scoutConfig && <p><span className="text-muted-foreground">Extraction config:</span> Added</p>}
</div>
</CardContent>
</Card>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={() => { setError(''); step === 2 ? onCancel() : setStep(2); }}>
{step === 2 ? 'Cancel' : 'Back'}
</Button>
{step === 2 && <Button size="sm" onClick={nextFromConfig}>Next</Button>}
{step === 3 && <Button size="sm" onClick={handleCreate} disabled={isSubmitting}>Create scout now</Button>}
</div>
</div>
);
}
```
- [ ] **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<AgentCatalogItem | null>(null);
if (selectedTemplate) {
return selectedTemplate.type === 'local_directory'
? <LocalScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />
: <CloudScoutCreationFlow template={selectedTemplate} onCancel={() => setSelectedTemplate(null)} onCreated={onCreated} />;
}
return (
<div className="flex flex-col gap-5 w-full">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 1 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Choose your<br />
<span className="text-muted-foreground/50">starting template.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">Pick a starting point you can customize everything before the scout goes live.</p>
</div>
{isLoadingCatalog && (
<div className="rounded-lg border border-dashed px-4 py-10 text-sm text-muted-foreground text-center bg-muted/20">Loading templates...</div>
)}
{!isLoadingCatalog && catalog.length === 0 && (
<div className="rounded-xl border border-dashed px-6 py-10 text-center">
<Bot className="size-8 mx-auto mb-3 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No templates available yet. Add your server URL in Account settings, then try again.</p>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{catalog.map((item) => (
<TemplateSelectCard key={item.id} item={item} selected={false} onSelect={() => setSelectedTemplate(item)} />
))}
</div>
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
</div>
</div>
);
}
```
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<string | null>(null);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState('');
const [labels, setLabels] = useState<string[]>([]);
const [senderInput, setSenderInput] = useState('');
const [senders, setSenders] = useState<string[]>([]);
const labelsQuery = trpc.scout.cloud.gmailLabels.useQuery(
{ scoutId: scoutId ?? '' },
{ enabled: step === 'filter' && !!scoutId },
);
const scoutIdRef = useRef<string | null>(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 (
<div className="flex flex-col gap-7 w-full">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 2 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Configure<br />
<input
value={name}
onChange={(e) => 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}
/>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.gmailBasicsHint')}</p>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.focusLabel')}</label>
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
</div>
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex items-center justify-between pt-1">
<Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
<Button size="sm" onClick={handleConnect} disabled={connecting}>
<Mail className="size-3.5 mr-1.5" />
{connecting ? t('scouts.connecting') : t('scouts.connectGmail')}
</Button>
</div>
</div>
);
}
// step === 'filter'
return (
<div className="flex flex-col gap-7 w-full">
<div className="pb-1">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 mb-2">Step 3 of 3</p>
<h2 className="text-3xl font-semibold tracking-tight leading-tight">
Narrow the<br />
<span className="text-muted-foreground/50">emails it watches.</span>
</h2>
<p className="text-sm text-muted-foreground mt-3 leading-relaxed">{t('scouts.filterHint')}</p>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterLabels')}</label>
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels</p>}
<div className="flex flex-wrap gap-3">
{(labelsQuery.data ?? []).map(lbl => (
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
<span>{lbl.name}</span>
</label>
))}
</div>
</div>
<div>
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground/60 block mb-2">{t('scouts.filterSenders')}</label>
<div className="flex gap-2">
<Input
value={senderInput}
onChange={(e) => setSenderInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
placeholder={t('scouts.filterSendersPlaceholder')}
/>
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
</div>
{senders.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{senders.map(s => (
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
{s}
<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
</span>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-2">{t('scouts.watchAllInbox')}</p>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={handleFinish}>{t('scouts.skipFilter')}</Button>
<Button size="sm" onClick={handleFinish} disabled={updateMutation.isPending}>{t('scouts.finish')}</Button>
</div>
</div>
);
}
```
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<string[]>(scout.filterConfig?.labels ?? []);
const [senders, setSenders] = useState<string[]>(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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize">{scout.provider}</Badge>
</div>
{/* Connection status */}
{connected ? (
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
<Mail className="size-4 text-muted-foreground" />
<span className="text-xs flex-1">{t('scouts.connectedAs')} <span className="font-medium">{scout.gmailAddress ?? '—'}</span></span>
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })}>{t('scouts.reconnect')}</Button>
<Button size="sm" variant="ghost" onClick={handleDisconnect} disabled={disconnect.isPending}>{t('scouts.disconnect')}</Button>
</div>
) : (
<div className="flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
<span className="text-xs text-amber-700 dark:text-amber-400 flex-1">{t('scouts.gmailAccessRequired')}</span>
<Button size="sm" variant="outline" onClick={() => startOAuth.mutate({ scoutId: scout.id })} disabled={startOAuth.isPending}>
{t('scouts.connectGmail')}
</Button>
</div>
)}
{/* Focus */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.focusLabel')}</label>
<Input value={focus} onChange={(e) => setFocus(e.target.value)} placeholder={t('scouts.focusPlaceholder')} />
</div>
{/* Filter — labels */}
{connected && (
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterLabels')}</label>
{labelsQuery.isPending && <p className="text-xs text-muted-foreground">Loading labels</p>}
<div className="flex flex-wrap gap-3">
{(labelsQuery.data ?? []).map(lbl => (
<label key={lbl.id} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={labels.includes(lbl.id)} onCheckedChange={() => toggleLabel(lbl.id)} />
<span>{lbl.name}</span>
</label>
))}
</div>
</div>
)}
{/* Filter — senders */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide block mb-2">{t('scouts.filterSenders')}</label>
<div className="flex gap-2">
<Input value={senderInput} onChange={(e) => setSenderInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSender(); } }}
placeholder={t('scouts.filterSendersPlaceholder')} />
<Button size="sm" variant="outline" onClick={addSender}><Plus className="size-3.5" /></Button>
</div>
{senders.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{senders.map(s => (
<span key={s} className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs">
{s}<button onClick={() => setSenders(prev => prev.filter(x => x !== s))}><X className="size-3" /></button>
</span>
))}
</div>
)}
</div>
{/* Auto-trash */}
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<p className="text-sm font-medium">{t('scouts.autoTrashSpam')}</p>
<p className="text-xs text-muted-foreground">{t('scouts.autoTrashHint')}</p>
</div>
<Switch checked={autoTrash} onCheckedChange={setAutoTrash} />
</div>
<div className="flex items-center justify-end pt-1">
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>{t('common.save')}</Button>
</div>
</div>
);
}
```
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 && <span className="text-[10px] uppercase tracking-wide text-muted-foreground">{t('scouts.comingSoon')}</span>}` 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 <email>". 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 12 build them — larger than the spec implied but required for any cloud scout to function.
**Which approach?**