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>
74 KiB
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
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:
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
cd api
python -c "from app.schemas import CloudScoutCreateRequest, CloudScoutUpdateRequest, CloudScoutResponse; print('ok')"
Expected: ok.
- Step 4: Commit
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:
"""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
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:
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):
_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
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
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
"""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
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):
gmail_address: Mapped[str | None] = mapped_column(String(320), nullable=True)
- Step 4: Run scout tests to confirm create_all still works
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
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(thescout_gmail_oauth_callbackhandler, 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:
# 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
cd api
python -c "import app.api.routes.scouts; print('ok')"
Expected: ok.
- Step 3: Commit
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:
@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
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:
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
cd api
pytest tests/test_scout_connectors_gmail.py -v
Expected: all PASS (3 existing + 2 new).
- Step 5: Commit
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:
@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
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):
@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
cd api
pytest tests/test_scout_cloud_crud.py -v
Expected: all PASS (6 total).
- Step 5: Commit
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()):
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
cd adiuvAI
npx tsc --noEmit 2>&1 | grep "api-types" || echo "no api-types errors"
Expected: no api-types errors.
- Step 3: Commit
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(thescoutCloudRouter, lines 1155-1239) -
Step 1: Update
createinput — schedule optional, add autoTrashSpam
Replace the create procedure's input schema:
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
updateinput — add autoTrashSpam, type filterConfig
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+disconnectGmailprocedures
Add inside scoutCloudRouter, after completeGmailOAuth:
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
cd adiuvAI
npx tsc --noEmit 2>&1 | grep "router/index" || echo "no router errors"
Expected: no router errors.
- Step 5: Commit
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:
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.tsxto a router
Replace the whole file body with the shared template-pick step that delegates:
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)
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
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
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
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
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
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
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -i "CloudScoutConfigPanel" || echo "no panel errors"
Expected: no panel errors.
- Step 3: Commit
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
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:
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
cd adiuvAI
npx tsc --noEmit 2>&1 | grep -i "TemplateSelectCard" || echo "no card errors"
Expected: no card errors.
- Step 4: Commit
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
scoutsnamespace in each file
English (en/translation.json), add to the scouts object:
"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):
"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):
"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):
"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):
"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
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.saveexists (used by the config panel Save button)
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
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
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
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
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
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_addresscolumn (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?