diff --git a/.github/ACTIONS-REFERENCE.md b/.github/ACTIONS-REFERENCE.md index 6c1ff878..6a48bebc 100644 --- a/.github/ACTIONS-REFERENCE.md +++ b/.github/ACTIONS-REFERENCE.md @@ -39,7 +39,7 @@ GitHub provides two mechanisms for storing configuration values: | CDK_CERTIFICATE_ARN | Variable | No | None | Platform | ACM certificate ARN for HTTPS on the ALB. Must be in the **deployment region** (not us-east-1). | | CDK_CLOUDFRONT_CERTIFICATE_ARN | Variable | No | None | Platform | Shared ACM certificate ARN for all CloudFront origins (SPA, artifacts, mcp-sandbox). **Must be in `us-east-1`** and cover `{CDK_DOMAIN_NAME}` + `*.{CDK_DOMAIN_NAME}`. Each CloudFront origin falls back to this when its section-specific cert var is unset, so one wildcard satisfies all three. **Effectively required for any deploy with `CDK_DOMAIN_NAME` set** (unless every per-origin cert var is supplied individually) — a domained deploy with no effective CloudFront cert fails at `cdk synth`. | | CDK_CORS_ORIGINS | Variable | No | None | All | Additional CORS origins appended to the auto-derived `https://{CDK_DOMAIN_NAME}`. Comma-separated. Use for localhost during local dev (e.g., `http://localhost:4200`) or extra domains. | -| CDK_DOMAIN_NAME | Variable | No | None | All | Primary domain name (e.g., 'alpha.boisestate.ai'). Auto-applied as `https://{value}` to CORS origins platform-wide. This is the primary mechanism for CORS configuration. | +| CDK_DOMAIN_NAME | Variable | No | None | All | Primary domain name (e.g., `dev.boisestate.ai` for a dev stack, or `boisestate.ai` for an apex production stack). Auto-applied as `https://{value}` to CORS origins platform-wide. This is the primary mechanism for CORS configuration. | | CDK_FILE_UPLOAD_CORS_ORIGINS | Variable | No | None | Platform | Additional CORS origins for the file upload S3 bucket only (appended to global CORS origins) | | CDK_FILE_UPLOAD_MAX_SIZE_MB | Variable | No | `10` | Platform | Maximum file upload size in megabytes | | CDK_FINE_TUNING_CORS_ORIGINS | Variable | No | None | SageMaker Fine-Tuning | Additional CORS origins for the fine-tuning S3 bucket only (appended to global CORS origins) | @@ -62,6 +62,7 @@ GitHub provides two mechanisms for storing configuration values: | CDK_INFERENCE_API_ENABLED | Variable | No | `true` | Inference API | Enable/disable Inference API deployment | | CDK_INFERENCE_API_MAX_CAPACITY | Variable | No | `5` | Inference API | Maximum Inference API runtime instances for auto-scaling | | CDK_INFERENCE_API_MEMORY | Variable | No | `2048` | Inference API | Memory (MB) for Inference API AgentCore Runtime (512, 1024, 2048, 4096, 8192) | +| CDK_MANAGE_DNS_RECORDS | Variable | No | `true` | Platform | Whether CDK creates the Route53 ALIAS/A records for the SPA, ALB, artifacts, and mcp-sandbox origins. Set to `false` when the hosted zone for `CDK_DOMAIN_NAME` lives in a **different AWS account** (or is otherwise managed out-of-band): the stack still attaches the custom domain + ACM cert to every origin but skips the in-account `HostedZone.fromLookup` + record creation that would fail cross-account. In that mode the deploy emits CfnOutputs (`*-dns-record-name` / `*-dns-alias-target`) with the record name and alias target for each origin so an operator can create the records manually. ACM certs still live in the **deploy** account (CloudFront in `us-east-1`, ALB in the deploy region); validate them via DNS records added by hand in the zone's account. | | CDK_MCP_SANDBOX_CERTIFICATE_ARN | Variable | No | Falls back to `CDK_CLOUDFRONT_CERTIFICATE_ARN` | MCP Sandbox | **Optional override** for the MCP Apps sandbox origin (`mcp-sandbox.{CDK_DOMAIN_NAME}`) cert — the cross-origin shell the SPA frames MCP Apps in. Leave unset to use the shared `CDK_CLOUDFRONT_CERTIFICATE_ARN`; set only to give this origin a different cert. **Must be in `us-east-1`.** An effective cert (this or the shared var) is required for a domained deploy — without it the proxy would land on the CloudFront default domain with no Route 53 ALIAS and MCP Apps fail to load, so synth fails instead. | | CDK_MCP_SANDBOX_EXTRA_FRAME_ANCESTORS | Variable | No | None | MCP Sandbox | Comma-separated extra origins (beyond `https://{CDK_DOMAIN_NAME}`) permitted to embed the MCP Apps sandbox proxy via CSP `frame-ancestors` — e.g. `http://localhost:4200` for a local SPA pointed at this deployment. **Leave unset in production.** | | CDK_PRODUCTION | Variable | No | `true` | Frontend | Production environment flag (affects runtime config generation) | diff --git a/.github/workflows/nightly-deploy-pipeline.yml b/.github/workflows/nightly-deploy-pipeline.yml index c388fec2..1f9778c2 100644 --- a/.github/workflows/nightly-deploy-pipeline.yml +++ b/.github/workflows/nightly-deploy-pipeline.yml @@ -161,6 +161,9 @@ jobs: CDK_CLOUDFRONT_CERTIFICATE_ARN: "" CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_RETAIN_DATA_ON_DELETE: false + # Ephemeral nightly deploys in-account, so manage DNS records in-account. + # See platform.yml — set "false" for cross-account hosted zones. Defaults true. + CDK_MANAGE_DNS_RECORDS: true CDK_ARTIFACTS_CERTIFICATE_ARN: "" CDK_ARTIFACTS_EXTRA_FRAME_ANCESTORS: "" CDK_FRONTEND_CERTIFICATE_ARN: "" diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index ac61a6cd..a0c18440 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -76,6 +76,11 @@ jobs: # three. A section-specific var still overrides per origin. CDK_CLOUDFRONT_CERTIFICATE_ARN: ${{ vars.CDK_CLOUDFRONT_CERTIFICATE_ARN }} CDK_RETAIN_DATA_ON_DELETE: ${{ vars.CDK_RETAIN_DATA_ON_DELETE }} + # Whether CDK creates Route53 records for the SPA/ALB/artifacts/mcp-sandbox + # origins. Set to "false" when the hosted zone for CDK_DOMAIN_NAME lives in + # a different AWS account (records are then created manually from the + # deploy's CfnOutputs). Defaults to true. + CDK_MANAGE_DNS_RECORDS: ${{ vars.CDK_MANAGE_DNS_RECORDS }} CDK_HOSTED_ZONE_DOMAIN: ${{ vars.CDK_HOSTED_ZONE_DOMAIN }} CDK_COGNITO_DOMAIN_PREFIX: ${{ vars.CDK_COGNITO_DOMAIN_PREFIX }} CDK_COGNITO_CALLBACK_URLS: ${{ vars.CDK_COGNITO_CALLBACK_URLS }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a94789..85261f21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.0.1] - 2026-06-26 + +First patch on top of the 1.0.0 general-availability release. Adds the ability to **save a conversation to a connected app** ("Save to…", with Google Drive as the reference export target) and **support for external (cross-account) Route53 hosted zones**. Both additions are additive and off by default until configured; existing 1.0.0 deployments upgrade in place with no migration. + +### 🚀 Added + +- **Save conversations to connected apps ("Save to…")** — push a full conversation transcript out to a connected app as a native Google Doc, Markdown, or plain-text file. New `apis.app_api.export_targets` package: `ExportTargetAdapter` contract + code-shipped registry mirroring the read-side `FileSourceAdapter` pattern, a `GoogleDriveAdapter` reference target, and a transcript renderer. User routes `GET /export-targets` (catalog with per-connector `connected`/`supportedFormats`/`browsable`) and `POST /sessions/{id}/export` (renders + creates the document via the user's own AgentCore Identity token; `409`→consent, `503`→workload misconfig). Admin `GET /admin/export-target-adapters` + new `OAuthProvider.export_target_adapter_id` mapping; `ExportReceipt` persisted to session metadata. SPA `ExportDialogComponent` + `ExportService`, a "Save to…" action on the conversation list, and an admin connector-form adapter dropdown (#507, #508, #509, #510, #511) +- **External (cross-account) Route53 hosted zones** — new optional `manageDnsRecords` flag (env `CDK_MANAGE_DNS_RECORDS`, context `manageDnsRecords`; defaults to `true`). When `false`, the SPA, ALB, artifacts, and mcp-sandbox origins still attach their custom domain + ACM cert but skip the in-account `HostedZone.fromLookup` + ALIAS/A record creation (which fails when the zone is in another account), emitting `CfnOutput` record-name/alias-target pairs per origin so an operator can create the records by hand. Plumbed through `load-env.sh`, the `platform.yml`/`teardown.yml`/`nightly-deploy-pipeline.yml` workflows, and the deployment docs (#512) + +### 🧪 Test coverage + +- 1,400+ lines of new export-target tests: `test_export_routes.py`, `test_export_target_service.py`, `test_export_google_drive.py`, `test_export_render.py`, `test_export_target_adapters_admin.py` (backend); `export-dialog.component.spec.ts`, `export.service.spec.ts` (frontend) + ## [1.0.0] - 2026-06-24 The **1.0.0 general-availability release** — the platform graduates from beta to a stable, single-stack architecture. The CDK app collapses from nine CloudFormation stacks into one `PlatformStack` with a platform-as-bootstrap code-deploy model; admin-curated Conversation Modes, external file-source connectors and website crawling for assistant knowledge bases, self-service AgentCore Gateway MCP target registration, a curated model catalog with the new Amazon Bedrock Mantle provider, per-turn context attribution, a Starlight documentation site, and a full backup/restore DR toolchain all ship; plus a coordinated security-hardening sweep and remediation of all 22 HIGH Dependabot findings. diff --git a/README.md b/README.md index d6de5740..2e1a0a8e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.0.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.0.1-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.0.0 +**Current release:** v1.0.1 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index be90090a..67707454 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,64 @@ +# Release Notes — v1.0.1 + +**Release Date:** June 26, 2026 +**Previous Release:** v1.0.0 (June 24, 2026) + +--- + +> ⚠️ **Coming from a pre-1.0.0 (beta) deployment? Read the 1.0.0 release notes first.** 1.0.1 lands just two days after 1.0.0, so most operators haven't deployed 1.0.0 yet. There is **no special upgrade path for 1.0.1 itself** — if you're already on 1.0.0 you upgrade in place with no migration. But 1.0.0 was the single-stack consolidation, and upgrading **from any beta** to 1.0.0 (and therefore to 1.0.1) is a **destructive backup → teardown → redeploy → restore migration**, not an in-place `cdk deploy`. If you haven't already worked through it, do that before deploying 1.0.1: see [**Upgrading an existing deployment** (1.0.0 notes)](#upgrading-an-existing-deployment) below, or the published guide at . **Brand-new deployments need none of this.** + +--- + +## Highlights + +v1.0.1 is the first patch on top of the 1.0.0 general-availability release, and it ships two operator- and user-facing additions. **Save conversations to connected apps** ("Save to…") extends the existing connector/adapter pattern in the write direction: a user can push a full conversation transcript out to an app they've connected — Google Drive is the reference destination — as a native Google Doc, Markdown, or plain-text file, reusing the same OAuth consent, RBAC visibility, and AgentCore Identity token flow as document import. **External (cross-account) Route53 hosted zones** lets deployments whose DNS zone lives in a different AWS account (or is managed out-of-band) stand up the full platform without the deploy failing on an in-account hosted-zone lookup. No action is required for existing 1.0.0 deployments; both features are additive. + +--- + +## Save conversations to connected apps ("Save to…") + +The platform already lets a user connect an app and pull documents **into** an assistant's knowledge base. v1.0.1 adds the opposite direction: save a conversation transcript **out** to a connected app. The architectural move is to split the existing connector pattern into a direction-agnostic **auth layer** (`OAuthProvider` + AgentCore Identity + consent UX, reused as-is) and a direction-specific **capability layer** — a new `ExportTargetAdapter` registry mirroring the read-side `FileSourceAdapter` registry. Google Drive is the first export target; adding OneDrive / SharePoint / Dropbox later is "write one adapter class, register it, and have an admin map a connector." (#507, #508, #509, #510, #511) + +### Backend + +- New `apis.app_api.export_targets` package: an `ExportTargetAdapter` contract (`adapter.py`), a code-shipped `registry`, the reference `adapters/google_drive.py` (creates a Drive file via the user's own token), a `render.py` transcript renderer (native Google Doc / Markdown / plain text), `models.py` (`ExportFormat`, `ExportInclude`, `ExportTargetError`), and `service.py` (connector resolution, RBAC visibility gate, AgentCore Identity token mint with consent/`503` handling). +- User-facing routes (`export_targets/routes.py`, on **app-api** — the inference-API boundary only proxies `/invocations` + `/ping`): `GET /export-targets` returns the catalog the "Save to…" dialog reads (per-connector `connected` state, `supportedFormats`, and a `browsable` flag for the folder picker), and `POST /sessions/{id}/export` renders the full transcript (paged, with a runaway guard) and creates the document via the resolved adapter. A `409` signals the user must complete OAuth consent; a `503` signals workload/callback misconfiguration. +- Admin mapping: new `GET /admin/export-target-adapters` plus an `OAuthProvider.export_target_adapter_id` field — a connector becomes an export target only once an admin maps it to a shipped adapter. Export receipts are persisted to session metadata (`ExportReceipt` on `sessions/models.py`, written best-effort by `add_export_receipt`) so the SPA can reflect "saved" state. + +### Frontend + +- New `ExportDialogComponent` (the "Save to…" dialog: connector picker, format picker driven by `supportedFormats`, optional destination-folder picker reusing the file-source browse dialog) and an `ExportService` (`session/services/export/`). A "Save to…" action is added to the conversation list (`session-list`). Admin connector form gains an export-target-adapter mapping dropdown. + +### Test coverage + +1,400+ lines of new tests: `test_export_routes.py`, `test_export_target_service.py`, `test_export_google_drive.py`, `test_export_render.py`, and `test_export_target_adapters_admin.py` on the backend; `export-dialog.component.spec.ts` and `export.service.spec.ts` on the frontend. + +## External (cross-account) Route53 hosted zones + +Deployments where the Route53 hosted zone for `domainName` lives in a **different AWS account** — or is otherwise managed out-of-band — previously failed: the stack's in-account `HostedZone.fromLookup` + ALIAS/A record creation cannot reach a zone it doesn't own. v1.0.1 makes DNS record management optional so these deployments succeed, with the platform emitting the records an operator needs to create by hand. (#512) + +### Infrastructure + +- New `manageDnsRecords` config flag (env `CDK_MANAGE_DNS_RECORDS`, context `manageDnsRecords`; **defaults to `true`**, so existing single-account deployments are unaffected). Loaded in `infrastructure/lib/config.ts` and threaded into the four custom-domain origins. +- When `manageDnsRecords=false`, the SPA, ALB, artifacts, and mcp-sandbox constructs still attach the custom domain + ACM certificate to each origin but **skip** the in-account `HostedZone.fromLookup` and ALIAS/A record creation. Instead each origin emits `CfnOutput`s with the **record name** and **alias target** (e.g. `AlbDnsRecordName`/`AlbDnsAliasTarget`, `FrontendDnsRecordName`, `ArtifactsDnsRecordName`/`ArtifactsDnsAliasTarget`, `McpSandboxDnsRecordName`/`McpSandboxDnsAliasTarget`) so an operator can create the records manually in the owning account. + +### CI/CD + +- `CDK_MANAGE_DNS_RECORDS` plumbed end-to-end: exported and passed as a `--context` flag in `scripts/common/load-env.sh`, and added to the job-level `env:` of the `platform.yml`, `teardown.yml`, and `nightly-deploy-pipeline.yml` workflows. The nightly smoke test reads it as well. + +### Docs + +- Deployment docs updated for the cross-account workflow — `docs-site` (environments, platform-cdk, troubleshooting) and `.github/docs/deploy/` (GitHub config, troubleshooting) plus `.github/ACTIONS-REFERENCE.md`. + +## 🚀 Deployment notes + +v1.0.1 is a patch release on the single-stack `PlatformStack` architecture introduced in 1.0.0. Operators already on 1.0.0 upgrade in place — there is **no migration**. Both features are additive and off by default until configured. + +- **Save to… (export targets):** opt-in. An admin maps an existing connector (e.g. the Google Drive connector) to the `google-drive` export-target adapter from the admin connector form; until a connector is mapped, the "Save to…" dialog shows no destinations. No new infrastructure or env vars. +- **Cross-account DNS:** if your hosted zone is in the **same** AWS account as the deployment (the default), no action is required — `manageDnsRecords` defaults to `true` and behavior is unchanged. If your zone lives in a **different** account (or you manage DNS out-of-band), set `CDK_MANAGE_DNS_RECORDS=false` (GitHub Actions Variable), then after the deploy read the `*DnsRecordName` / `*DnsAliasTarget` CloudFormation outputs and create the matching ALIAS/A records in the owning account for the SPA, ALB, artifacts, and mcp-sandbox origins. + +--- + # Release Notes — v1.0.0 **Release Date:** June 24, 2026 diff --git a/VERSION b/VERSION index 3eefcb9d..7dea76ed 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.0.1 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 866e2e93..e42acd3c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.0.0" +version = "1.0.1" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/src/apis/app_api/admin/export_targets/__init__.py b/backend/src/apis/app_api/admin/export_targets/__init__.py new file mode 100644 index 00000000..a70ab99e --- /dev/null +++ b/backend/src/apis/app_api/admin/export_targets/__init__.py @@ -0,0 +1 @@ +"""Admin surface for export-target adapter discovery.""" diff --git a/backend/src/apis/app_api/admin/export_targets/routes.py b/backend/src/apis/app_api/admin/export_targets/routes.py new file mode 100644 index 00000000..f7429acd --- /dev/null +++ b/backend/src/apis/app_api/admin/export_targets/routes.py @@ -0,0 +1,74 @@ +"""Admin API routes for export-target adapter discovery. + +Exposes the export-target adapter registry read-only so the admin connector +form can render a dropdown for mapping a connector to a destination adapter. +The registry is code-defined and immutable at runtime — adapters ship in +releases and are never created through this API. + +Mirror of `admin/file_sources/routes.py` for the write direction. +""" + +import logging +from typing import List + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, ConfigDict, Field + +from apis.shared.auth import User, require_admin + +from apis.app_api.export_targets.registry import registry + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/export-target-adapters", tags=["admin-export-targets"]) + + +class ExportTargetAdapterInfo(BaseModel): + """Read-only description of a registered export-target adapter.""" + + model_config = ConfigDict(populate_by_name=True) + + key: str = Field(..., description="Stable adapter key stored on a connector") + display_name: str = Field(..., alias="displayName") + icon: str = Field(..., description="Icon hint the admin UI maps to an asset") + compatible_provider_types: List[str] = Field( + ..., + alias="compatibleProviderTypes", + description="OAuth provider types this adapter may be mapped to", + ) + required_scopes: List[str] = Field( + ..., + alias="requiredScopes", + description="OAuth scopes the connector must grant for the adapter to work", + ) + supported_formats: List[str] = Field( + ..., + alias="supportedFormats", + description="Output formats this destination can produce", + ) + + +class ExportTargetAdapterListResponse(BaseModel): + adapters: List[ExportTargetAdapterInfo] + + +@router.get("/", response_model=ExportTargetAdapterListResponse) +async def list_export_target_adapters( + admin: User = Depends(require_admin), +) -> ExportTargetAdapterListResponse: + """List every export-target adapter shipped in this release. Admin only.""" + logger.info("Admin listing export-target adapters") + adapters = [ + ExportTargetAdapterInfo( + key=a.metadata.key, + displayName=a.metadata.display_name, + icon=a.metadata.icon, + compatibleProviderTypes=[ + pt.value for pt in a.metadata.compatible_provider_types + ], + requiredScopes=list(a.metadata.required_scopes), + supportedFormats=[fmt.value for fmt in a.metadata.supported_formats], + ) + for a in registry.all() + ] + return ExportTargetAdapterListResponse(adapters=adapters) diff --git a/backend/src/apis/app_api/admin/oauth/routes.py b/backend/src/apis/app_api/admin/oauth/routes.py index 2232f905..23d3874c 100644 --- a/backend/src/apis/app_api/admin/oauth/routes.py +++ b/backend/src/apis/app_api/admin/oauth/routes.py @@ -16,6 +16,7 @@ import boto3 from fastapi import APIRouter, Depends, HTTPException, Query, status +from apis.app_api.export_targets.registry import registry as export_target_registry from apis.app_api.file_sources.registry import registry from apis.shared.auth import User, require_admin from apis.shared.oauth.agentcore_registrar import ( @@ -191,10 +192,14 @@ async def create_provider( detail=f"Provider '{provider_data.provider_id}' already exists", ) - # Fail fast on a bad file-source mapping, before any AgentCore side-effect. + # Fail fast on a bad file-source / export-target mapping, before any + # AgentCore side-effect. _validate_file_source_adapter( provider_data.file_source_adapter_id, provider_data.provider_type ) + _validate_export_target_adapter( + provider_data.export_target_adapter_id, provider_data.provider_type + ) try: credential_info = registrar.create_credential_provider( @@ -270,6 +275,9 @@ async def update_provider( _validate_file_source_adapter( updates.file_source_adapter_id, existing.provider_type ) + _validate_export_target_adapter( + updates.export_target_adapter_id, existing.provider_type + ) rotating_credentials = bool(updates.client_id and updates.client_secret) changing_discovery = ( @@ -394,6 +402,35 @@ def _validate_file_source_adapter( ) +def _validate_export_target_adapter( + adapter_id: Optional[str], provider_type: OAuthProviderType +) -> None: + """Reject an export-target adapter mapping the registry cannot honor. + + An empty/None value is a no-op — the connector simply isn't an export + target. A populated value must name an adapter shipped in the registry + whose `compatible_provider_types` includes this connector's type. + Raises `HTTPException` 400 on a bad mapping. Mirrors + `_validate_file_source_adapter` for the write direction. + """ + if not adapter_id: + return + adapter = export_target_registry.get(adapter_id) + if adapter is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown export-target adapter '{adapter_id}'", + ) + if provider_type not in adapter.metadata.compatible_provider_types: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Export-target adapter '{adapter_id}' is not compatible with " + f"provider type '{provider_type.value}'" + ), + ) + + def _build_provider_from_create( data: OAuthProviderCreate, credential_info: CredentialProviderInfo ) -> OAuthProvider: @@ -419,6 +456,8 @@ def _build_provider_from_create( custom_parameters=data.custom_parameters or None, # `""` from the form means "not a file source" — store as None. file_source_adapter_id=data.file_source_adapter_id or None, + # `""` from the form means "not an export target" — store as None. + export_target_adapter_id=data.export_target_adapter_id or None, created_at=now, updated_at=now, ) diff --git a/backend/src/apis/app_api/admin/routes.py b/backend/src/apis/app_api/admin/routes.py index 42cafa3a..01f5d761 100644 --- a/backend/src/apis/app_api/admin/routes.py +++ b/backend/src/apis/app_api/admin/routes.py @@ -868,6 +868,11 @@ async def sync_model_roles( router.include_router(file_sources_admin_router) +# ========== Include Export-Target Adapters Admin Subrouter ========== +from .export_targets.routes import router as export_targets_admin_router + +router.include_router(export_targets_admin_router) + # ========== Include Auth Providers Admin Subrouter ========== from .auth_providers.routes import router as auth_providers_router diff --git a/backend/src/apis/app_api/export_targets/__init__.py b/backend/src/apis/app_api/export_targets/__init__.py new file mode 100644 index 00000000..a4de8d19 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/__init__.py @@ -0,0 +1,10 @@ +"""Export targets: connectors usable as a *destination* to save content to. + +The mirror image of `file_sources`. Where a file-source adapter reads files +*into* the system, an export-target adapter writes a rendered document *out* +to a connected app (e.g. saving a conversation transcript to Google Drive). + +The connector + OAuth layer is shared with file sources — only the capability +(write vs. read) differs, so this package mirrors the file-source adapter +contract and registry rather than extending them. +""" diff --git a/backend/src/apis/app_api/export_targets/adapter.py b/backend/src/apis/app_api/export_targets/adapter.py new file mode 100644 index 00000000..f8d92804 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/adapter.py @@ -0,0 +1,79 @@ +"""Export-target adapter contract. + +An adapter is the per-provider code that makes a connector usable as a +*destination* — somewhere a rendered document (e.g. a conversation transcript) +can be written. It is bound to a connector by an admin (the connector record +stores the adapter's `key` in `export_target_adapter_id`) and implements a +uniform create/list contract so the rest of the system stays provider-agnostic. + +The write-direction mirror of `file_sources.adapter.FileSourceAdapter`. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from apis.shared.oauth.models import OAuthProviderType + +from apis.app_api.export_targets.models import ( + CreatedFile, + ExportDestination, + ExportFormat, +) + + +@dataclass(frozen=True) +class ExportTargetMetadata: + """Static, code-defined description of an export-target adapter. + + Surfaced read-only to the admin UI so an admin can map a connector to an + adapter from a dropdown. `compatible_provider_types` constrains which + connectors an adapter may be mapped to; `required_scopes` lets the admin + form warn when a connector's OAuth scopes don't cover the adapter (a + write scope, e.g. Drive's `drive.file`); `supported_formats` tells the + SPA which output formats to offer for this destination. + """ + + key: str + display_name: str + icon: str + compatible_provider_types: Tuple[OAuthProviderType, ...] + required_scopes: Tuple[str, ...] + supported_formats: Tuple[ExportFormat, ...] + + +class ExportTargetAdapter(ABC): + """Provider-specific implementation of the export-target contract. + + All methods receive an already-resolved OAuth access token for the + exporting user — adapters never deal with token acquisition or refresh. + """ + + @property + @abstractmethod + def metadata(self) -> ExportTargetMetadata: + """Return this adapter's static metadata.""" + + @abstractmethod + async def list_destinations(self, access_token: str) -> List[ExportDestination]: + """Return the top-level write locations the user can save into.""" + + @abstractmethod + async def create_document( + self, + access_token: str, + *, + content: bytes, + name: str, + source_mime_type: str, + target_format: ExportFormat, + parent_id: Optional[str] = None, + ) -> CreatedFile: + """Create a document at the destination and return its identity. + + `content`/`source_mime_type` are the rendered bytes and their MIME + type (e.g. `text/html`); `target_format` is how the file should land + (e.g. a native Google Doc converted from that HTML). `parent_id` is an + optional destination folder — None means the destination's default + location. + """ diff --git a/backend/src/apis/app_api/export_targets/adapters/__init__.py b/backend/src/apis/app_api/export_targets/adapters/__init__.py new file mode 100644 index 00000000..46768a48 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/adapters/__init__.py @@ -0,0 +1 @@ +"""Concrete export-target adapters (one module per provider).""" diff --git a/backend/src/apis/app_api/export_targets/adapters/google_drive.py b/backend/src/apis/app_api/export_targets/adapters/google_drive.py new file mode 100644 index 00000000..77c4a0d0 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/adapters/google_drive.py @@ -0,0 +1,264 @@ +"""Google Drive export-target adapter (Drive API v3). + +Writes a rendered document into the user's Drive. Uses the least-privilege +`drive.file` scope: the app can create files and only ever sees the files it +created — it cannot read the rest of the user's Drive. A `GOOGLE_DOC` export +uploads an HTML body against the Google Doc MIME type so Drive converts it to +a native Doc; a `MARKDOWN` export uploads the bytes as a plain `.md` file. + +When no destination folder is given, files land in a single app-owned folder +(find-or-create by name) so exports don't clutter the user's Drive root. With +`drive.file` the folder search only matches files the app itself created, +which is exactly the folder we made on a previous export. + +`transport` is injectable so tests can drive the adapter with an +`httpx.MockTransport`; production constructs it with no arguments. Mirrors the +file-source Drive adapter's HTTP conventions. +""" + +import json +import logging +import os +from typing import Any, Dict, List, Optional + +import httpx + +from apis.shared.oauth.models import OAuthProviderType + +from apis.app_api.export_targets.adapter import ( + ExportTargetAdapter, + ExportTargetMetadata, +) +from apis.app_api.export_targets.models import ( + CreatedFile, + ExportDestination, + ExportFormat, + ExportTargetAuthError, + ExportTargetError, + ExportTargetNotFoundError, +) + +logger = logging.getLogger(__name__) + +DRIVE_API_BASE = "https://www.googleapis.com/drive/v3" +DRIVE_UPLOAD_BASE = "https://www.googleapis.com/upload/drive/v3" +DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file" + +_FOLDER_MIME = "application/vnd.google-apps.folder" +_GOOGLE_DOC_MIME = "application/vnd.google-apps.document" + +# Single app-owned folder exports land in when no destination is chosen. +# Overridable per-deployment; the default is intentionally generic. +_DEFAULT_FOLDER_ENV = "EXPORT_DRIVE_FOLDER_NAME" +_DEFAULT_FOLDER_NAME = "AI Conversations" + +# Fixed multipart boundary — the body never contains it, and a constant keeps +# uploads deterministic (and easy to assert in tests). +_MULTIPART_BOUNDARY = "agentcore-export-boundary" + +_RETURN_FIELDS = "id,name,webViewLink" +_TIMEOUT = httpx.Timeout(30.0) + + +def _escape_query_value(value: str) -> str: + """Escape a value for safe interpolation into a Drive `q` parameter.""" + return value.replace("\\", "\\\\").replace("'", "\\'") + + +class GoogleDriveExportAdapter(ExportTargetAdapter): + """Drive API v3 export adapter.""" + + def __init__(self, transport: Optional[httpx.AsyncBaseTransport] = None) -> None: + self._transport = transport + + @property + def metadata(self) -> ExportTargetMetadata: + return ExportTargetMetadata( + key="google-drive", + display_name="Google Drive", + icon="google-drive", + compatible_provider_types=(OAuthProviderType.GOOGLE,), + required_scopes=(DRIVE_FILE_SCOPE,), + # PDF is intentionally omitted until the render step can produce it + # (needs a renderer dependency we don't ship yet). + supported_formats=(ExportFormat.GOOGLE_DOC, ExportFormat.MARKDOWN), + ) + + async def list_destinations(self, access_token: str) -> List[ExportDestination]: + # `drive.file` cannot enumerate the user's folders, so the real folder + # picker is driven by the combined-scope connector's file-source + # `browse` (see the spec). This returns just the implicit roots. + roots = [ExportDestination(id="root", name="My Drive")] + try: + data = await self._get_json( + access_token, + "/drives", + params={"pageSize": 100, "fields": "drives(id,name)"}, + ) + for drive in data.get("drives", []): + roots.append(ExportDestination(id=drive["id"], name=drive["name"])) + except ExportTargetError as err: + logger.info("Skipping shared drives for export destinations: %s", err) + return roots + + async def create_document( + self, + access_token: str, + *, + content: bytes, + name: str, + source_mime_type: str, + target_format: ExportFormat, + parent_id: Optional[str] = None, + ) -> CreatedFile: + if target_format == ExportFormat.GOOGLE_DOC: + file_mime = _GOOGLE_DOC_MIME + elif target_format == ExportFormat.MARKDOWN: + file_mime = source_mime_type or "text/markdown" + else: + raise ExportTargetError( + f"Google Drive export does not support format '{target_format}'" + ) + + if parent_id is None: + parent_id = await self._ensure_default_folder(access_token) + + metadata: Dict[str, Any] = { + "name": name, + "mimeType": file_mime, + "parents": [parent_id], + } + body, content_type = _multipart_related(metadata, content, source_mime_type) + + data = await self._request_json( + access_token, + "POST", + f"{DRIVE_UPLOAD_BASE}/files", + params={ + "uploadType": "multipart", + "supportsAllDrives": "true", + "fields": _RETURN_FIELDS, + }, + content=body, + content_type=content_type, + ) + return CreatedFile( + file_id=data.get("id", ""), + name=data.get("name", name), + web_view_link=data.get("webViewLink"), + ) + + # ── internals ─────────────────────────────────────────────────────────── + + async def _ensure_default_folder(self, access_token: str) -> str: + """Return the id of the app's export folder, creating it if needed.""" + folder_name = os.environ.get(_DEFAULT_FOLDER_ENV, _DEFAULT_FOLDER_NAME) + escaped = _escape_query_value(folder_name) + data = await self._get_json( + access_token, + "/files", + params={ + "q": ( + f"name = '{escaped}' and mimeType = '{_FOLDER_MIME}' " + "and trashed = false" + ), + "spaces": "drive", + "fields": "files(id,name)", + "pageSize": 1, + }, + ) + files = data.get("files", []) + if files: + return files[0]["id"] + + created = await self._request_json( + access_token, + "POST", + f"{DRIVE_API_BASE}/files", + params={"fields": "id"}, + content=json.dumps( + {"name": folder_name, "mimeType": _FOLDER_MIME} + ).encode("utf-8"), + content_type="application/json; charset=UTF-8", + ) + return created["id"] + + @staticmethod + def _auth_headers(access_token: str) -> Dict[str, str]: + return {"Authorization": f"Bearer {access_token}"} + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + if response.is_success: + return + status = response.status_code + snippet = response.text[:300] + if status in (401, 403): + raise ExportTargetAuthError( + f"Google Drive rejected the request ({status}): {snippet}" + ) + if status == 404: + raise ExportTargetNotFoundError( + f"Google Drive resource not found: {snippet}" + ) + raise ExportTargetError(f"Google Drive request failed ({status}): {snippet}") + + async def _get_json( + self, access_token: str, path: str, params: Dict[str, Any] + ) -> Dict[str, Any]: + async with httpx.AsyncClient( + transport=self._transport, timeout=_TIMEOUT + ) as client: + try: + response = await client.get( + f"{DRIVE_API_BASE}{path}", + params=params, + headers=self._auth_headers(access_token), + ) + except httpx.HTTPError as err: + raise ExportTargetError(f"Google Drive request error: {err}") from err + self._raise_for_status(response) + data: Dict[str, Any] = response.json() + return data + + async def _request_json( + self, + access_token: str, + method: str, + url: str, + *, + params: Dict[str, Any], + content: bytes, + content_type: str, + ) -> Dict[str, Any]: + headers = self._auth_headers(access_token) + headers["Content-Type"] = content_type + async with httpx.AsyncClient( + transport=self._transport, timeout=_TIMEOUT + ) as client: + try: + response = await client.request( + method, url, params=params, headers=headers, content=content + ) + except httpx.HTTPError as err: + raise ExportTargetError(f"Google Drive upload error: {err}") from err + self._raise_for_status(response) + data: Dict[str, Any] = response.json() + return data + + +def _multipart_related( + metadata: Dict[str, Any], media: bytes, media_mime: str +) -> tuple[bytes, str]: + """Build a Drive `multipart/related` upload body (metadata + media).""" + boundary = _MULTIPART_BOUNDARY + preamble = ( + f"--{boundary}\r\n" + "Content-Type: application/json; charset=UTF-8\r\n\r\n" + f"{json.dumps(metadata)}\r\n" + f"--{boundary}\r\n" + f"Content-Type: {media_mime}\r\n\r\n" + ).encode("utf-8") + closing = f"\r\n--{boundary}--\r\n".encode("utf-8") + body = preamble + media + closing + return body, f"multipart/related; boundary={boundary}" diff --git a/backend/src/apis/app_api/export_targets/models.py b/backend/src/apis/app_api/export_targets/models.py new file mode 100644 index 00000000..ca2c7027 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/models.py @@ -0,0 +1,90 @@ +"""Normalized export-target domain models. + +An export-target adapter translates a rendered document into a provider's +"create a file" API (Google Drive, etc.) behind a provider-agnostic contract, +so the rest of the system can offer a single generic "Save to…" action +regardless of which destination the user picks. + +Mirrors `file_sources.models` for the write direction. Kept self-contained +(no import from `file_sources`) so the two capabilities stay decoupled even +when a single connector is mapped to both. +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ExportFormat(str, Enum): + """The document format an export produces. + + `GOOGLE_DOC` is a provider-native document the destination converts an + uploaded HTML body into (so Markdown formatting maps to real styling). + `MARKDOWN` and `PDF` are plain files uploaded as-is. + """ + + GOOGLE_DOC = "google_doc" + MARKDOWN = "markdown" + PDF = "pdf" + + +class ExportInclude(BaseModel): + """Which conversation elements to include in an exported transcript. + + Surfaced as a checkbox group in the SPA's "Save to…" dialog. The two + message flags are always on (the transcript itself); they exist so the + contract is explicit and a future "redacted" mode has a place to live. + Defaults match the dialog's default selection so the common case needs + no body. + """ + + model_config = ConfigDict(populate_by_name=True) + + user_messages: bool = Field(True, alias="userMessages") + assistant_messages: bool = Field(True, alias="assistantMessages") + tool_calls: bool = Field(True, alias="toolCalls") + images: bool = Field(True) + citations: bool = Field(True) + reasoning: bool = Field(False) + timestamps: bool = Field(False) + + +class ExportDestination(BaseModel): + """A top-level write location a destination exposes (e.g. My Drive). + + The optional folder picker lists these, then writes into the chosen one. + Deliberately the same minimal shape as `file_sources.SourceRoot` without + sharing the type — the two capabilities evolve independently. + """ + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(..., description="Provider-side opaque identifier") + name: str = Field(..., description="Display name") + + +@dataclass +class CreatedFile: + """The result of creating a document at the destination. + + `web_view_link` is surfaced to the SPA as an "Open in " + affordance; it is None for providers that don't return a viewer URL. + """ + + file_id: str + name: str + web_view_link: Optional[str] = None + + +class ExportTargetError(Exception): + """Base error raised by an export-target adapter when a provider call fails.""" + + +class ExportTargetAuthError(ExportTargetError): + """The access token was rejected or lacks the required scopes (401/403).""" + + +class ExportTargetNotFoundError(ExportTargetError): + """The requested destination folder does not exist (404).""" diff --git a/backend/src/apis/app_api/export_targets/registry.py b/backend/src/apis/app_api/export_targets/registry.py new file mode 100644 index 00000000..009d43f5 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/registry.py @@ -0,0 +1,69 @@ +"""Export-target adapter registry. + +The registry is the "what the codebase can do" boundary: it is populated +purely by adapter code shipped in a release, never by config or admin action. +An admin maps a connector to one of these registered adapters; the registry +itself is immutable at runtime. + +Mirror of `file_sources.registry`. The first concrete adapter +(`GoogleDriveExportAdapter`) lands in the next PR — until then this registry +is intentionally empty: the contract, validation, and admin surface ship +first so the data model is in place. +""" + +import logging +from typing import Dict, List, Optional + +from apis.shared.oauth.models import OAuthProviderType + +from apis.app_api.export_targets.adapter import ExportTargetAdapter + +logger = logging.getLogger(__name__) + + +class ExportTargetRegistry: + """An in-memory map of adapter key -> adapter instance.""" + + def __init__(self) -> None: + self._adapters: Dict[str, ExportTargetAdapter] = {} + + def register(self, adapter: ExportTargetAdapter) -> None: + """Register an adapter. Raises on a duplicate key.""" + key = adapter.metadata.key + if key in self._adapters: + raise ValueError(f"Duplicate export-target adapter key: {key}") + self._adapters[key] = adapter + logger.info("Registered export-target adapter: %s", key) + + def get(self, key: str) -> Optional[ExportTargetAdapter]: + """Return the adapter for `key`, or None if no such adapter is shipped.""" + return self._adapters.get(key) + + def all(self) -> List[ExportTargetAdapter]: + """Return every registered adapter.""" + return list(self._adapters.values()) + + def adapters_for_provider_type( + self, provider_type: OAuthProviderType + ) -> List[ExportTargetAdapter]: + """Return adapters that may be mapped to a connector of this type.""" + return [ + a + for a in self._adapters.values() + if provider_type in a.metadata.compatible_provider_types + ] + + +def _build_default_registry() -> ExportTargetRegistry: + """Construct the registry with every export-target adapter in this release.""" + from apis.app_api.export_targets.adapters.google_drive import ( + GoogleDriveExportAdapter, + ) + + reg = ExportTargetRegistry() + reg.register(GoogleDriveExportAdapter()) + return reg + + +# Process-wide singleton, populated at import time. +registry = _build_default_registry() diff --git a/backend/src/apis/app_api/export_targets/render.py b/backend/src/apis/app_api/export_targets/render.py new file mode 100644 index 00000000..cf59ca8c --- /dev/null +++ b/backend/src/apis/app_api/export_targets/render.py @@ -0,0 +1,235 @@ +"""Render a conversation transcript into an exportable document. + +Pure formatting: takes already-fetched messages and produces bytes plus a +MIME type for an export-target adapter to upload. Paging a session into this +list (calling `get_messages` until the cursor is exhausted) is the caller's +job — keeping this module free of I/O so it is trivially unit-testable. + +Markdown is the intermediate representation: the `MARKDOWN` format emits it +directly, and the `GOOGLE_DOC` format converts it to HTML (which Drive's +import turns into a native Doc, so Markdown structure maps to real styling). +""" + +from __future__ import annotations + +import base64 +import html as html_lib +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence + +from markdown_it import MarkdownIt + +from apis.shared.sessions.models import Citation, MessageContent, MessageResponse + +from apis.app_api.export_targets.models import ExportFormat, ExportInclude + +_ROLE_LABELS = {"user": "User", "assistant": "Assistant", "system": "System"} + +_DEFAULT_TITLE = "Conversation" + + +@dataclass +class RenderedDocument: + """Bytes to upload plus how to upload them. + + `mime_type` is the *source* MIME of `content` (text/markdown or text/html); + the destination decides what to convert it into. `suggested_name` is the + document title (Google Doc) or filename stem (Markdown file). + """ + + content: bytes + mime_type: str + suggested_name: str + + +def render_transcript( + title: str, + messages: Sequence[MessageResponse], + fmt: ExportFormat, + include: Optional[ExportInclude] = None, +) -> RenderedDocument: + """Render `messages` into a document of the requested format. + + Raises `ValueError` for a format this renderer cannot produce (e.g. PDF, + which needs a renderer dependency we don't ship yet — Drive's export + adapter deliberately doesn't advertise it). + """ + include = include or ExportInclude() + clean_title = (title or "").strip() or _DEFAULT_TITLE + markdown = _to_markdown(clean_title, messages, include) + + if fmt == ExportFormat.MARKDOWN: + return RenderedDocument( + content=markdown.encode("utf-8"), + mime_type="text/markdown", + suggested_name=f"{clean_title}.md", + ) + if fmt == ExportFormat.GOOGLE_DOC: + return RenderedDocument( + content=_markdown_to_html(clean_title, markdown).encode("utf-8"), + mime_type="text/html", + suggested_name=clean_title, + ) + raise ValueError(f"Unsupported export format for transcript render: {fmt}") + + +# ── markdown assembly ────────────────────────────────────────────────────── + + +def _to_markdown( + title: str, messages: Sequence[MessageResponse], include: ExportInclude +) -> str: + lines: List[str] = [f"# {title}", ""] + for msg in messages: + if msg.role == "user" and not include.user_messages: + continue + if msg.role == "assistant" and not include.assistant_messages: + continue + + heading = _ROLE_LABELS.get(msg.role, msg.role.title()) + if include.timestamps and msg.created_at: + heading = f"{heading} · {msg.created_at}" + lines.append(f"## {heading}") + lines.append("") + lines.extend(_render_blocks(msg.content, include)) + if include.citations and msg.citations: + lines.extend(_render_citations(msg.citations)) + + # Collapse to a single trailing newline. + return "\n".join(lines).rstrip() + "\n" + + +def _render_blocks( + content: Sequence[MessageContent], include: ExportInclude +) -> List[str]: + out: List[str] = [] + for block in content: + btype = block.type + if btype == "text" and block.text: + out.append(block.text.strip()) + out.append("") + elif btype == "reasoningContent" and include.reasoning: + out.extend(_render_reasoning(block.reasoning_content)) + elif btype == "toolUse" and include.tool_calls: + out.extend(_render_tool_use(block.tool_use)) + elif btype == "toolResult" and include.tool_calls: + out.extend(_render_tool_result(block.tool_result)) + elif btype == "image" and include.images: + out.extend(_render_image(block.image)) + elif btype == "document": + # Raw document blobs are never inlined (size + fidelity); always + # a placeholder so the reader knows something was attached. + name = (block.document or {}).get("name") if block.document else None + out.append(f"_[attached document: {name or 'document'}]_") + out.append("") + return out + + +def _render_tool_use(tool_use: Optional[Dict[str, Any]]) -> List[str]: + if not tool_use: + return [] + name = tool_use.get("name", "tool") + out = [f"**🛠 Tool call: `{name}`**", ""] + tool_input = tool_use.get("input") + if tool_input not in (None, {}, ""): + out += ["```json", _safe_json(tool_input), "```", ""] + return out + + +def _render_tool_result(tool_result: Optional[Dict[str, Any]]) -> List[str]: + if not tool_result: + return [] + status = tool_result.get("status") + out = [f"**Tool result**{f' ({status})' if status else ''}", ""] + text = _tool_result_text(tool_result.get("content")) + if text: + out += ["```", text, "```", ""] + return out + + +def _tool_result_text(content: Any) -> str: + if not content: + return "" + if not isinstance(content, list): + return str(content) + parts: List[str] = [] + for item in content: + if not isinstance(item, dict): + parts.append(str(item)) + elif item.get("text") is not None: + parts.append(str(item["text"])) + elif "json" in item: + parts.append(_safe_json(item["json"])) + else: + parts.append(_safe_json(item)) + return "\n".join(p for p in parts if p) + + +def _render_image(image: Optional[Dict[str, Any]]) -> List[str]: + if not image: + return [] + fmt = image.get("format", "png") + data = (image.get("source") or {}).get("bytes") + if isinstance(data, (bytes, bytearray)): + data = base64.b64encode(bytes(data)).decode("ascii") + if not data: + return ["_[image]_", ""] + return [f"![image](data:image/{fmt};base64,{data})", ""] + + +def _render_reasoning(reasoning: Optional[Dict[str, Any]]) -> List[str]: + text = _reasoning_text(reasoning) + if not text: + return [] + out = ["> **Reasoning**", ">"] + out += [f"> {line}" for line in text.splitlines()] + out.append("") + return out + + +def _reasoning_text(reasoning: Optional[Dict[str, Any]]) -> str: + if not reasoning: + return "" + reasoning_text = reasoning.get("reasoningText") + if isinstance(reasoning_text, dict): + return str(reasoning_text.get("text") or "") + if isinstance(reasoning_text, str): + return reasoning_text + return str(reasoning.get("text") or "") + + +def _render_citations(citations: Sequence[Citation]) -> List[str]: + out = ["**Sources:**", ""] + out += [f"- {c.file_name}" for c in citations] + out.append("") + return out + + +def _safe_json(value: Any) -> str: + try: + return json.dumps(value, indent=2, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(value) + + +# ── markdown -> html ─────────────────────────────────────────────────────── + + +def _markdown_to_html(title: str, markdown: str) -> str: + """Convert Markdown to a standalone HTML document. + + Uses the CommonMark preset with `html` forced off — the preset otherwise + allows raw HTML passthrough (per the CommonMark spec), so we override it so + HTML in message text is escaped, not injected into the uploaded doc — plus + the table rule so Markdown tables become real Docs tables on import. + """ + parser = MarkdownIt("commonmark", {"html": False}).enable("table") + body = parser.render(markdown) + safe_title = html_lib.escape(title) + return ( + "\n" + '' + f"{safe_title}\n" + f"\n{body}\n" + ) diff --git a/backend/src/apis/app_api/export_targets/routes.py b/backend/src/apis/app_api/export_targets/routes.py new file mode 100644 index 00000000..dd139b94 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/routes.py @@ -0,0 +1,373 @@ +"""User-facing export-target endpoints: catalog + save-a-conversation. + +A connector becomes an *export target* only once an admin maps it to an +export-target adapter (the write-direction mirror of file sources). These +endpoints let a signed-in user discover which of their connectors can receive +a conversation and push a full transcript out to one ("Save this chat to +Google Drive"). + +`GET /export-targets` is the catalog the SPA's "Save to…" dialog reads to +populate its connector picker (and, per connector, which output formats the +destination accepts). `POST /sessions/{id}/export` renders the conversation +and creates the document via the resolved adapter. + +Like the connector and file-source routes, these live on the app API: the +AgentCore Runtime that fronts the inference API only proxies `/invocations` +and `/ping`, so custom paths are unreachable there. The app API can mint +per-user OAuth tokens via the workload identity, which is exactly what the +export write needs. +""" + +import asyncio +import logging +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict, Field + +from apis.shared.auth import User, get_current_user_from_session +from apis.shared.oauth.agentcore_identity import ( + CallbackUrlUnavailableError, + WorkloadTokenUnavailableError, +) +from apis.shared.oauth.disconnect_repository import ( + OAuthDisconnectRepository, + get_disconnect_repository, +) +from apis.shared.oauth.models import OAuthProvider +from apis.shared.oauth.provider_repository import ( + OAuthProviderRepository, + get_provider_repository, +) +from apis.shared.rbac.service import AppRoleService, get_app_role_service +from apis.shared.sessions.messages import get_messages +from apis.shared.sessions.metadata import add_export_receipt, get_session_metadata +from apis.shared.sessions.models import ExportReceipt, MessageResponse + +from apis.app_api.export_targets.models import ( + ExportFormat, + ExportInclude, + ExportTargetError, +) +from apis.app_api.export_targets.registry import registry +from apis.app_api.file_sources.registry import registry as file_source_registry +from apis.app_api.export_targets.render import render_transcript +from apis.app_api.export_targets.service import ( + connector_visible_to_user, + http_error_for_export_target_error, + require_export_target_token, + resolve_export_target, + resolve_export_target_token, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["export-targets"]) + +# Page the transcript out in chunks rather than one unbounded read. The cap is +# a runaway guard, not a product limit; if a conversation is somehow longer we +# log and export what we have rather than silently truncating without a trace. +_EXPORT_PAGE_SIZE = 200 +_MAX_EXPORT_PAGES = 100 + + +# --------------------------------------------------------------------------- +# Catalog +# --------------------------------------------------------------------------- + + +class ExportTargetConnector(BaseModel): + """A connector the current user can save a conversation to.""" + + model_config = ConfigDict(populate_by_name=True) + + provider_id: str = Field(..., alias="providerId") + display_name: str = Field(..., alias="displayName") + icon_name: str = Field(..., alias="iconName") + icon_data: Optional[str] = Field(None, alias="iconData") + # True when AgentCore's vault holds a usable token for this user — the SPA + # can save straight away. False means it must run the consent flow first. + connected: bool + # The output formats this destination accepts, so the dialog's format + # picker offers only what the adapter can actually produce. + supported_formats: List[str] = Field(..., alias="supportedFormats") + # True when this connector is also mapped as a file source, so the SPA can + # reuse the import browse dialog to pick a destination folder. Only the + # combined-scope Drive connector (drive.readonly + drive.file) qualifies — + # `drive.file` alone cannot list folders. False means the export lands in + # the adapter's default app folder and the SPA hides the folder picker. + browsable: bool + + +class ExportTargetListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + export_targets: List[ExportTargetConnector] = Field(..., alias="exportTargets") + + +# --------------------------------------------------------------------------- +# Export request / response +# --------------------------------------------------------------------------- + + +class ExportRequest(BaseModel): + """Body for `POST /sessions/{id}/export`.""" + + model_config = ConfigDict(populate_by_name=True) + + connector_id: str = Field(..., alias="connectorId", description="Connector to save to") + format: ExportFormat = Field( + ExportFormat.GOOGLE_DOC, description="Output format; defaults to a native Google Doc" + ) + parent_id: Optional[str] = Field( + None, + alias="parentId", + description="Destination folder id (v2 picker); omit to use the app's default folder", + ) + include: Optional[ExportInclude] = Field( + None, description="Which conversation elements to include; omit for defaults" + ) + + +class ExportResponse(BaseModel): + """Result of a successful export.""" + + model_config = ConfigDict(populate_by_name=True) + + file_id: str = Field(..., alias="fileId") + name: str + web_view_link: Optional[str] = Field(None, alias="webViewLink") + # The persisted receipt, so the SPA can update its local session state + # without re-fetching metadata. + receipt: ExportReceipt + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _is_connected( + provider: OAuthProvider, + user_id: str, + disconnect_repo: OAuthDisconnectRepository, +) -> bool: + """Best-effort check of whether the user has a usable token. + + Mirrors the file-source catalog: a prior disconnect wins over a still-valid + vault entry, and workload/callback misconfiguration is treated as "not + connected" rather than failing the whole catalog — the user gets the + actionable 503 when they try to save. + """ + if await disconnect_repo.is_disconnected(user_id, provider.provider_id): + return False + try: + result = await resolve_export_target_token(provider, user_id) + except (WorkloadTokenUnavailableError, CallbackUrlUnavailableError) as err: + logger.warning( + "Export-target connectivity check failed for %s: %s", + provider.provider_id, + err, + ) + return False + return not result.requires_consent + + +def _is_browsable(provider: OAuthProvider) -> bool: + """True when the connector can also back the import browse dialog. + + The destination folder picker reuses the file-source `roots`/`browse` + endpoints, which only resolve when the connector is mapped to a shipped + file-source adapter (the combined-scope Drive connector). An export-only + connector has no folder picker; its exports land in the app folder. + """ + adapter_id = provider.file_source_adapter_id + return bool(adapter_id) and file_source_registry.get(adapter_id) is not None + + +async def _collect_transcript(session_id: str, user_id: str) -> List[MessageResponse]: + """Page the whole conversation into a single chronological list. + + Pages are sequence-ordered and returned oldest-first, so concatenating + them preserves chronology. Stops at the runaway-guard page cap. + """ + messages: List[MessageResponse] = [] + next_token: Optional[str] = None + for _ in range(_MAX_EXPORT_PAGES): + page = await get_messages( + session_id=session_id, + user_id=user_id, + limit=_EXPORT_PAGE_SIZE, + next_token=next_token, + ) + messages.extend(page.messages) + next_token = page.next_token + if not next_token: + return messages + logger.warning( + "Export for session %s hit the %d-page cap; transcript may be truncated", + session_id, + _MAX_EXPORT_PAGES, + ) + return messages + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@router.get("/export-targets", response_model=ExportTargetListResponse) +async def list_export_targets( + current_user: User = Depends(get_current_user_from_session), + provider_repo: OAuthProviderRepository = Depends(get_provider_repository), + role_service: AppRoleService = Depends(get_app_role_service), + disconnect_repo: OAuthDisconnectRepository = Depends(get_disconnect_repository), +) -> ExportTargetListResponse: + """List the connectors the current user can save a conversation to. + + A connector qualifies when it is enabled, mapped to an export-target + adapter that ships in this release, and visible to the user's roles. + `connected` reflects whether the user already has a usable OAuth token, so + the SPA can decide between "Save" and "Connect"; `supportedFormats` drives + the format picker. + """ + permissions = await role_service.resolve_user_permissions(current_user) + providers = await provider_repo.list_providers(enabled_only=True) + + candidates = [] + for provider in providers: + if not provider.export_target_adapter_id: + continue + if not connector_visible_to_user(provider, permissions.app_roles): + continue + adapter = registry.get(provider.export_target_adapter_id) + if adapter is None: + # Admin mapped an adapter key that no longer ships — hide it rather + # than offer a destination that would 404 on save. + logger.warning( + "Connector %s maps to unknown export-target adapter '%s'; omitting from catalog", + provider.provider_id, + provider.export_target_adapter_id, + ) + continue + candidates.append((provider, adapter)) + + connected_flags = await asyncio.gather( + *( + _is_connected(provider, current_user.user_id, disconnect_repo) + for provider, _ in candidates + ) + ) + + return ExportTargetListResponse( + export_targets=[ + ExportTargetConnector( + provider_id=provider.provider_id, + display_name=provider.display_name, + icon_name=provider.icon_name, + icon_data=provider.icon_data, + connected=connected, + supported_formats=[ + fmt.value for fmt in adapter.metadata.supported_formats + ], + browsable=_is_browsable(provider), + ) + for (provider, adapter), connected in zip(candidates, connected_flags) + ] + ) + + +@router.post("/sessions/{session_id}/export", response_model=ExportResponse) +async def export_session( + session_id: str, + request: ExportRequest, + current_user: User = Depends(get_current_user_from_session), + provider_repo: OAuthProviderRepository = Depends(get_provider_repository), + role_service: AppRoleService = Depends(get_app_role_service), +) -> ExportResponse: + """Save a conversation transcript to a connected app. + + Resolves the connector to its export-target adapter, renders the full + transcript in the requested format, and creates the document via the + user's own OAuth token. A 409 means the user must complete the OAuth + consent flow (the SPA reuses the connector consent popup, then retries). + """ + user_id = current_user.user_id + + # Ownership + title source. Session metadata is keyed by user, so a missing + # record means the session isn't the caller's (or doesn't exist) — 404 + # either way, matching the read path so a user can only export their own + # conversations. + metadata = await get_session_metadata(session_id, user_id) + if not metadata: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session not found: {session_id}", + ) + + provider, adapter = await resolve_export_target( + request.connector_id, current_user, provider_repo, role_service + ) + + if request.format not in adapter.metadata.supported_formats: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=( + f"'{provider.display_name}' cannot export to format " + f"'{request.format.value}'" + ), + ) + + # 409 (not connected) / 503 (no workload) raised here; the SPA's consent + # retry hooks on the 409. + access_token = await require_export_target_token(provider, user_id) + + messages = await _collect_transcript(session_id, user_id) + + try: + rendered = render_transcript( + metadata.title, messages, request.format, request.include + ) + except ValueError as err: + # The renderer can't produce this format (e.g. PDF) even though the + # adapter claims it — a configuration mismatch, surfaced as 422. + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(err) + ) + + try: + created = await adapter.create_document( + access_token, + content=rendered.content, + name=rendered.suggested_name, + source_mime_type=rendered.mime_type, + target_format=request.format, + parent_id=request.parent_id, + ) + except ExportTargetError as err: + logger.warning( + "create_document failed for connector %s: %s", request.connector_id, err + ) + raise http_error_for_export_target_error(err) + + receipt = ExportReceipt( + connector_id=provider.provider_id, + adapter_key=adapter.metadata.key, + format=request.format.value, + file_id=created.file_id, + file_name=created.name, + web_view_link=created.web_view_link, + exported_at=datetime.now(timezone.utc).isoformat(), + ) + # Best-effort: swallows its own errors so a metadata-write hiccup never + # fails an export that already succeeded. + await add_export_receipt(session_id, user_id, receipt) + + return ExportResponse( + file_id=created.file_id, + name=created.name, + web_view_link=created.web_view_link, + receipt=receipt, + ) diff --git a/backend/src/apis/app_api/export_targets/service.py b/backend/src/apis/app_api/export_targets/service.py new file mode 100644 index 00000000..ec5c0cb2 --- /dev/null +++ b/backend/src/apis/app_api/export_targets/service.py @@ -0,0 +1,187 @@ +"""Helpers shared by the export-target endpoints. + +A connector becomes an *export target* only when an admin maps it to an +export-target adapter. These helpers centralize the "resolve a connector to a +usable adapter + token" steps so the export flow stays consistent with the +connector status/consent routes — the write-side mirror of +`file_sources.service`. +""" + +import logging +from typing import List, Tuple + +from fastapi import HTTPException, status + +from apis.shared.auth import User +from apis.shared.oauth.agentcore_identity import ( + CallbackUrlUnavailableError, + TokenResult, + WorkloadTokenUnavailableError, + custom_parameters_for, + get_agentcore_identity_client, +) +from apis.shared.oauth.models import OAuthProvider +from apis.shared.oauth.provider_repository import OAuthProviderRepository +from apis.shared.rbac.service import AppRoleService + +from apis.app_api.export_targets.adapter import ExportTargetAdapter +from apis.app_api.export_targets.models import ( + ExportTargetAuthError, + ExportTargetError, + ExportTargetNotFoundError, +) +from apis.app_api.export_targets.registry import registry + +logger = logging.getLogger(__name__) + + +def connector_visible_to_user( + provider: OAuthProvider, user_role_ids: List[str] +) -> bool: + """True when an enabled connector is usable by a user with these roles. + + An empty `allowed_roles` list means unrestricted access; a non-empty list + grants access to users who share at least one AppRole id. Mirrors the + connector catalog's visibility rule. + """ + if not provider.enabled: + return False + if not provider.allowed_roles: + return True + return bool(set(provider.allowed_roles) & set(user_role_ids)) + + +async def resolve_export_target( + connector_id: str, + current_user: User, + provider_repo: OAuthProviderRepository, + role_service: AppRoleService, +) -> Tuple[OAuthProvider, ExportTargetAdapter]: + """Resolve a connector id to its provider record and export-target adapter. + + Raises `HTTPException` (404/403) when the connector is missing, disabled, + not visible to the caller, not configured as an export target, or mapped + to an adapter that is not shipped in this release. + """ + provider = await provider_repo.get_provider(connector_id) + if not provider or not provider.enabled: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Connector '{connector_id}' not found", + ) + + permissions = await role_service.resolve_user_permissions(current_user) + if not connector_visible_to_user(provider, permissions.app_roles): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have access to this connector", + ) + + if not provider.export_target_adapter_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Connector '{connector_id}' is not configured as an export target", + ) + + adapter = registry.get(provider.export_target_adapter_id) + if adapter is None: + # An admin mapped an adapter key that no longer ships in this release. + # Indistinguishable from "not an export target" to the user. + logger.error( + "Connector %s maps to unknown export-target adapter '%s'", + connector_id, + provider.export_target_adapter_id, + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Connector '{connector_id}' is not configured as an export target", + ) + return provider, adapter + + +async def resolve_export_target_token( + provider: OAuthProvider, user_id: str +) -> TokenResult: + """Fetch the user's OAuth token for an export-target connector. + + Returns a `TokenResult`: `access_token` is populated when the vault has a + usable token, `authorization_url` when the user still needs to consent. + + `custom_parameters` is built with `force_authentication=True` so it matches + the consent flow — AgentCore factors `customParameters` into whether + `get_resource_oauth2_token` short-circuits to a vaulted token (see the + file-source service for the full rationale). Pure read; `force_authentication` + stays False on `get_token_for_user` itself. + """ + identity = get_agentcore_identity_client() + return await identity.get_token_for_user( + provider_name=provider.provider_id, + scopes=provider.scopes, + user_id=user_id, + custom_parameters=custom_parameters_for( + provider.provider_type.value, + provider.custom_parameters, + force_authentication=True, + ), + ) + + +async def require_export_target_token(provider: OAuthProvider, user_id: str) -> str: + """Resolve a usable OAuth access token for an export-target connector. + + Turns the two non-token outcomes into `HTTPException`s the route layer can + return unchanged: + + - the user has not completed OAuth consent -> 409 Conflict + - AgentCore workload/callback context is unavailable -> 503 + + Returns the bare access-token string on success. + """ + try: + result = await resolve_export_target_token(provider, user_id) + except (WorkloadTokenUnavailableError, CallbackUrlUnavailableError) as err: + logger.warning( + "Export-target token resolution failed for %s: %s", + provider.provider_id, + err, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(err), + ) + + if result.requires_consent or not result.access_token: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + f"Connector '{provider.provider_id}' is not connected. " + "Complete the OAuth consent flow before saving to it." + ), + ) + return result.access_token + + +def http_error_for_export_target_error(err: ExportTargetError) -> HTTPException: + """Map an export-target adapter error onto an HTTP response. + + - `ExportTargetAuthError` -> 403 (token rejected / missing scopes) + - `ExportTargetNotFoundError` -> 404 (destination folder gone) + - any other `ExportTargetError` -> 502 (the provider call itself failed) + """ + if isinstance(err, ExportTargetAuthError): + return HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=( + "The export target rejected the request. Reconnect the " + "connector and try again." + ), + ) + if isinstance(err, ExportTargetNotFoundError): + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The destination folder no longer exists.", + ) + return HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="The export target could not be reached. Try again shortly.", + ) diff --git a/backend/src/apis/app_api/main.py b/backend/src/apis/app_api/main.py index a35cc8ee..60a95eb5 100644 --- a/backend/src/apis/app_api/main.py +++ b/backend/src/apis/app_api/main.py @@ -192,6 +192,7 @@ async def lifespan(app: FastAPI): from apis.app_api.user_settings.routes import router as user_settings_router from apis.app_api.connectors.routes import router as connectors_router from apis.app_api.file_sources.routes import router as file_sources_router +from apis.app_api.export_targets.routes import router as export_targets_router from apis.app_api.web_sources.routes import router as web_sources_router from apis.app_api.system.routes import router as system_router from apis.app_api.shares.routes import conversations_share_router, shares_router, shared_view_router @@ -221,6 +222,7 @@ async def lifespan(app: FastAPI): app.include_router(files_router) # File upload via pre-signed URLs app.include_router(connectors_router) # User-facing connector catalog + consent flows app.include_router(file_sources_router) # File-source catalog + browse/search over connectors +app.include_router(export_targets_router) # Export-target catalog + save-a-conversation to a connector app.include_router(web_sources_router) # Web-crawl ingestion: URL -> documents via BFS + S3 staging app.include_router(system_router) # System status and first-boot endpoints app.include_router(conversations_share_router) # Share conversations endpoints diff --git a/backend/src/apis/shared/oauth/models.py b/backend/src/apis/shared/oauth/models.py index 404a0612..f965bf08 100644 --- a/backend/src/apis/shared/oauth/models.py +++ b/backend/src/apis/shared/oauth/models.py @@ -128,6 +128,14 @@ class OAuthProvider: # Adapter existence / provider-type compatibility is validated in the # admin route — `apis.shared` cannot import the app_api registry. file_source_adapter_id: Optional[str] = None + # Maps this connector to an export-target adapter (e.g. "google-drive"), + # making it a destination users can save conversations to. Mirrors + # `file_source_adapter_id` but for the write direction; the value is an + # adapter key from the export-target registry. None means the connector + # is not an export target. A single connector may be both a file source + # and an export target (e.g. a combined-scope Drive connector). Validated + # in the admin route for the same reason as above. + export_target_adapter_id: Optional[str] = None created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat() + "Z") updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat() + "Z") @@ -156,6 +164,7 @@ def to_dynamo_item(self) -> Dict[str, Any]: "authorizationServerMetadata": self.authorization_server_metadata, "customParameters": self.custom_parameters, "fileSourceAdapterId": self.file_source_adapter_id, + "exportTargetAdapterId": self.export_target_adapter_id, "createdAt": self.created_at, "updatedAt": self.updated_at, } @@ -177,6 +186,7 @@ def from_dynamo_item(cls, item: Dict[str, Any]) -> "OAuthProvider": authorization_server_metadata=item.get("authorizationServerMetadata"), custom_parameters=item.get("customParameters"), file_source_adapter_id=item.get("fileSourceAdapterId"), + export_target_adapter_id=item.get("exportTargetAdapterId"), created_at=item.get("createdAt", datetime.now(timezone.utc).isoformat() + "Z"), updated_at=item.get("updatedAt", datetime.now(timezone.utc).isoformat() + "Z"), ) @@ -215,6 +225,10 @@ class OAuthProviderCreate(BaseModel): # Adapter key that makes this connector a file source. Validated against # the adapter registry in the admin route. file_source_adapter_id: Optional[str] = None + # Adapter key that makes this connector an export target (a destination + # users can save conversations to). Validated against the export-target + # registry in the admin route. + export_target_adapter_id: Optional[str] = None model_config = ConfigDict(json_schema_extra={ "example": { @@ -272,6 +286,10 @@ class OAuthProviderUpdate(BaseModel): # `""` clears the file-source mapping (the connector stops being a file # source); a populated adapter key sets it; `None` leaves it unchanged. file_source_adapter_id: Optional[str] = None + # `""` clears the export-target mapping (the connector stops being an + # export target); a populated adapter key sets it; `None` leaves it + # unchanged. + export_target_adapter_id: Optional[str] = None @model_validator(mode="after") def _validate_credential_pair(self) -> "OAuthProviderUpdate": @@ -305,6 +323,7 @@ class OAuthProviderResponse(BaseModel): authorization_server_metadata: Optional[Dict[str, Any]] = None custom_parameters: Optional[Dict[str, str]] = None file_source_adapter_id: Optional[str] = None + export_target_adapter_id: Optional[str] = None created_at: str updated_at: str @@ -325,6 +344,7 @@ def from_provider(cls, provider: OAuthProvider) -> "OAuthProviderResponse": authorization_server_metadata=provider.authorization_server_metadata, custom_parameters=provider.custom_parameters, file_source_adapter_id=provider.file_source_adapter_id, + export_target_adapter_id=provider.export_target_adapter_id, created_at=provider.created_at, updated_at=provider.updated_at, ) diff --git a/backend/src/apis/shared/oauth/provider_repository.py b/backend/src/apis/shared/oauth/provider_repository.py index ae48892d..6237fc6e 100644 --- a/backend/src/apis/shared/oauth/provider_repository.py +++ b/backend/src/apis/shared/oauth/provider_repository.py @@ -158,6 +158,10 @@ async def apply_metadata_update( # Empty string (`""`) clears the file-source mapping; a populated # adapter key sets it. `None` leaves the existing value alone. existing.file_source_adapter_id = updates.file_source_adapter_id or None + if updates.export_target_adapter_id is not None: + # Empty string (`""`) clears the export-target mapping; a populated + # adapter key sets it. `None` leaves the existing value alone. + existing.export_target_adapter_id = updates.export_target_adapter_id or None existing.updated_at = datetime.now(timezone.utc).isoformat() + "Z" self._table.put_item(Item=existing.to_dynamo_item()) diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index 29f37376..0a00d807 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -16,7 +16,7 @@ from decimal import Decimal # Relative imports from shared sessions module -from .models import MessageMetadata, PausedTurnSnapshot, PendingInterrupt, SessionMetadata, SessionPreferences +from .models import ExportReceipt, MessageMetadata, PausedTurnSnapshot, PendingInterrupt, SessionMetadata, SessionPreferences # Import preview session helper from agents.main_agent.session.preview_session_manager import is_preview_session @@ -1905,6 +1905,62 @@ async def add_pending_interrupt( logger.error("Failed to persist pending_interrupt: %s", e, exc_info=True) +async def add_export_receipt( + session_id: str, + user_id: str, + receipt: ExportReceipt, +) -> None: + """Append an export receipt to the session record. + + Called after a conversation is successfully saved out to a connected app + (e.g. Google Drive) so the SPA can restore a "Saved · Open" affordance + after a reload. Uses ``list_append`` with ``if_not_exists`` so it can run + concurrently with the full-row ``store_session_metadata`` merge and other + targeted writers without a read-modify-write window — the same idiom as + ``add_pending_interrupt``. + + Best-effort: a persistence failure is logged and swallowed. The export + itself already succeeded and the endpoint returns the receipt in its + response, so the live tab still shows the link — only reload-survival is + lost. No-op when the session metadata row is missing. + """ + sessions_metadata_table = os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME") + if not sessions_metadata_table: + logger.warning("DYNAMODB_SESSIONS_METADATA_TABLE_NAME not set; skipping export receipt persistence") + return + + try: + import boto3 + + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(sessions_metadata_table) + + existing = await _get_session_by_gsi(session_id, user_id, table) + if not existing: + logger.info("Skipping export receipt add — session %s not found", session_id) + return + + sk = existing.get("SK") + if not sk: + logger.warning("Session %s has no SK; cannot persist export receipt", session_id) + return + + new_entry = receipt.model_dump(by_alias=True, exclude_none=True) + + table.update_item( + Key={"PK": f"USER#{user_id}", "SK": sk}, + UpdateExpression="SET #er = list_append(if_not_exists(#er, :empty), :new)", + ExpressionAttributeNames={"#er": "exportReceipts"}, + ExpressionAttributeValues={":empty": [], ":new": [new_entry]}, + ) + logger.info( + "Persisted export receipt (connector=%s, file=%s) for session %s", + receipt.connector_id, receipt.file_id, session_id, + ) + except Exception as e: + logger.error("Failed to persist export receipt: %s", e, exc_info=True) + + async def remove_pending_interrupts( session_id: str, user_id: str, diff --git a/backend/src/apis/shared/sessions/models.py b/backend/src/apis/shared/sessions/models.py index 055add99..738e0384 100644 --- a/backend/src/apis/shared/sessions/models.py +++ b/backend/src/apis/shared/sessions/models.py @@ -162,6 +162,28 @@ class SessionPreferences(BaseModel): ) +class ExportReceipt(BaseModel): + """Record of a successful conversation export to a connected app. + + Persisted on the session (and appended, one per export) so the SPA can + show a "Saved to · Open" affordance that survives a page reload. + The write-direction mirror of `DocumentProvenance`: it records which + connector/adapter produced the file, the remote file's identity and + viewer link, and when the export happened. Generic across destinations — + Google Drive is the first, OneDrive/Dropbox/Box reuse it unchanged. + """ + + model_config = ConfigDict(populate_by_name=True) + + connector_id: str = Field(..., alias="connectorId", description="OAuth connector the conversation was saved to") + adapter_key: str = Field(..., alias="adapterKey", description="Export-target adapter that created the file") + format: str = Field(..., description="Export format produced (e.g. 'google_doc', 'markdown')") + file_id: str = Field(..., alias="fileId", description="Provider-side identifier of the created file") + file_name: str = Field(..., alias="fileName", description="Name the file was created with") + web_view_link: Optional[str] = Field(None, alias="webViewLink", description="Viewer URL surfaced as 'Open in '; None if the provider returns none") + exported_at: str = Field(..., alias="exportedAt", description="ISO 8601 timestamp of the export") + + class SessionMetadata(BaseModel): """Complete session metadata @@ -237,6 +259,15 @@ class SessionMetadata(BaseModel): description="Cumulative count of turns rolled into a compaction summary in this session", ) + # Receipts for conversation exports to connected apps (e.g. Google Drive), + # appended one per successful "Save to…" so the UI can show a "Saved · Open" + # affordance that survives a reload. Written race-free via add_export_receipt. + export_receipts: Optional[List[ExportReceipt]] = Field( + default=None, + alias="exportReceipts", + description="Receipts for conversation exports to connected apps, newest appended last", + ) + class UpdateSessionMetadataRequest(BaseModel): """Request body for updating session metadata""" @@ -295,6 +326,11 @@ class SessionMetadataResponse(BaseModel): alias="lastTurnContinuable", description="True when the last turn ended in a recoverable max_tokens truncation, so the client can re-show the 'Continue' affordance after a refresh", ) + export_receipts: Optional[List[ExportReceipt]] = Field( + default=None, + alias="exportReceipts", + description="Receipts for conversation exports to connected apps, newest appended last; lets the UI restore a 'Saved · Open' affordance after a reload", + ) class SessionsListResponse(BaseModel): diff --git a/backend/tests/apis/app_api/test_export_google_drive.py b/backend/tests/apis/app_api/test_export_google_drive.py new file mode 100644 index 00000000..0375b644 --- /dev/null +++ b/backend/tests/apis/app_api/test_export_google_drive.py @@ -0,0 +1,164 @@ +"""Tests for the Google Drive export-target adapter (mocked Drive API v3).""" + +from __future__ import annotations + +import json +from typing import Dict, List, Tuple + +import httpx +import pytest + +from apis.app_api.export_targets.adapters.google_drive import GoogleDriveExportAdapter +from apis.app_api.export_targets.models import ( + ExportFormat, + ExportTargetAuthError, +) + + +def _adapter(handler) -> GoogleDriveExportAdapter: + return GoogleDriveExportAdapter(transport=httpx.MockTransport(handler)) + + +def _parse_multipart(body: bytes) -> Tuple[Dict, str, bytes]: + """Split a Drive multipart/related body into (metadata, media_mime, media).""" + text = body.decode("utf-8", errors="replace") + # Parts are separated by the boundary; the metadata is JSON, the media + # follows its own Content-Type header. + meta_start = text.index("{") + meta_end = text.index("}", meta_start) + 1 + metadata = json.loads(text[meta_start:meta_end]) + media_marker = "Content-Type: " + second = text.index(media_marker, meta_end) + media_mime = text[second + len(media_marker):].split("\r\n", 1)[0] + media = text.split("\r\n\r\n", 2)[-1].rsplit("\r\n--", 1)[0] + return metadata, media_mime, media.encode("utf-8") + + +class TestCreateDocument: + @pytest.mark.asyncio + async def test_google_doc_into_existing_folder(self): + captured: Dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/drive/v3/files" and request.method == "GET": + captured["folder_query"] = request.url.params.get("q") + return httpx.Response(200, json={"files": [{"id": "folder-1", "name": "AI Conversations"}]}) + if request.url.path == "/upload/drive/v3/files": + captured["upload_body"] = request.content + captured["upload_type"] = request.url.params.get("uploadType") + return httpx.Response( + 200, + json={"id": "doc-1", "name": "My Chat", "webViewLink": "https://docs/doc-1"}, + ) + return httpx.Response(500, text=f"unexpected {request.method} {request.url}") + + result = await _adapter(handler).create_document( + "tok", + content=b"

hi

", + name="My Chat", + source_mime_type="text/html", + target_format=ExportFormat.GOOGLE_DOC, + ) + + assert result.file_id == "doc-1" + assert result.web_view_link == "https://docs/doc-1" + assert captured["upload_type"] == "multipart" + assert "AI Conversations" in captured["folder_query"] + + metadata, media_mime, media = _parse_multipart(captured["upload_body"]) + assert metadata["mimeType"] == "application/vnd.google-apps.document" + assert metadata["parents"] == ["folder-1"] + assert metadata["name"] == "My Chat" + assert media_mime == "text/html" + assert b"

hi

" in media + + @pytest.mark.asyncio + async def test_creates_folder_when_absent(self): + calls: List[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(f"{request.method} {request.url.path}") + if request.url.path == "/drive/v3/files" and request.method == "GET": + return httpx.Response(200, json={"files": []}) + if request.url.path == "/drive/v3/files" and request.method == "POST": + body = json.loads(request.content) + assert body["mimeType"] == "application/vnd.google-apps.folder" + return httpx.Response(200, json={"id": "folder-new"}) + if request.url.path == "/upload/drive/v3/files": + metadata, _, _ = _parse_multipart(request.content) + assert metadata["parents"] == ["folder-new"] + return httpx.Response(200, json={"id": "doc-2", "name": "C"}) + return httpx.Response(500, text="unexpected") + + result = await _adapter(handler).create_document( + "tok", + content=b"# c", + name="C", + source_mime_type="text/markdown", + target_format=ExportFormat.MARKDOWN, + ) + + assert result.file_id == "doc-2" + assert result.web_view_link is None + assert "POST /drive/v3/files" in calls # folder was created + + @pytest.mark.asyncio + async def test_explicit_parent_skips_folder_lookup(self): + calls: List[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(f"{request.method} {request.url.path}") + if request.url.path == "/upload/drive/v3/files": + metadata, media_mime, _ = _parse_multipart(request.content) + assert metadata["parents"] == ["given-folder"] + assert metadata["mimeType"] == "text/markdown" + assert media_mime == "text/markdown" + return httpx.Response(200, json={"id": "doc-3", "name": "C"}) + return httpx.Response(500, text="unexpected") + + result = await _adapter(handler).create_document( + "tok", + content=b"# c", + name="C", + source_mime_type="text/markdown", + target_format=ExportFormat.MARKDOWN, + parent_id="given-folder", + ) + + assert result.file_id == "doc-3" + assert calls == ["POST /upload/drive/v3/files"] # no folder search/create + + @pytest.mark.asyncio + async def test_auth_error_maps_to_export_target_auth_error(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, text="forbidden") + + with pytest.raises(ExportTargetAuthError): + await _adapter(handler).create_document( + "tok", + content=b"x", + name="C", + source_mime_type="text/html", + target_format=ExportFormat.GOOGLE_DOC, + parent_id="f1", + ) + + +class TestListDestinations: + @pytest.mark.asyncio + async def test_lists_my_drive_and_shared_drives(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"drives": [{"id": "sd1", "name": "Team"}]}) + + dests = await _adapter(handler).list_destinations("tok") + names = [d.name for d in dests] + assert "My Drive" in names + assert "Team" in names + + @pytest.mark.asyncio + async def test_shared_drive_failure_is_tolerated(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(403, text="no shared drives") + + dests = await _adapter(handler).list_destinations("tok") + assert [d.name for d in dests] == ["My Drive"] diff --git a/backend/tests/apis/app_api/test_export_render.py b/backend/tests/apis/app_api/test_export_render.py new file mode 100644 index 00000000..35a615bb --- /dev/null +++ b/backend/tests/apis/app_api/test_export_render.py @@ -0,0 +1,182 @@ +"""Tests for the conversation transcript renderer.""" + +from __future__ import annotations + +from typing import List, Optional + +import pytest + +from apis.shared.sessions.models import Citation, MessageContent, MessageResponse + +from apis.app_api.export_targets.models import ExportFormat, ExportInclude +from apis.app_api.export_targets.render import render_transcript + + +def _msg(role: str, blocks: List[MessageContent], created_at: str = "") -> MessageResponse: + return MessageResponse(id=f"m-{role}", role=role, content=blocks, createdAt=created_at) + + +def _text(role: str, text: str, created_at: str = "") -> MessageResponse: + return _msg(role, [MessageContent(type="text", text=text)], created_at) + + +class TestMarkdownStructure: + def test_title_and_role_headings(self): + msgs = [_text("user", "hi"), _text("assistant", "hello")] + out = render_transcript("My Chat", msgs, ExportFormat.MARKDOWN) + body = out.content.decode() + assert out.mime_type == "text/markdown" + assert out.suggested_name == "My Chat.md" + assert body.startswith("# My Chat") + assert "## User" in body + assert "## Assistant" in body + assert "hi" in body and "hello" in body + + def test_empty_messages_still_renders_title(self): + out = render_transcript("Empty", [], ExportFormat.MARKDOWN) + assert out.content.decode().strip() == "# Empty" + + def test_blank_title_falls_back(self): + out = render_transcript(" ", [_text("user", "x")], ExportFormat.MARKDOWN) + assert out.content.decode().startswith("# Conversation") + + +class TestIncludeFlags: + def test_tool_calls_included_by_default_and_omittable(self): + msgs = [ + _msg( + "assistant", + [ + MessageContent(type="text", text="done"), + MessageContent(type="toolUse", toolUse={"name": "calc", "input": {"x": 1}}), + MessageContent( + type="toolResult", + toolResult={"status": "success", "content": [{"text": "2"}]}, + ), + ], + ) + ] + with_tools = render_transcript("t", msgs, ExportFormat.MARKDOWN).content.decode() + assert "Tool call: `calc`" in with_tools + assert "Tool result" in with_tools + + without = render_transcript( + "t", msgs, ExportFormat.MARKDOWN, ExportInclude(tool_calls=False) + ).content.decode() + assert "Tool call" not in without + assert "done" in without # text still present + + def test_reasoning_off_by_default_on_when_requested(self): + msgs = [ + _msg( + "assistant", + [ + MessageContent( + type="reasoningContent", + reasoningContent={"reasoningText": {"text": "let me think"}}, + ), + MessageContent(type="text", text="answer"), + ], + ) + ] + default = render_transcript("t", msgs, ExportFormat.MARKDOWN).content.decode() + assert "let me think" not in default + + on = render_transcript( + "t", msgs, ExportFormat.MARKDOWN, ExportInclude(reasoning=True) + ).content.decode() + assert "let me think" in on + assert "Reasoning" in on + + def test_timestamps_appended_to_heading_when_requested(self): + msgs = [_text("user", "hi", created_at="2026-06-25T12:00:00Z")] + off = render_transcript("t", msgs, ExportFormat.MARKDOWN).content.decode() + assert "2026-06-25" not in off + on = render_transcript( + "t", msgs, ExportFormat.MARKDOWN, ExportInclude(timestamps=True) + ).content.decode() + assert "2026-06-25T12:00:00Z" in on + + def test_user_messages_can_be_excluded(self): + msgs = [_text("user", "secret prompt"), _text("assistant", "reply")] + out = render_transcript( + "t", msgs, ExportFormat.MARKDOWN, ExportInclude(user_messages=False) + ).content.decode() + assert "secret prompt" not in out + assert "reply" in out + + def test_images_embedded_as_data_uri_and_omittable(self): + msgs = [ + _msg( + "user", + [MessageContent(type="image", image={"format": "png", "source": {"bytes": "QUJD"}})], + ) + ] + on = render_transcript("t", msgs, ExportFormat.MARKDOWN).content.decode() + assert "data:image/png;base64,QUJD" in on + off = render_transcript( + "t", msgs, ExportFormat.MARKDOWN, ExportInclude(images=False) + ).content.decode() + assert "data:image" not in off + + def test_document_blocks_become_placeholder(self): + msgs = [ + _msg("user", [MessageContent(type="document", document={"name": "spec.pdf"})]) + ] + out = render_transcript("t", msgs, ExportFormat.MARKDOWN).content.decode() + assert "[attached document: spec.pdf]" in out + + def test_citations_rendered_when_present(self): + msg = MessageResponse( + id="m1", + role="assistant", + content=[MessageContent(type="text", text="answer")], + createdAt="", + citations=[ + Citation(assistantId="a1", documentId="d1", fileName="handbook.pdf", text="x") + ], + ) + out = render_transcript("t", [msg], ExportFormat.MARKDOWN).content.decode() + assert "Sources:" in out and "handbook.pdf" in out + + off = render_transcript( + "t", [msg], ExportFormat.MARKDOWN, ExportInclude(citations=False) + ).content.decode() + assert "handbook.pdf" not in off + + +class TestGoogleDocHtml: + def test_markdown_maps_to_html_styling(self): + msgs = [_text("assistant", "# Heading\n\n**bold** and a list:\n\n- one\n- two")] + out = render_transcript("Doc", msgs, ExportFormat.GOOGLE_DOC) + html = out.content.decode() + assert out.mime_type == "text/html" + assert out.suggested_name == "Doc" + assert "bold" in html + assert "
  • one
  • " in html + assert "Doc" in html + + def test_table_rule_enabled(self): + table = "| a | b |\n| - | - |\n| 1 | 2 |" + out = render_transcript("t", [_text("assistant", table)], ExportFormat.GOOGLE_DOC) + assert "" in out.content.decode() + + def test_raw_html_in_message_is_escaped_not_injected(self): + out = render_transcript( + "t", [_text("user", "")], ExportFormat.GOOGLE_DOC + ) + html = out.content.decode() + assert "" not in html + assert "<script>" in html + + def test_html_title_is_escaped(self): + out = render_transcript( + "x", [_text("user", "hi")], ExportFormat.GOOGLE_DOC + ) + assert "<b>x</b>" in out.content.decode() + + +class TestUnsupportedFormat: + def test_pdf_raises(self): + with pytest.raises(ValueError): + render_transcript("t", [_text("user", "x")], ExportFormat.PDF) diff --git a/backend/tests/apis/app_api/test_export_routes.py b/backend/tests/apis/app_api/test_export_routes.py new file mode 100644 index 00000000..18e3bb1e --- /dev/null +++ b/backend/tests/apis/app_api/test_export_routes.py @@ -0,0 +1,486 @@ +"""Route-level tests for the user-facing export-target endpoints. + +Covers the catalog (`GET /export-targets`) and the save action +(`POST /sessions/{id}/export`). External boundaries — the provider repository, +role service, disconnect repository, AgentCore identity client, the adapter +registry, transcript retrieval, and receipt persistence — are stubbed; we test +our gating, ordering, error mapping, and response shape, not the downstream +provider calls. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import List, Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.app_api.export_targets import routes, service +from apis.app_api.export_targets.adapter import ( + ExportTargetAdapter, + ExportTargetMetadata, +) +from apis.app_api.export_targets.models import ( + CreatedFile, + ExportFormat, + ExportTargetAuthError, + ExportTargetError, + ExportTargetNotFoundError, +) +from apis.app_api.export_targets.registry import ExportTargetRegistry +from apis.shared.auth.models import User +from apis.shared.oauth.agentcore_identity import ( + TokenResult, + WorkloadTokenUnavailableError, +) +from apis.shared.oauth.disconnect_repository import get_disconnect_repository +from apis.shared.oauth.models import OAuthProvider, OAuthProviderType +from apis.shared.oauth.provider_repository import get_provider_repository +from apis.shared.rbac.models import UserEffectivePermissions +from apis.shared.rbac.service import get_app_role_service +from apis.shared.sessions.models import ( + MessageContent, + MessageResponse, + MessagesListResponse, + SessionMetadata, +) + +ADAPTER_KEY = "stub-drive" + + +class _StubExportAdapter(ExportTargetAdapter): + """Adapter that records create_document calls or raises on demand.""" + + def __init__(self) -> None: + self.supported_formats = (ExportFormat.GOOGLE_DOC, ExportFormat.MARKDOWN) + self.created_with: Optional[SimpleNamespace] = None + self.raise_error: Optional[ExportTargetError] = None + + @property + def metadata(self) -> ExportTargetMetadata: + return ExportTargetMetadata( + key=ADAPTER_KEY, + display_name="Stub Drive", + icon="stub", + compatible_provider_types=(OAuthProviderType.GOOGLE,), + required_scopes=(), + supported_formats=self.supported_formats, + ) + + async def list_destinations(self, access_token): # type: ignore[no-untyped-def] + return [] + + async def create_document( # type: ignore[no-untyped-def] + self, + access_token, + *, + content, + name, + source_mime_type, + target_format, + parent_id=None, + ): + if self.raise_error: + raise self.raise_error + self.created_with = SimpleNamespace( + access_token=access_token, + content=content, + name=name, + source_mime_type=source_mime_type, + target_format=target_format, + parent_id=parent_id, + ) + return CreatedFile( + file_id="file-123", + name=name, + web_view_link="https://drive.example/file-123", + ) + + +def _make_user(user_id: str = "alice") -> User: + return User( + user_id=user_id, + email=f"{user_id}@example.com", + name=user_id.capitalize(), + roles=[], + raw_token="test-token", + ) + + +def _make_provider( + provider_id: str = "gdrive", + *, + enabled: bool = True, + allowed_roles: Optional[list] = None, + export_target_adapter_id: Optional[str] = ADAPTER_KEY, + file_source_adapter_id: Optional[str] = None, +) -> OAuthProvider: + now = datetime.now(timezone.utc).isoformat() + "Z" + return OAuthProvider( + provider_id=provider_id, + display_name="Google Drive", + provider_type=OAuthProviderType.GOOGLE, + scopes=["https://www.googleapis.com/auth/drive.file"], + allowed_roles=allowed_roles or [], + enabled=enabled, + custom_parameters=None, + created_at=now, + updated_at=now, + export_target_adapter_id=export_target_adapter_id, + file_source_adapter_id=file_source_adapter_id, + ) + + +def _make_permissions( + user_id: str = "alice", *, roles: Optional[list] = None +) -> UserEffectivePermissions: + return UserEffectivePermissions( + user_id=user_id, + app_roles=roles or [], + tools=[], + models=[], + quota_tier=None, + resolved_at=datetime.now(timezone.utc).isoformat() + "Z", + ) + + +def _make_metadata(title: str = "My Chat", user_id: str = "alice") -> SessionMetadata: + now = datetime.now(timezone.utc).isoformat() + "Z" + return SessionMetadata( + session_id="sess-1", + user_id=user_id, + title=title, + status="active", + created_at=now, + last_message_at=now, + message_count=2, + ) + + +def _msg(mid: str, role: str, text: str) -> MessageResponse: + return MessageResponse( + id=mid, + role=role, + content=[MessageContent(type="text", text=text)], + created_at="2026-06-25T00:00:00Z", + ) + + +class _FakeDisconnectRepo: + def __init__(self) -> None: + self.disconnected: set = set() + + async def is_disconnected(self, user_id: str, provider_id: str) -> bool: + return (user_id, provider_id) in self.disconnected + + +@pytest.fixture +def app_with_deps(monkeypatch): + """Mount the router and stub every external boundary.""" + + def _build( + user_id: str = "alice", + *, + providers: Optional[List[OAuthProvider]] = None, + permissions: Optional[UserEffectivePermissions] = None, + identity_result: Optional[TokenResult] = None, + identity_raises: Optional[Exception] = None, + adapter: Optional[ExportTargetAdapter] = None, + metadata: Optional[SessionMetadata] = _make_metadata(), + message_pages: Optional[List[List[MessageResponse]]] = None, + disconnect_repo: Optional[_FakeDisconnectRepo] = None, + ): + providers = [_make_provider()] if providers is None else providers + app = FastAPI() + app.include_router(routes.router) + app.dependency_overrides[routes.get_current_user_from_session] = ( + lambda: _make_user(user_id) + ) + + by_id = {p.provider_id: p for p in providers} + repo = MagicMock() + repo.list_providers = AsyncMock(return_value=list(providers)) + repo.get_provider = AsyncMock(side_effect=lambda pid: by_id.get(pid)) + app.dependency_overrides[get_provider_repository] = lambda: repo + + role_service = MagicMock() + role_service.resolve_user_permissions = AsyncMock( + return_value=permissions or _make_permissions(user_id), + ) + app.dependency_overrides[get_app_role_service] = lambda: role_service + + disconnect_repo = disconnect_repo or _FakeDisconnectRepo() + app.dependency_overrides[get_disconnect_repository] = lambda: disconnect_repo + + identity = MagicMock() + if identity_raises is not None: + identity.get_token_for_user = AsyncMock(side_effect=identity_raises) + else: + identity.get_token_for_user = AsyncMock( + return_value=identity_result or TokenResult(access_token="vault-token"), + ) + monkeypatch.setattr(service, "get_agentcore_identity_client", lambda: identity) + + the_adapter = adapter if adapter is not None else _StubExportAdapter() + reg = ExportTargetRegistry() + reg.register(the_adapter) + # resolve_export_target reads service.registry; the catalog reads + # routes.registry — both point at the same stub. + monkeypatch.setattr(service, "registry", reg) + monkeypatch.setattr(routes, "registry", reg) + + async def fake_get_session_metadata(session_id, user_id): + return metadata + + monkeypatch.setattr(routes, "get_session_metadata", fake_get_session_metadata) + + pages = message_pages if message_pages is not None else [[_msg("m1", "user", "hi")]] + + async def fake_get_messages(session_id, user_id, limit=None, next_token=None): + idx = int(next_token) if next_token else 0 + page = pages[idx] + nxt = str(idx + 1) if idx + 1 < len(pages) else None + return MessagesListResponse(messages=page, next_token=nxt) + + monkeypatch.setattr(routes, "get_messages", fake_get_messages) + + receipts: list = [] + + async def fake_add_export_receipt(session_id, user_id, receipt): + receipts.append(receipt) + + monkeypatch.setattr(routes, "add_export_receipt", fake_add_export_receipt) + + return SimpleNamespace( + app=app, + adapter=the_adapter, + identity=identity, + receipts=receipts, + disconnect_repo=disconnect_repo, + ) + + return _build + + +# --------------------------------------------------------------------------- +# Catalog +# --------------------------------------------------------------------------- + + +class TestListExportTargets: + def test_lists_only_mapped_visible_connectors(self, app_with_deps): + ctx = app_with_deps( + providers=[ + _make_provider("gdrive"), + _make_provider("slack", export_target_adapter_id=None), + _make_provider("secret", allowed_roles=["admins"]), + ], + ) + response = TestClient(ctx.app).get("/export-targets") + + assert response.status_code == 200 + targets = response.json()["exportTargets"] + assert [t["providerId"] for t in targets] == ["gdrive"] + assert targets[0]["connected"] is True + assert targets[0]["supportedFormats"] == ["google_doc", "markdown"] + # Export-only connector (no file_source_adapter_id) → no folder picker. + assert targets[0]["browsable"] is False + + def test_browsable_true_for_combined_scope_connector(self, app_with_deps): + # A connector also mapped to a shipped file-source adapter backs the + # destination folder picker via the reused import browse endpoints. + ctx = app_with_deps( + providers=[_make_provider("gdrive", file_source_adapter_id="google-drive")], + ) + response = TestClient(ctx.app).get("/export-targets") + + assert response.status_code == 200 + targets = response.json()["exportTargets"] + assert targets[0]["browsable"] is True + + def test_browsable_false_for_unshipped_file_source_adapter(self, app_with_deps): + # An admin can map a file_source_adapter_id that no longer ships; the + # picker would 404, so the catalog reports it as not browsable. + ctx = app_with_deps( + providers=[_make_provider("gdrive", file_source_adapter_id="ghost")], + ) + response = TestClient(ctx.app).get("/export-targets") + + assert response.status_code == 200 + assert response.json()["exportTargets"][0]["browsable"] is False + + def test_includes_role_gated_connector_when_user_has_role(self, app_with_deps): + ctx = app_with_deps( + providers=[_make_provider("secret", allowed_roles=["admins"])], + permissions=_make_permissions(roles=["admins"]), + ) + response = TestClient(ctx.app).get("/export-targets") + + assert response.status_code == 200 + assert [t["providerId"] for t in response.json()["exportTargets"]] == ["secret"] + + def test_connected_false_when_consent_required(self, app_with_deps): + ctx = app_with_deps( + identity_result=TokenResult(authorization_url="https://auth.example/x"), + ) + response = TestClient(ctx.app).get("/export-targets") + + assert response.status_code == 200 + assert response.json()["exportTargets"][0]["connected"] is False + + def test_unknown_adapter_key_omitted(self, app_with_deps): + ctx = app_with_deps( + providers=[_make_provider("gdrive", export_target_adapter_id="dropbox")], + ) + response = TestClient(ctx.app).get("/export-targets") + + assert response.status_code == 200 + assert response.json()["exportTargets"] == [] + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + + +class TestExportSession: + def test_success_returns_file_and_persists_receipt(self, app_with_deps): + ctx = app_with_deps( + message_pages=[[_msg("m1", "user", "hello"), _msg("m2", "assistant", "hi there")]], + ) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + + assert response.status_code == 200 + body = response.json() + assert body["fileId"] == "file-123" + assert body["webViewLink"] == "https://drive.example/file-123" + assert body["receipt"]["connectorId"] == "gdrive" + assert body["receipt"]["adapterKey"] == ADAPTER_KEY + assert body["receipt"]["format"] == "google_doc" + + # Default format → native Google Doc → uploads HTML. + assert ctx.adapter.created_with.target_format == ExportFormat.GOOGLE_DOC + assert ctx.adapter.created_with.source_mime_type == "text/html" + assert ctx.adapter.created_with.name == "My Chat" + # Receipt persisted exactly once. + assert len(ctx.receipts) == 1 + assert ctx.receipts[0].file_id == "file-123" + + def test_pages_full_transcript(self, app_with_deps): + ctx = app_with_deps( + message_pages=[ + [_msg("m1", "user", "page-zero-text")], + [_msg("m2", "assistant", "page-one-text")], + ], + ) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", + json={"connectorId": "gdrive", "format": "markdown"}, + ) + + assert response.status_code == 200 + rendered = ctx.adapter.created_with.content.decode("utf-8") + # Both pages made it into the document, in order. + assert "page-zero-text" in rendered + assert "page-one-text" in rendered + assert rendered.index("page-zero-text") < rendered.index("page-one-text") + + def test_404_when_session_missing(self, app_with_deps): + ctx = app_with_deps(metadata=None) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 404 + + def test_404_when_not_an_export_target(self, app_with_deps): + ctx = app_with_deps( + providers=[_make_provider("gdrive", export_target_adapter_id=None)], + ) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 404 + + def test_403_when_user_lacks_role(self, app_with_deps): + ctx = app_with_deps( + providers=[_make_provider("gdrive", allowed_roles=["admins"])], + permissions=_make_permissions(roles=["users"]), + ) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 403 + + def test_409_when_not_connected(self, app_with_deps): + ctx = app_with_deps( + identity_result=TokenResult(authorization_url="https://auth.example/x"), + ) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 409 + # No upload attempted, no receipt persisted on a consent miss. + assert ctx.adapter.created_with is None + assert ctx.receipts == [] + + def test_503_when_workload_unavailable(self, app_with_deps): + ctx = app_with_deps( + identity_raises=WorkloadTokenUnavailableError("no workload"), + ) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 503 + + def test_422_when_format_unsupported(self, app_with_deps): + ctx = app_with_deps() + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", + json={"connectorId": "gdrive", "format": "pdf"}, + ) + assert response.status_code == 422 + assert ctx.adapter.created_with is None + + def test_502_on_adapter_error(self, app_with_deps): + adapter = _StubExportAdapter() + adapter.raise_error = ExportTargetError("drive exploded") + ctx = app_with_deps(adapter=adapter) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 502 + assert ctx.receipts == [] + + def test_403_on_adapter_auth_error(self, app_with_deps): + adapter = _StubExportAdapter() + adapter.raise_error = ExportTargetAuthError("token rejected") + ctx = app_with_deps(adapter=adapter) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", json={"connectorId": "gdrive"} + ) + assert response.status_code == 403 + + def test_404_on_adapter_not_found_error(self, app_with_deps): + adapter = _StubExportAdapter() + adapter.raise_error = ExportTargetNotFoundError("folder gone") + ctx = app_with_deps(adapter=adapter) + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", + json={"connectorId": "gdrive", "parentId": "missing"}, + ) + assert response.status_code == 404 + + def test_parent_id_passed_to_adapter(self, app_with_deps): + ctx = app_with_deps() + response = TestClient(ctx.app).post( + "/sessions/sess-1/export", + json={"connectorId": "gdrive", "parentId": "folder-9"}, + ) + assert response.status_code == 200 + assert ctx.adapter.created_with.parent_id == "folder-9" diff --git a/backend/tests/apis/app_api/test_export_target_adapters_admin.py b/backend/tests/apis/app_api/test_export_target_adapters_admin.py new file mode 100644 index 00000000..998c8700 --- /dev/null +++ b/backend/tests/apis/app_api/test_export_target_adapters_admin.py @@ -0,0 +1,223 @@ +"""Tests for connector ↔ export-target-adapter mapping. + +Covers the OAuthProvider model round-trip for `export_target_adapter_id`, the +admin-route validation helper, the export-target registry, and the read-only +GET /admin/export-target-adapters endpoint. + +The export-target registry ships empty in this PR (the Google Drive export +adapter lands in the next one), so the validation/endpoint tests register a +stub adapter under a test-only key — distinct from any real adapter key so it +never collides with a future shipped adapter. +""" + +from __future__ import annotations + +from typing import List, Optional + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from apis.shared.auth import require_admin +from apis.shared.auth.models import User +from apis.shared.oauth.models import OAuthProvider, OAuthProviderType + +from apis.app_api.admin.export_targets import routes as adapter_routes +from apis.app_api.admin.oauth.routes import _validate_export_target_adapter +from apis.app_api.export_targets.adapter import ( + ExportTargetAdapter, + ExportTargetMetadata, +) +from apis.app_api.export_targets.models import ( + CreatedFile, + ExportDestination, + ExportFormat, +) +from apis.app_api.export_targets.registry import ExportTargetRegistry, registry + +_STUB_KEY = "test-export-target" +_STUB_SCOPE = "https://www.googleapis.com/auth/drive.file" + + +def _admin() -> User: + return User( + user_id="admin-1", + email="admin@example.com", + name="Admin", + roles=["admin"], + raw_token="test-token", + ) + + +class _StubExportAdapter(ExportTargetAdapter): + """Minimal in-test adapter so the registry/endpoint have something to list.""" + + @property + def metadata(self) -> ExportTargetMetadata: + return ExportTargetMetadata( + key=_STUB_KEY, + display_name="Test Export Target", + icon="test-icon", + compatible_provider_types=(OAuthProviderType.GOOGLE,), + required_scopes=(_STUB_SCOPE,), + supported_formats=(ExportFormat.GOOGLE_DOC, ExportFormat.MARKDOWN), + ) + + async def list_destinations(self, access_token: str) -> List[ExportDestination]: + return [ExportDestination(id="root", name="My Drive")] + + async def create_document( + self, + access_token: str, + *, + content: bytes, + name: str, + source_mime_type: str, + target_format: ExportFormat, + parent_id: Optional[str] = None, + ) -> CreatedFile: + return CreatedFile(file_id="file-1", name=name, web_view_link=None) + + +@pytest.fixture +def registered_stub(): + """Register the stub into the process-wide registry for the test, then remove it.""" + adapter = _StubExportAdapter() + registry.register(adapter) + try: + yield adapter + finally: + # No public unregister — the registry is immutable at runtime in prod. + registry._adapters.pop(_STUB_KEY, None) + + +class TestOAuthProviderExportMapping: + def test_export_target_adapter_id_round_trips_through_dynamo(self): + provider = OAuthProvider( + provider_id="google", + display_name="Google", + provider_type=OAuthProviderType.GOOGLE, + scopes=["openid"], + allowed_roles=[], + export_target_adapter_id="google-drive", + ) + restored = OAuthProvider.from_dynamo_item(provider.to_dynamo_item()) + assert restored.export_target_adapter_id == "google-drive" + + def test_connector_can_be_both_source_and_target(self): + provider = OAuthProvider( + provider_id="google", + display_name="Google", + provider_type=OAuthProviderType.GOOGLE, + scopes=["openid"], + allowed_roles=[], + file_source_adapter_id="google-drive", + export_target_adapter_id="google-drive", + ) + restored = OAuthProvider.from_dynamo_item(provider.to_dynamo_item()) + assert restored.file_source_adapter_id == "google-drive" + assert restored.export_target_adapter_id == "google-drive" + + def test_unmapped_provider_round_trips_as_none(self): + provider = OAuthProvider( + provider_id="slack", + display_name="Slack", + provider_type=OAuthProviderType.SLACK, + scopes=[], + allowed_roles=[], + ) + assert provider.export_target_adapter_id is None + restored = OAuthProvider.from_dynamo_item(provider.to_dynamo_item()) + assert restored.export_target_adapter_id is None + + def test_legacy_dynamo_item_without_field_defaults_to_none(self): + # Records written before this field existed have no exportTargetAdapterId. + item = OAuthProvider( + provider_id="github", + display_name="GitHub", + provider_type=OAuthProviderType.GITHUB, + scopes=[], + allowed_roles=[], + ).to_dynamo_item() + del item["exportTargetAdapterId"] + assert OAuthProvider.from_dynamo_item(item).export_target_adapter_id is None + + +class TestExportTargetRegistry: + def test_register_get_and_all(self): + reg = ExportTargetRegistry() + adapter = _StubExportAdapter() + reg.register(adapter) + assert reg.get(_STUB_KEY) is adapter + assert adapter in reg.all() + + def test_duplicate_key_raises(self): + reg = ExportTargetRegistry() + reg.register(_StubExportAdapter()) + with pytest.raises(ValueError): + reg.register(_StubExportAdapter()) + + def test_adapters_for_provider_type_filters(self): + reg = ExportTargetRegistry() + reg.register(_StubExportAdapter()) + assert reg.adapters_for_provider_type(OAuthProviderType.GOOGLE) + assert reg.adapters_for_provider_type(OAuthProviderType.SLACK) == [] + + def test_default_registry_includes_google_drive(self): + # The shipped registry now carries the Google Drive export adapter. + drive = registry.get("google-drive") + assert drive is not None + assert drive.metadata.compatible_provider_types == (OAuthProviderType.GOOGLE,) + + +class TestValidateExportTargetAdapter: + def test_empty_or_none_is_a_noop(self): + _validate_export_target_adapter(None, OAuthProviderType.GOOGLE) + _validate_export_target_adapter("", OAuthProviderType.GOOGLE) + + def test_valid_mapping_passes(self, registered_stub): + _validate_export_target_adapter(_STUB_KEY, OAuthProviderType.GOOGLE) + + def test_unknown_adapter_is_rejected(self): + with pytest.raises(HTTPException) as exc: + _validate_export_target_adapter("nope", OAuthProviderType.GOOGLE) + assert exc.value.status_code == 400 + assert "Unknown export-target adapter" in exc.value.detail + + def test_incompatible_provider_type_is_rejected(self, registered_stub): + with pytest.raises(HTTPException) as exc: + _validate_export_target_adapter(_STUB_KEY, OAuthProviderType.SLACK) + assert exc.value.status_code == 400 + assert "not compatible" in exc.value.detail + + +class TestListExportTargetAdaptersEndpoint: + @pytest.fixture + def client(self) -> TestClient: + app = FastAPI() + app.include_router(adapter_routes.router) + app.dependency_overrides[require_admin] = _admin + return TestClient(app) + + def test_lists_shipped_adapters(self, client: TestClient, registered_stub): + response = client.get("/export-target-adapters/") + assert response.status_code == 200 + adapters = {a["key"]: a for a in response.json()["adapters"]} + assert _STUB_KEY in adapters + stub = adapters[_STUB_KEY] + assert stub["displayName"] == "Test Export Target" + assert stub["compatibleProviderTypes"] == ["google"] + assert stub["requiredScopes"] == [_STUB_SCOPE] + assert stub["supportedFormats"] == ["google_doc", "markdown"] + + def test_lists_shipped_google_drive_adapter(self, client: TestClient): + response = client.get("/export-target-adapters/") + assert response.status_code == 200 + adapters = {a["key"]: a for a in response.json()["adapters"]} + assert "google-drive" in adapters + drive = adapters["google-drive"] + assert drive["displayName"] == "Google Drive" + assert drive["requiredScopes"] == [ + "https://www.googleapis.com/auth/drive.file" + ] + assert drive["supportedFormats"] == ["google_doc", "markdown"] diff --git a/backend/tests/apis/app_api/test_export_target_service.py b/backend/tests/apis/app_api/test_export_target_service.py new file mode 100644 index 00000000..3cc47114 --- /dev/null +++ b/backend/tests/apis/app_api/test_export_target_service.py @@ -0,0 +1,130 @@ +"""Tests for the export-target resolve + token helpers.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import List, Optional + +import pytest +from fastapi import HTTPException + +from apis.shared.auth.models import User +from apis.shared.oauth.agentcore_identity import WorkloadTokenUnavailableError +from apis.shared.oauth.models import OAuthProvider, OAuthProviderType + +from apis.app_api.export_targets import service +from apis.app_api.export_targets.service import ( + require_export_target_token, + resolve_export_target, +) + + +def _user() -> User: + return User( + user_id="u1", + email="u1@example.com", + name="U", + roles=["user"], + raw_token="tok", + ) + + +def _provider(**kw) -> OAuthProvider: + defaults = dict( + provider_id="gdrive", + display_name="Google Drive", + provider_type=OAuthProviderType.GOOGLE, + scopes=["https://www.googleapis.com/auth/drive.file"], + allowed_roles=[], + enabled=True, + export_target_adapter_id="google-drive", + ) + defaults.update(kw) + return OAuthProvider(**defaults) + + +class _Repo: + def __init__(self, provider: Optional[OAuthProvider]): + self._p = provider + + async def get_provider(self, provider_id: str): + return self._p + + +class _Roles: + def __init__(self, app_roles: List[str]): + self._roles = app_roles + + async def resolve_user_permissions(self, user: User): + return SimpleNamespace(app_roles=self._roles) + + +class TestResolveExportTarget: + @pytest.mark.asyncio + async def test_resolves_provider_and_adapter(self): + provider, adapter = await resolve_export_target( + "gdrive", _user(), _Repo(_provider()), _Roles([]) + ) + assert provider.provider_id == "gdrive" + assert adapter.metadata.key == "google-drive" + + @pytest.mark.asyncio + async def test_missing_connector_404(self): + with pytest.raises(HTTPException) as exc: + await resolve_export_target("gdrive", _user(), _Repo(None), _Roles([])) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_not_an_export_target_404(self): + provider = _provider(export_target_adapter_id=None) + with pytest.raises(HTTPException) as exc: + await resolve_export_target("gdrive", _user(), _Repo(provider), _Roles([])) + assert exc.value.status_code == 404 + assert "not configured as an export target" in exc.value.detail + + @pytest.mark.asyncio + async def test_unknown_adapter_key_404(self): + provider = _provider(export_target_adapter_id="dropbox") + with pytest.raises(HTTPException) as exc: + await resolve_export_target("gdrive", _user(), _Repo(provider), _Roles([])) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_rbac_denied_403(self): + provider = _provider(allowed_roles=["admins-only"]) + with pytest.raises(HTTPException) as exc: + await resolve_export_target( + "gdrive", _user(), _Repo(provider), _Roles(["plain-user"]) + ) + assert exc.value.status_code == 403 + + +class TestRequireExportTargetToken: + @pytest.mark.asyncio + async def test_returns_token_when_connected(self, monkeypatch): + async def fake(provider, user_id): + return SimpleNamespace(requires_consent=False, access_token="the-token") + + monkeypatch.setattr(service, "resolve_export_target_token", fake) + token = await require_export_target_token(_provider(), "u1") + assert token == "the-token" + + @pytest.mark.asyncio + async def test_consent_required_409(self, monkeypatch): + async def fake(provider, user_id): + return SimpleNamespace(requires_consent=True, access_token=None) + + monkeypatch.setattr(service, "resolve_export_target_token", fake) + with pytest.raises(HTTPException) as exc: + await require_export_target_token(_provider(), "u1") + assert exc.value.status_code == 409 + + @pytest.mark.asyncio + async def test_workload_unavailable_503(self, monkeypatch): + async def fake(provider, user_id): + raise WorkloadTokenUnavailableError("no workload") + + monkeypatch.setattr(service, "resolve_export_target_token", fake) + with pytest.raises(HTTPException) as exc: + await require_export_target_token(_provider(), "u1") + assert exc.value.status_code == 503 diff --git a/backend/uv.lock b/backend/uv.lock index e3b9af60..7dd92f86 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/docs-site/src/content/docs/deployment/environments.md b/docs-site/src/content/docs/deployment/environments.md index a3cb4bca..30f5a893 100644 --- a/docs-site/src/content/docs/deployment/environments.md +++ b/docs-site/src/content/docs/deployment/environments.md @@ -31,16 +31,63 @@ reference deployment runs two: | Environment | Branch | Domain | |-------------|--------|--------| -| **Development** | `develop` | `alpha.boisestate.ai` | -| **Production** | `main` | `beta.boisestate.ai` | - -Both are **subdomains**, which means the TLS wildcard-depth rule applies: a -`*.boisestate.ai` cert does **not** cover `artifacts.alpha.boisestate.ai`, so -each environment needs a `us-east-1` cert that covers its own -`artifacts.{domain}` and `mcp-sandbox.{domain}` origins. See the +| **Development** | `develop` | `dev.boisestate.ai` | +| **Production** | `main` | `boisestate.ai` | + +Development is a **subdomain**, so the TLS wildcard-depth rule applies: a +`*.boisestate.ai` cert does **not** cover `artifacts.dev.boisestate.ai`, so the +dev environment needs a `us-east-1` cert covering its own `dev.boisestate.ai` +**and** `*.dev.boisestate.ai` (which covers `artifacts.dev.boisestate.ai` and +`mcp-sandbox.dev.boisestate.ai`). + +Production serves at the **apex** `boisestate.ai`. The SPA sits on the bare +apex, which a wildcard does **not** match, while the `artifacts.` and +`mcp-sandbox.` origins are single-label subdomains that a wildcard does cover — +so the prod cert needs **both** `boisestate.ai` and `*.boisestate.ai` as SANs. +See the [wildcard-depth note](/agentcore-public-stack/deployment/platform-cdk/#acm-certificates) on the Platform page. +## Cross-account hosted zones + +CDK normally creates the Route53 ALIAS/A records for the SPA, ALB, artifacts, +and mcp-sandbox origins. It does this with `HostedZone.fromLookup`, which runs +under the **deploy account's** credentials — so it only works when the hosted +zone lives in the same account as the deployment. + +When the zone for an environment's domain lives in a **different AWS account** +(as the production `boisestate.ai` zone does), set `CDK_MANAGE_DNS_RECORDS=false` +on that GitHub Environment. The stack then: + +- still attaches the custom domain + ACM cert to every CloudFront origin and the + ALB listener, so each origin serves its domain over TLS, and +- skips the in-account zone lookup + record creation that would otherwise fail + cross-account, and +- emits CfnOutputs with the record name and alias target for each origin so you + can create the records by hand in the zone's account: + +| Origin | Record-name output | Alias-target output | +|--------|--------------------|---------------------| +| SPA | `{prefix}-frontend-dns-record-name` | `{prefix}-frontend-dns-alias-target` | +| ALB | `{prefix}-alb-dns-record-name` | `{prefix}-alb-dns-alias-target` | +| Artifacts | `{prefix}-artifacts-dns-record-name` | `{prefix}-artifacts-dns-alias-target` | +| MCP sandbox | `{prefix}-mcp-sandbox-dns-record-name` | `{prefix}-mcp-sandbox-dns-alias-target` | + +In each pair, create an ALIAS (or CNAME) at the record name pointing to the +alias target. The CloudFront origins target a `*.cloudfront.net` domain; the ALB +record targets the ALB DNS name. + +:::note[Certs still live in the deploy account] +ACM certificates are referenced by ARN and must exist in the **deploy** account +(CloudFront certs in `us-east-1`, the ALB cert in the deploy region) regardless +of where the zone lives. Validate them with DNS validation records you add by +hand in the zone's account. +::: + +Leave `CDK_MANAGE_DNS_RECORDS` unset (defaults to `true`) for any environment +whose hosted zone is in the deploy account — e.g. the dev stack — and CDK manages +the records automatically. + ## Per-environment overrides Beyond the required variables, most tuning knobs are optional and naturally diff --git a/docs/specs/conversation-export-connectors.md b/docs/specs/conversation-export-connectors.md new file mode 100644 index 00000000..7e5b4d62 --- /dev/null +++ b/docs/specs/conversation-export-connectors.md @@ -0,0 +1,419 @@ +# Save Conversations to Connected Apps (Export Targets) + +**Status:** Draft / proposal +**Author:** (spec) +**Related:** connector + file-source import pattern (`apis/app_api/file_sources/`, `apis/app_api/connectors/`), OAuth provider model (`apis/shared/oauth/`) + +## Summary + +We already let a user connect an app (Google Drive) and pull documents **into** an +Assistant's knowledge base. This spec extends the same connector + adapter pattern +in the opposite direction: let a user push a **conversation transcript out** to a +connected app — "Save this chat to Google Drive." + +The key architectural move is to recognize that the existing pattern has two layers, +and only one of them is direction-specific: + +- **Auth layer** (`OAuthProvider` + AgentCore Identity + consent UX) — provider-agnostic + and **direction-agnostic**. Reused as-is. +- **Capability layer** (`FileSourceAdapter` registry) — direction-specific. Today it + only models **sources** (read: `list_roots`/`browse`/`search`/`download`). We add a + parallel **destination/export-target** capability (write: `create_document`). + +Because the capability layer is a registry keyed by a code-shipped adapter, this design +is generic from day one: Google Drive is the first export target, and adding OneDrive / +SharePoint / Dropbox / Box later is "write one adapter class + register it + an admin maps +a connector" — exactly the cost of adding a new file source today. + +## Goals + +- Let a user save a full conversation transcript to a connected app they own. +- Reuse the existing connector model, RBAC visibility gate, AgentCore Identity token + mint, and the connect/consent/disconnect UX with **zero changes to the auth layer**. +- Make export **generic across providers** via an `ExportTargetAdapter` registry that + mirrors the `FileSourceAdapter` registry. Drive is the reference implementation. +- Keep the write in `app-api` (user-facing, deterministic action), honoring the + inference-api boundary rule. + +## Non-goals (v1) + +- Continuous/auto sync of conversations to Drive. v1 is an explicit, one-shot "Save" + action. +- Round-tripping (importing a saved transcript back as a conversation). +- An agent-callable `save_conversation` tool. Designed-for but deferred (see + [Future: conversational surface](#future-conversational-surface)). +- Org-managed shared destinations / write to *another* user's drive. + +--- + +## Background: how import works today (the pattern we're extending) + +``` +CONNECTOR (OAuthProvider, DynamoDB) ← auth layer (provider-agnostic) + provider_type: GOOGLE + scopes: [drive.readonly] + allowed_roles: [...] ← RBAC visibility + file_source_adapter_id: "google-drive" ← maps connector → capability + +FileSourceAdapter (registry, code-shipped) ← capability layer (READ) + metadata{key, compatible_provider_types, required_scopes} + list_roots / browse / search / download + +Flow: connect (AgentCore 3LO consent) → pick files → POST /assistants/{id}/documents/import + → resolve_file_source() + require_file_source_token() → adapter.download() → S3 → ingest +``` + +Concrete anchors: +- Connector model: [`OAuthProvider`](../../backend/src/apis/shared/oauth/models.py) — note the existing `file_source_adapter_id` field (models.py:124) we will mirror. +- Capability contract: [`FileSourceAdapter`](../../backend/src/apis/app_api/file_sources/adapter.py) +- Registry: [`registry.py`](../../backend/src/apis/app_api/file_sources/registry.py) +- Drive adapter: [`google_drive.py`](../../backend/src/apis/app_api/file_sources/adapters/google_drive.py) +- Resolve connector → adapter + token: [`resolve_file_source` / `require_file_source_token`](../../backend/src/apis/app_api/file_sources/service.py) +- Consent UX (connect / status / complete / disconnect): [`connectors/routes.py`](../../backend/src/apis/app_api/connectors/routes.py) +- Admin adapter dropdown: [`admin/file_sources/routes.py`](../../backend/src/apis/app_api/admin/file_sources/routes.py) +- Transcript retrieval: [`get_messages(session_id, user_id, ...)`](../../backend/src/apis/shared/sessions/messages.py) (messages.py:534) + +**The asymmetry that matters:** the Drive adapter is read-only by construction — +`drive.readonly` scope, and the contract has no write method. Export inverts the data +flow (write a file out) and needs a write scope. That is the whole of the new work. + +--- + +## Design + +### 1. New capability: `ExportTargetAdapter` (mirrors `FileSourceAdapter`) + +New package `apis/app_api/export_targets/` parallel to `file_sources/`. + +```python +# apis/app_api/export_targets/adapter.py +@dataclass(frozen=True) +class ExportTargetMetadata: + key: str # e.g. "google-drive" + display_name: str # "Google Drive" + icon: str + compatible_provider_types: Tuple[OAuthProviderType, ...] + required_scopes: Tuple[str, ...] # Drive: (DRIVE_FILE_SCOPE,) + # What document formats this target can accept / convert to: + supported_formats: Tuple[ExportFormat, ...] # e.g. (GOOGLE_DOC, MARKDOWN, PDF) + +@dataclass(frozen=True) +class CreatedFile: + file_id: str + name: str + web_view_link: Optional[str] # surfaced to the SPA as "Open in Drive" + +class ExportTargetAdapter(ABC): + @property + @abstractmethod + def metadata(self) -> ExportTargetMetadata: ... + + @abstractmethod + async def list_destinations(self, access_token: str) -> List[SourceRoot]: + """Top-level write locations (e.g. My Drive, shared drives). Optional folder picker.""" + + @abstractmethod + async def create_document( + self, + access_token: str, + *, + content: bytes, + name: str, + source_mime_type: str, # what we're uploading, e.g. text/html + target_format: ExportFormat, # how it should land, e.g. GOOGLE_DOC + parent_id: Optional[str], # destination folder, None = drive root + ) -> CreatedFile: ... +``` + +Registry `apis/app_api/export_targets/registry.py` is a 1:1 copy of the file-source +registry pattern (process-wide singleton, code-shipped, immutable at runtime), seeded with +`GoogleDriveExportAdapter()`. + +> **Decided (was OQ-1): parallel registries, not a write method bolted onto +> `FileSourceAdapter`.** A connector can legitimately be a source but not a destination (or +> vice-versa), required scopes differ, and the existing `FileSourceAdapter` docstring is +> explicit that adapters are read-shaped. Two small registries read more honestly than one +> adapter with half its methods raising `NotImplementedError`. The browse/roots contract is +> near-identical between the two — acceptable duplication for an honest contract. + +### 2. Google Drive export adapter + +`apis/app_api/export_targets/adapters/google_drive.py` + +- Scope: `https://www.googleapis.com/auth/drive.file` — **least privilege**: grants + create + access to files the app created, not read over the user's whole drive. +- `create_document` → `POST https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true` + with metadata `{name, mimeType: , parents: [parent_id?]}` + media body. + - **`target_format = GOOGLE_DOC` (default):** metadata `mimeType: application/vnd.google-apps.document`, + upload a **`text/html` body** (the render step converts Markdown → HTML first) → Drive's + HTML-import converter produces a **native Google Doc with real Docs styling**. See + [Markdown → Google Doc fidelity](#markdown--google-doc-fidelity) below — this is why we go + via HTML rather than uploading raw Markdown (which would land as literal `**asterisks**`). + - `target_format = MARKDOWN` → plain `text/markdown` file, no conversion (portable export). + - `target_format = PDF` → upload `application/pdf` bytes (render step produces them). +- `list_destinations` → My Drive + shared drives (same shape as the import adapter's + `list_roots`). With the combined-scope connector (decided, §3) this is backed by the import + adapter's existing `browse()`. + +#### Markdown → Google Doc fidelity + +We render the transcript Markdown to **sanitized HTML**, then upload as `text/html` against the +Google Doc target. Drive's HTML import maps the common elements to native Docs styling: + +| Markdown | Google Doc result | Fidelity | +|---|---|---| +| `#` / `##` / `###` | Heading 1/2/3 paragraph styles | high | +| bold / italic / links | bold / italic / hyperlinks | high | +| bullet / numbered lists | native Docs lists | high | +| tables | Docs tables | good | +| blockquotes, `---` | indented quote, horizontal rule | good | +| fenced / inline code | preformatted / monospace text, **no syntax highlighting** | partial | +| inline images | embedded — data-URI images need to be hosted or skipped | needs care | + +PR-2 should also **spike Google's newer native Markdown→Docs import** (upload `text/markdown` +against the Doc target): if it round-trips fenced code and images better than HTML import, prefer +it; otherwise HTML import is the robust default. Either way the adapter interface is unchanged — +only the `source_mime_type` we hand it differs. + +### 3. Folder picker & scope — **decided: combined-scope connector** + +`drive.file` can *write into* a folder given its id, but cannot *list* arbitrary existing +folders to discover that id. **Decision (was OQ-4):** one Drive connector carries the combined +scope set **`[drive.readonly, drive.file]`**. This is the same connector used for document +import, now also mapped as an export target. Consequences: + +- The **import adapter's existing `browse()`/`list_roots()` powers the destination folder + picker for free** — no Google Picker JS, no separate write-only connector. +- A **single connection covers import *and* export** — one row in the connector catalog, one + consent. +- **Re-consent cost:** adding `drive.file` to an already-mapped Drive connector changes its + `scopes_hash`; existing import users must re-consent once. Surface this in the admin UI the + same way other scope changes are surfaced today (see [Security & correctness](#security--correctness-notes)). + +Phasing of the picker UI itself is independent of this scope decision: +- **v1:** ship with a **default app folder** (`//Conversations/`, creatable under + `drive.file`) and surface the returned `web_view_link`. No picker UI yet. +- **v2:** wire the reused import browse dialog as a destination picker (now unblocked by the + combined scope — `parentId` simply flows into `create_document`). + +### 4. Transcript → document rendering (user-selectable elements) + +New `apis/app_api/export_targets/render.py`: + +- Page through the entire session via `get_messages(session_id, user_id, limit, next_token)` + until `next_token` is exhausted (export needs the *full* transcript, not one page). +- Render messages to an intermediate **HTML** (Markdown → HTML), then hand bytes to the + adapter. HTML is the universal source: Drive's HTML import yields a styled Google Doc, and + the same HTML feeds an `.md` emit or an HTML→PDF pass. +- Reuse opportunity: the artifact-render / docling infra already in the repo for any HTML→PDF + need, rather than adding a new renderer dependency. + +**Decided (was OQ-2): the user picks what's included, via checkboxes in the export dialog.** +The render is driven by an `include` flag set carried on the export request, so the same +endpoint produces a lean or a full transcript without server-side guesswork: + +```python +class ExportInclude(BaseModel): # all default per the table below + user_messages: bool = True # always on; shown disabled/checked in the UI + assistant_messages: bool = True # always on; shown disabled/checked in the UI + tool_calls: bool = True # tool name + collapsed input/result + images: bool = True # inline image blocks (data-URI handling, §2) + citations: bool = True # source citations / references + reasoning: bool = False # model reasoningContent blocks — default OFF + timestamps: bool = False # per-message time — default OFF +``` + +| Checkbox | Default | Notes | +|---|---|---| +| Messages (user + assistant) | on, locked | the transcript itself; not unselectable | +| Tool calls & results | on | rendered collapsed/labeled | +| Images | on | inline; raw document blobs always become a `[attached: name]` placeholder | +| Citations | on | — | +| Reasoning | off | can be verbose / not user-facing | +| Timestamps | off | — | + +The SPA renders these as a checkbox group in the "Save to…" dialog; unchecked elements are +skipped at render time. Defaults match the table so the common case is one click. + +### 5. Resolve + token helpers (mirror file-source service) + +`apis/app_api/export_targets/service.py` mirrors +[`file_sources/service.py`](../../backend/src/apis/app_api/file_sources/service.py) exactly: + +- `resolve_export_target(connector_id, user, provider_repo, role_service) -> (OAuthProvider, ExportTargetAdapter)` + — RBAC visibility gate + `export_target_adapter_id` lookup + registry resolve (404/403). +- `require_export_target_token(provider, user_id) -> str` — calls + `get_token_for_user(... custom_parameters=custom_parameters_for(..., force_authentication=True))`, + returns the access token or raises 409 (not connected) / 503 (no workload context). + +This is a near-verbatim copy; the only differences are the field name +(`export_target_adapter_id`) and the registry it resolves against. + +### 6. Export endpoint (app-api) + +``` +POST /sessions/{session_id}/export + body: { + connectorId: str, + format?: "google_doc" | "markdown" | "pdf", # default "google_doc" + parentId?: str, # destination folder (v2 picker); omit = app folder + include?: ExportInclude, # §4 checkboxes; omitted = defaults + } + auth: Depends(get_current_user_from_session) # cookie, per the auth-dependency rule +``` + +Flow: +1. Verify the session belongs to `current_user` (reuse the session ownership check used by + `GET /sessions/{id}/messages`). +2. `provider, adapter = resolve_export_target(connectorId, ...)`. +3. `token = require_export_target_token(provider, user_id)` — **409 path is the consent hook** + (see next section). +4. `content = render(session_id, user_id, format)`. +5. `created = adapter.create_document(token, content=..., name=, ...)`. +6. Return `{ fileId, name, webViewLink }` (200). Optionally persist an **export receipt** on + session metadata (mirrors `DocumentProvenance`: connector id, adapter key, remote file id, + link, timestamp) so the UI can show "Saved to Drive · Open". + +Lives in `app-api`, **not** inference-api: it is a user-facing, non-invocation HTTP path, and +app-api can mint per-user tokens via the `AGENTCORE_RUNTIME_WORKLOAD_NAME` workload identity — +the exact mechanism the import + connectors routes already rely on. + +### 7. Not-connected → consent (reuse existing UX verbatim) + +When `require_export_target_token` raises 409, the SPA runs the **identical** consent flow the +connectors settings page already implements — no new consent machinery: + +1. `POST /connectors/{connectorId}/initiate-consent` → `{ authorizationUrl }` +2. open popup → user consents → `POST /connectors/complete-consent { sessionUri, providerId }` +3. retry `POST /sessions/{session_id}/export`. + +`initiate-consent`/`complete-consent`/`status`/`disconnect` are already provider-agnostic — +they key only on `provider_id`. They need **no changes**. + +### 8. Admin surface + +Mirror the file-source admin dropdown: +- Add `export_target_adapter_id: Optional[str]` to `OAuthProvider`, + `OAuthProviderCreate`, `OAuthProviderUpdate`, `OAuthProviderResponse`, and the + Dynamo (de)serializers in [`models.py`](../../backend/src/apis/shared/oauth/models.py) + (mirrors `file_source_adapter_id` line-for-line). +- Add `GET /admin/export-target-adapters` (copy of + [`admin/file_sources/routes.py`](../../backend/src/apis/app_api/admin/file_sources/routes.py)) + so the connector form can render an "Export target" dropdown. +- Admin-route validation mirrors the file-source validation at + `admin/oauth/routes.py:196/271/381` (adapter exists + provider-type compatible + + warn if connector scopes don't cover `required_scopes`). + +### 9. Frontend + +- **Chat surface:** a "Save to…" action (overflow menu on a conversation) opens a dialog with: + (a) connector picker (reuse `GET /connectors/` filtered to export-capable ones), (b) format + choice (Google Doc default / Markdown / PDF), (c) the **include checkbox group** from §4 + (messages locked-on; tool calls / images / citations on; reasoning / timestamps off). It then + calls the export endpoint; on 409 runs the consent popup and retries; on success shows + "Saved · Open in Drive" using `webViewLink`. +- Reuse the connector consent service already powering the settings page. +- (v2) destination folder picker reusing the import file-browser dialog component — unblocked + by the combined-scope connector (§3). + +--- + +## Generalization: adding the next export target + +This is the payoff of the registry approach. To add **OneDrive** (or Dropbox, Box, SharePoint): + +1. Write `OneDriveExportAdapter(ExportTargetAdapter)` (Graph API `PUT /me/drive/...`), + `metadata.compatible_provider_types = (OAuthProviderType.MICROSOFT,)`, + `required_scopes = ("Files.ReadWrite",)`. +2. Register it in `export_targets/registry.py`. +3. Admin creates a Microsoft connector and maps `export_target_adapter_id = "onedrive"`. + +No changes to the endpoint, the consent flow, RBAC, the render step, or the SPA. The auth +layer already enumerates `MICROSOFT`, `SLACK`, `SALESFORCE`, `ZOOM`, `CANVAS`, `CUSTOM` +(`OAuthProviderType`), so new providers are connector config, not new auth code — the same +property that makes import provider-agnostic today. + +--- + +## Security & correctness notes + +- **Least privilege:** prefer `drive.file` over `drive`/`drive.readonly` for the write path. + The app can only touch files it created. Combined-scope connectors (for the folder picker) + add `drive.readonly` deliberately and visibly. +- **customParameters are part of the token-vault key.** The export token request **must** use + `custom_parameters_for(..., force_authentication=True)`, identical to the file-source and + connector paths, or AgentCore reports consent-required against a usable vaulted token. This + is already encoded in `require_file_source_token`; copy it exactly. +- **Re-consent on scope change.** Adding `drive.file` to an already-connected Drive connector + changes its `scopes_hash`; existing users must re-consent. Surface this in the admin UI the + same way scope changes are surfaced today. +- **Ownership:** the export endpoint writes to the *requesting user's own* drive via their own + vaulted token. No cross-user or service-account writes in v1. +- **Session ownership check** on the export endpoint must match the read path so a user can + only export their own conversations. +- **AgentCore Memory is read-OK for display.** `get_messages` already backs the SPA history + view, so the transcript is retrievable; just remember to page to completion. + +## Testing + +- Adapter unit tests with `httpx.MockTransport` (the import adapter is the template — + `GoogleDriveAdapter` takes an injectable transport for exactly this). +- `resolve_export_target` / `require_export_target_token` tests mirror the file-source service + tests (404 not-an-export-target, 403 RBAC, 409 not-connected, 503 no-workload). +- Render tests: multi-page session, tool-call rendering, empty conversation. +- Architecture boundary test stays green: `export_targets/` lives under `apis/app_api/`, only + imports from `apis.shared` and `apis.app_api` (no inference-api / agents coupling). +- Backend pytest is **not** run in CI — full local suite is the correctness gate for the + shared `OAuthProvider` change. + +## Rollout + +- Feature-flag the chat "Save to…" action (env flag on app-api + SPA) so it ships dark until + an export connector is configured. +- No CDK changes required for the core feature: it reuses the existing OAuth provider table, + workload identity, and app-api service. (A combined-scope or new connector is admin config, + not infra.) + +## Suggested PR sequence + +1. **PR-1 — data + capability scaffold:** `export_target_adapter_id` on `OAuthProvider` + (+ models/serializers), `ExportTargetAdapter` contract + registry, admin + `GET /admin/export-target-adapters`, admin form validation. No behavior yet. +2. **PR-2 — Drive export adapter + render:** `GoogleDriveExportAdapter.create_document` + (default app-folder), `render.py`, service resolve/token helpers. Unit tests. +3. **PR-3 — export endpoint:** `POST /sessions/{id}/export` + optional export receipt on + session metadata. +4. **PR-4 — SPA:** "Save to…" action, connector picker, 409→consent retry, success/Open link. +5. **PR-5 (v2) — folder picker:** combined-scope connector + reuse the import browse dialog as + a destination picker. +6. **PR-6 (deferred) — agent tool:** `save_conversation` riding the `oauth_required` SSE gate. + +## Decisions + +- **D-1 (was OQ-1) — Parallel `ExportTargetAdapter` registry**, not a write method bolted onto + `FileSourceAdapter`. Cleaner contract; source ≠ destination. (§1) +- **D-2 (was OQ-2) — User-selectable elements via checkboxes.** The export request carries an + `include` flag set; messages are locked-on, tool calls / images / citations default on, + reasoning / timestamps default off. (§4) +- **D-3 (was OQ-3) — Default to a native Google Doc**, rendered Markdown → HTML → Doc so + Markdown formatting maps to real Docs styling; also offer Markdown and PDF. PR-2 spikes + Google's native Markdown→Docs import as a possible better path for code/images. (§2) +- **D-4 (was OQ-4) — One combined-scope Drive connector** (`[drive.readonly, drive.file]`), + used for both import and export. Unlocks reusing the import browse UI as a destination picker; + costs a one-time re-consent for existing import users. (§3) + +### Remaining to confirm + +- **R-1.** Data-URI / inline image handling for the Google Doc path (host vs. skip vs. embed). + Resolve during PR-2 alongside the native-Markdown-import spike. +- **R-2.** Whether to persist an export receipt on session metadata in v1 (for a "Saved · Open" + affordance that survives reload) or just return the link. Lean: persist — it's cheap and + mirrors `DocumentProvenance`. + +## Future: conversational surface + +Once the deterministic endpoint exists, a `save_conversation` **agent tool** is a thin add: it +rides the existing `oauth_required` SSE consent gate +([`oauth_consent.py`](../../backend/src/agents/main_agent/session/hooks/oauth_consent.py)) +and `OAuthRequiredEvent` so "save this chat to my Drive" works mid-conversation. Deferred from +v1 to keep the first cut deterministic and owned by app-api. diff --git a/docs/specs/google-tasks-todo.md b/docs/specs/google-tasks-todo.md new file mode 100644 index 00000000..86f7a0a1 --- /dev/null +++ b/docs/specs/google-tasks-todo.md @@ -0,0 +1,276 @@ +# Spec: Google Tasks Todo Integration + +**Status:** Approved direction, not yet implemented +**Audience:** A fresh implementation session with no prior context — this doc is self-contained. +**Owner:** Phil Merrell +**Last updated:** 2026-06-23 + +--- + +## 1. One-line summary + +Give students, staff, and faculty a conversational todo capability by **federating to Google +Tasks** — the assistant reads and writes tasks in the user's own Google account via the existing +AgentCore Identity OAuth flow. No new database. The headline value is **syncing Canvas deadlines +into Google Tasks**, so the list is never empty and tasks land on the user's phone automatically. + +--- + +## 2. Decisions already made (do not re-litigate) + +These were settled in a strategy discussion. Treat them as fixed inputs: + +| Decision | Choice | Why | +|---|---|---| +| **Source of truth** | **Federate to Google Tasks** (not an owned DynamoDB store) | Counters the "empty walled-garden todo app nobody adopts" trap. Tasks appear instantly in Gmail sidebar, Google Calendar, and the Google Tasks mobile app. | +| **Institutional ecosystem** | **Google Workspace** | Boise State is a Google shop, so Google Tasks/Calendar is the native surface. | +| **Mechanism** | A **first-party agent tool** calling the Google Tasks REST API with a **user-delegated OAuth token from the AgentCore Identity vault** | The OAuth plumbing already exists; no DB, no custom service, no Gateway SigV4 hop needed. | +| **Surface (phase 1)** | **Plain local tool** returning structured results | Fastest path to validate the federation loop. | +| **Surface (later)** | **MCP App panel** (interactive inline checklist) | Highest-payoff UX for a todo list; additive, shares the same Google Tasks calls underneath. | + +### Explicitly deferred / non-goals + +- **No owned todo DynamoDB table.** Google is the store. +- **No Microsoft To Do / Graph.** Google-only. +- **No custom standalone todo service or external MCP server** in phase 1. (An MCP server only + appears in phase 3, and only to get the App panel surface.) +- **No timed push reminders in phase 1** — see the Google Tasks constraints below. + +--- + +## 3. Phasing + +1. **Phase 1 — Federation loop (the foundation).** A local tool that lists / creates / completes / + updates / deletes tasks in the user's Google Tasks `@default` list. Prove OAuth scopes, consent, + and the API calls end-to-end. Returns structured text results. +2. **Phase 2 — Canvas → Google Tasks sync (the headline value).** Pull the user's Canvas assignment + deadlines (the Canvas OAuth provider already exists) and write them into Google Tasks. This is the + feature worth marketing; generic CRUD is table stakes. +3. **Phase 3 — Surface + timed reminders.** Wrap the capability in an **MCP App panel** for an + interactive inline checklist, and use **Google Calendar** (same Google OAuth) for anything that + needs a time-of-day reminder, since Google Tasks cannot (see §6). + +Ship and validate Phase 1 before starting Phase 2. + +--- + +## 4. Existing infrastructure to reuse + +> File:line references are orientation pointers gathered during planning — **confirm them against +> the current code** before relying on them; line numbers drift. + +### OAuth token retrieval (already built, production-ready) +- `apis/shared/oauth/agentcore_identity.py` — `AgentCoreIdentityClient.get_token_for_user()`. + Signature (keyword-only): + ```python + async def get_token_for_user( + *, provider_name: str, scopes: List[str], + callback_url: Optional[str] = None, + force_authentication: bool = False, + user_id: Optional[str] = None, + custom_state: Optional[str] = None, + custom_parameters: Optional[Dict[str, str]] = None, + ) -> TokenResult + ``` +- `TokenResult` (same file): `access_token: Optional[str]`, `authorization_url: Optional[str]`, + and a `requires_consent` property (true when `access_token is None and authorization_url is not + None`). Consent-required is a **normal outcome, not an error**. +- Google baseline `custom_parameters` (`access_type=offline`, plus `prompt=consent` on forced + re-auth) is injected automatically by `custom_parameters_for(...)` / `_vendor_baseline_params(...)` + in the same file. **Do not hand-roll these.** + +### Consent surfacing (already built) +- `apis/shared/agents/main_agent/session/hooks/oauth_consent.py` — `OAuthConsentHook` is a + **BeforeToolCall hook** that calls `get_token_for_user()` *before* the tool runs and, when consent + is required, raises a Strands interrupt: + ```python + event.interrupt(name=f"oauth:{provider_id}", reason={ + "type": "oauth_required", "providerId": provider_id, + "authorizationUrl": url, + }) + ``` + which becomes the `oauth_required` SSE event the SPA already knows how to handle (popup → consent → + `/api/connectors/complete-consent` → resume). **This is the path to reuse for consent — see §5 + Decision A.** + +### OAuth provider records (where scopes live) +- `apis/shared/oauth/models.py` — `OAuthProvider` dataclass; `scopes: List[str]`, + `provider_type: OAuthProviderType` (has `GOOGLE`), `custom_parameters`. +- `apis/shared/oauth/provider_repository.py` — read/write provider records in the + `DYNAMODB_OAUTH_PROVIDERS_TABLE_NAME` table. +- **Scopes are stored as DATA in DynamoDB, not in code.** The Google provider record's `scopes` list + is the source of truth. + +### Tool registration +- New first-party tool → `backend/src/agents/local_tools/.py` decorated with + `@tool` from `strands` (pattern: `local_tools/url_fetcher.py`). +- Export the tool functions in `backend/src/agents/local_tools/__init__.py`'s `__all__`. +- `backend/src/agents/main_agent/tools/tool_registry.py` discovers them via + `registry.register_module_tools(local_tools)` in `create_default_registry()`. +- **Note a CLAUDE.md discrepancy:** CLAUDE.md says new tools go in + `backend/src/agents/main_agent/tools/`. The live code uses `local_tools/` + `builtin_tools/`. + Follow the live `local_tools/` pattern (confirm by reading `tool_registry.py`). + +### Tool return shape +Strands-compatible dict, not a plain value: +```python +# success +{"content": [{"json": { ... }}], "status": "success"} +# error +{"content": [{"json": {"error": "..."}}], "status": "error"} +``` + +### RBAC / tool gating +- Tool access is granted per-role in DynamoDB AppRole records (`granted_tools` → + `effective_permissions.tools`), see `apis/shared/rbac/models.py` and `service.py`. +- After building the tool, **the tool IDs must be added to the appropriate AppRole(s)** (e.g. a + student role) or the tool stays invisible. Persona-scoped grants are a feature here, not a chore — + students vs. faculty can get different task tooling via RBAC. + +### MCP Apps host (for phase 3 only) +- Deployed and enabled by default (`AGENTCORE_MCP_APPS_HOST_ENABLED` default true; sandbox origin + wired through `PlatformComputeRefs` → `AGENTCORE_MCP_APPS_SANDBOX_ORIGIN`). The `ui_resource` SSE + path is emitted from `agents/main_agent/streaming/stream_coordinator.py`. +- **Important:** the `ui_resource` App-panel path is wired to **MCP** tools. A pure local Strands + tool will not get an App panel. Phase 3 therefore means exposing the Google Tasks capability via an + **MCP server that advertises a `ui://` resource**, not the phase-1 local tool. Plan for this fork. + +--- + +## 5. Key design decisions to resolve FIRST + +### Decision A — How consent is triggered (most important) + +The established, working pattern is the **pre-flight `OAuthConsentHook`**: it acquires/refreshes the +token (and surfaces `oauth_required`) *before* the tool body runs, using a tool→provider mapping. +The tool body then calls `get_token_for_user()` expecting a token to be present. + +- **Recommended:** wire the new Google Tasks tool(s) into the consent hook's tool→provider lookup so + consent is handled pre-flight, exactly like existing OAuth-backed tools. Read `oauth_consent.py` + and find how it resolves a tool_use to a provider (the `tool_use_provider_lookup` mechanism). This + reuses the proven path and the SPA's existing `oauth_required` handling with zero new UX. +- **Avoid** the naive "tool returns an error dict with `authorization_url`" approach — the tool body + has no access to the interrupt mechanism, so it cannot cleanly surface consent mid-execution. If + the pre-flight hook cannot be made to cover a first-party local tool, escalate this as a blocker + rather than inventing a parallel consent path. + +**First task of the implementing session: read `oauth_consent.py` and confirm exactly how a tool is +associated with a provider, then extend that association for the new tool.** + +### Decision B — Which Google provider record, and the scope + +- Discover the **actual deployed Google provider id** (do not assume `"google-workspace"`) by + inspecting the OAuth providers table / admin connectors UI. Use that id as `provider_name`. +- Add the Tasks scope **`https://www.googleapis.com/auth/tasks`** (read/write) to that provider + record's `scopes` list. Use `tasks.readonly` only if you want a read-only variant. +- **Vault-key gotcha (known issue, see memory):** the token vault key includes `(provider, scopes, + customParameters)`. Token retrieval must request the **same scopes and customParameters** used at + consent time, or it falsely reports consent-required. Keep one canonical scope list + let + `custom_parameters_for()` own the Google baseline params — don't pass ad-hoc customParameters. +- **Incremental-auth note:** adding the Tasks scope to an existing Google provider that already + requests other scopes (e.g. Drive for RAG) means existing users will be re-prompted to consent to + the widened scope. Decide whether to (a) widen the existing provider's scope list (one consent + covers everything) or (b) request the tasks scope incrementally. Document the choice. + +### Decision C — Where `user_id` comes from inside the tool + +The consent hook gets `user_id` from the agent context. Confirm how the tool body obtains the current +user id in the inference-api agent loop (context injection vs. tool parameter) by following how +`oauth_consent.py` resolves `self._user_id`. Do **not** invent a new way to pass user identity. + +--- + +## 6. Google Tasks API reference & hard constraints + +Design within these — they shape what you can promise users: + +- **Scope:** `https://www.googleapis.com/auth/tasks` (rw) or `.../tasks.readonly`. +- **Base:** `https://tasks.googleapis.com/tasks/v1`. Default task list = `@default`. + - List tasks: `GET /lists/@default/tasks` + - Create: `POST /lists/@default/tasks` + - Update: `PATCH /lists/@default/tasks/{taskId}` + - Complete: PATCH with `{"status": "completed"}` + - Delete: `DELETE /lists/@default/tasks/{taskId}` + - Task lists: `GET/POST /users/@me/lists` +- **Task fields:** `title`, `notes`, `due`, `status` (`needsAction` | `completed`), `parent` + (one level of subtasks), `position`. +- **CONSTRAINT — `due` is date-only.** The API accepts an RFC-3339 timestamp but **discards the + time of day and timezone**. "Submit by 3pm Friday" stores as just "Friday." Do not promise + time-of-day due times via Tasks. +- **CONSTRAINT — no reminder/notification times via the API.** Google Tasks will not push a timed + "remind me at 9am" notification; it only surfaces due-*dates*. Proactive timed nudging is **not** + achievable with Tasks alone. +- **CONSTRAINT — thin metadata.** No priority, no tags, no custom fields, one subtask level. +- **Mitigation for the two constraints above (phase 3):** pair Tasks with **Google Calendar** + (same Google OAuth) for time-sensitive items — Calendar events carry times and notifications. + Frame it as "Tasks for to-dos, Calendar for timed reminders." + +--- + +## 7. Phase 1 implementation checklist + +1. **Confirm the consent wiring (Decision A)** — read `oauth_consent.py`; extend the tool→provider + association for the new tool. +2. **Configure the provider (Decision B)** — find the real Google provider id; add the tasks scope + to its DynamoDB record; document the incremental-auth choice. +3. **Build `backend/src/agents/local_tools/google_tasks.py`** with `@tool`-decorated functions, e.g. + `list_tasks`, `create_task`, `complete_task`, `update_task`, `delete_task`. Each: + - obtains `user_id` per Decision C, + - calls `get_token_for_user(provider_name=, scopes=["https://www.googleapis.com/auth/tasks"], user_id=...)`, + - calls the Google Tasks REST endpoint with the bearer token, + - returns the Strands `{"content": [{"json": ...}], "status": ...}` shape, + - translates the date-only `due` constraint clearly in its docstring (the docstring is the model's + contract — state that time-of-day is not supported). +4. **Register** the functions in `local_tools/__init__.py` `__all__`. +5. **Grant** the new tool IDs to the appropriate AppRole(s) so they're visible to target users. +6. **Tests** — add pytest coverage (mock the Google API + the identity client). See §8. +7. **Manual verification** — exercise the consent popup → token → CRUD loop against a real Google + account in a dev environment. + +--- + +## 8. Conventions & guardrails (from CLAUDE.md / repo memory) + +- **Backend pytest is the ONLY correctness gate — it is not run in CI.** Run the full local suite + for any shared/auth/tool change: + ```bash + cd backend && uv sync --extra agentcore --extra dev + uv run python -m pytest tests/ -v + ``` +- **Service boundaries:** `app_api`, `inference_api`, and `agents/` import only from `apis.shared`, + never from each other (enforced by `tests/architecture/test_import_boundaries.py`). The tool lives + under `agents/` and must use `apis.shared.oauth.*` for token retrieval. +- **Do NOT add routes to inference-api.** If any user-facing CRUD endpoint is needed (it should not + be for phase 1), it goes in `app_api` with `Depends(get_current_user_from_session)`. +- **Exact version pins only** — no `^`, `~`, `>=`. **Never install a package without explicit user + approval** (prefer `httpx`/stdlib already in the project over a new Google client library; confirm + before adding any dependency). +- **Git:** branch from `develop` (never `main`); branch name `feature/google-tasks-todo`; PR targets + `develop`; conventional commits (`feat:`, `fix:`, …); one logical change per commit; no + commented-out code. +- **No `print()`** — use `logging`. Type hints on all signatures. + +--- + +## 9. Open questions for the implementing session + +1. **Consent wiring (Decision A):** does the pre-flight `OAuthConsentHook` tool→provider mechanism + support a first-party local tool, or is it MCP/Gateway-specific? Resolve before writing the tool. +2. **Provider id & scope strategy (Decision B):** what is the real Google provider id, and do we + widen its existing scope list or request the tasks scope incrementally? +3. **HTTP client:** confirm an approved HTTP library is already in `agents/` deps before coding the + REST calls; do not add a Google SDK without approval. +4. **Multiple task lists:** phase 1 targets `@default` only — confirm that's acceptable, or whether + per-persona task lists are wanted. + +--- + +## 10. Why this shape (one paragraph for reviewers) + +The expensive infrastructure — AgentCore Identity OAuth, the token vault, the `oauth_required` +consent UX, the Google provider, the Canvas provider, and the MCP Apps host — already exists. The +only genuinely new code is a thin tool that calls Google Tasks with a vaulted token. Federating to +Google (rather than owning a DynamoDB todo store) trades data ownership and a custom UI for instant +cross-surface presence (mobile, Gmail, Calendar) and zero new FERPA-relevant storage, and it makes +the real differentiator — Canvas deadlines flowing into the user's own task surface — a natural +phase 2 rather than a separate product. diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 3717f413..54a3dcee 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 74a5e82e..403db58a 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.0.0", + "version": "1.0.1", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts b/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts index db3ba0ee..8b9f6766 100644 --- a/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts +++ b/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts @@ -58,6 +58,13 @@ export interface Connector { * assistant editor. Null/absent means it is not a file source. */ fileSourceAdapterId?: string | null; + /** + * Export-target adapter key (e.g. `google-drive`) mapping this connector to + * a destination. When set, the connector appears in the "Save to…" dialog + * as a place to save a conversation. Null/absent means it is not an export + * target. The write-direction mirror of `fileSourceAdapterId`. + */ + exportTargetAdapterId?: string | null; createdAt: string; updatedAt: string; } @@ -96,6 +103,8 @@ export interface ConnectorCreateRequest { customParameters?: Record; /** File-source adapter key; omit when the connector is not a file source. */ fileSourceAdapterId?: string; + /** Export-target adapter key; omit when the connector is not an export target. */ + exportTargetAdapterId?: string; } /** @@ -123,6 +132,11 @@ export interface ConnectorUpdateRequest { * source); a populated adapter key sets it; `undefined` leaves it alone. */ fileSourceAdapterId?: string; + /** + * `""` clears the export-target mapping (the connector stops being an export + * target); a populated adapter key sets it; `undefined` leaves it alone. + */ + exportTargetAdapterId?: string; } /** @@ -144,6 +158,28 @@ export interface FileSourceAdapterListResponse { adapters: FileSourceAdapter[]; } +/** + * An export-target adapter shipped in the backend registry, as returned by + * `GET /admin/export-target-adapters`. Read-only — adapters are code, not + * config; an admin can only map a connector to an existing one. The + * write-direction mirror of {@link FileSourceAdapter}. + */ +export interface ExportTargetAdapter { + key: string; + displayName: string; + icon: string; + /** OAuth provider types this adapter may be mapped to. */ + compatibleProviderTypes: ConnectorType[]; + /** OAuth scopes the connector must grant for the adapter to work. */ + requiredScopes: string[]; + /** Output formats this destination can produce (e.g. `google_doc`). */ + supportedFormats: string[]; +} + +export interface ExportTargetAdapterListResponse { + adapters: ExportTargetAdapter[]; +} + /** * Form data bound to the connector form. Scopes are a comma-separated * string for admin entry; parsed into `string[]` before submit. diff --git a/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts b/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts index 044e196d..be48e8eb 100644 --- a/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts +++ b/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts @@ -73,6 +73,11 @@ interface ConnectorFormGroup { * source. On update, sending `''` clears an existing mapping. */ fileSourceAdapterId: FormControl; + /** + * Export-target adapter key, or `''` when the connector is not an export + * target. On update, sending `''` clears an existing mapping. + */ + exportTargetAdapterId: FormControl; } const ICON_DATA_MAX_BYTES = 100 * 1024; @@ -577,6 +582,47 @@ const ICON_ACCEPTED_MIME_TYPES = [ } +
    +

    Export Target

    +

    + Optionally map this connector to an export-target adapter so users can + save a conversation to it from the chat "Save to…" dialog. +

    + @if (compatibleExportAdapters().length > 0) { +
    + + +

    + When set, this connector appears as a destination in the "Save to…" + dialog for any user allowed to use it. +

    + @if (exportScopeCoverageWarning(); as warning) { +
    +
    +
    +
    + } +
    + } @else { +

    + No export-target adapter is available for this connector type. +

    + } +
    + @if (isEditMode()) {
    @@ -675,6 +721,7 @@ export class ConnectorFormPage implements OnInit { iconData: this.fb.control('', { nonNullable: true }), customParameters: this.fb.control('', { nonNullable: true }), fileSourceAdapterId: this.fb.control('', { nonNullable: true }), + exportTargetAdapterId: this.fb.control('', { nonNullable: true }), }); readonly pageTitle = computed(() => (this.isEditMode() ? 'Edit Connector' : 'Add Connector')); @@ -716,6 +763,7 @@ export class ConnectorFormPage implements OnInit { // section reacts to. Updated from valueChanges in ngOnInit. private readonly scopesSignal = signal(''); private readonly fileSourceAdapterIdSignal = signal(''); + private readonly exportTargetAdapterIdSignal = signal(''); /** * The file-source adapter mapping loaded from the server, so update can @@ -723,6 +771,9 @@ export class ConnectorFormPage implements OnInit { */ private readonly adapterLoadedFromServer = signal(''); + /** Same tri-state tracking for the export-target mapping. */ + private readonly exportAdapterLoadedFromServer = signal(''); + /** Every file-source adapter shipped in the backend registry. */ readonly fileSourceAdapters = computed(() => this.connectorsService.getFileSourceAdapters() @@ -764,6 +815,48 @@ export class ConnectorFormPage implements OnInit { ); }); + /** Every export-target adapter shipped in the backend registry. */ + readonly exportTargetAdapters = computed(() => + this.connectorsService.getExportTargetAdapters() + ); + + /** Adapters that may be mapped to the currently-selected provider type. */ + readonly compatibleExportAdapters = computed(() => + this.exportTargetAdapters().filter(a => + a.compatibleProviderTypes.includes(this.providerTypeSignal()) + ) + ); + + /** The export-target adapter currently chosen in the dropdown, if any. */ + readonly selectedExportAdapter = computed(() => { + const key = this.exportTargetAdapterIdSignal(); + if (!key) return null; + return this.exportTargetAdapters().find(a => a.key === key) ?? null; + }); + + /** + * Warns when the connector's scopes don't cover the write scope the + * selected export target needs — caught here at config time rather than as + * a failed save later. + */ + readonly exportScopeCoverageWarning = computed(() => { + const adapter = this.selectedExportAdapter(); + if (!adapter) return null; + const granted = new Set( + this.scopesSignal() + .split(',') + .map(s => s.trim()) + .filter(Boolean) + ); + const missing = adapter.requiredScopes.filter(s => !granted.has(s)); + if (missing.length === 0) return null; + return ( + `This connector's scopes are missing ${missing.join(', ')}, which ` + + `${adapter.displayName} needs to save files. Add them above or ` + + `saving will fail.` + ); + }); + /** * Returns a user-facing error string when clientId and clientSecret are * inconsistent. Rotation requires both or neither. @@ -802,6 +895,9 @@ export class ConnectorFormPage implements OnInit { this.connectorForm.controls.fileSourceAdapterId.valueChanges.subscribe((value) => { this.fileSourceAdapterIdSignal.set(value); }); + this.connectorForm.controls.exportTargetAdapterId.valueChanges.subscribe((value) => { + this.exportTargetAdapterIdSignal.set(value); + }); } /** @@ -810,16 +906,26 @@ export class ConnectorFormPage implements OnInit { * connector to Slack). Keeps the form from submitting an invalid mapping. */ private clearIncompatibleAdapter(): void { - const current = this.connectorForm.controls.fileSourceAdapterId.value; - if (!current) return; - const adapter = this.connectorsService - .getFileSourceAdapters() - .find(a => a.key === current); - const stillCompatible = adapter?.compatibleProviderTypes.includes( - this.connectorForm.controls.providerType.value - ); - if (!stillCompatible) { - this.connectorForm.controls.fileSourceAdapterId.setValue(''); + const providerType = this.connectorForm.controls.providerType.value; + + const currentFileSource = this.connectorForm.controls.fileSourceAdapterId.value; + if (currentFileSource) { + const adapter = this.connectorsService + .getFileSourceAdapters() + .find(a => a.key === currentFileSource); + if (!adapter?.compatibleProviderTypes.includes(providerType)) { + this.connectorForm.controls.fileSourceAdapterId.setValue(''); + } + } + + const currentExport = this.connectorForm.controls.exportTargetAdapterId.value; + if (currentExport) { + const adapter = this.connectorsService + .getExportTargetAdapters() + .find(a => a.key === currentExport); + if (!adapter?.compatibleProviderTypes.includes(providerType)) { + this.connectorForm.controls.exportTargetAdapterId.setValue(''); + } } } @@ -857,9 +963,11 @@ export class ConnectorFormPage implements OnInit { iconData: connector.iconData ?? '', customParameters: this.serializeCustomParameters(connector.customParameters ?? null), fileSourceAdapterId: connector.fileSourceAdapterId ?? '', + exportTargetAdapterId: connector.exportTargetAdapterId ?? '', }); this.iconLoadedFromServer.set(connector.iconData ?? null); this.adapterLoadedFromServer.set(connector.fileSourceAdapterId ?? ''); + this.exportAdapterLoadedFromServer.set(connector.exportTargetAdapterId ?? ''); this.selectedRoles.set(connector.allowedRoles.length > 0 ? connector.allowedRoles : ['*']); this.applyDiscoveryValidator(); } catch (error) { @@ -999,6 +1107,11 @@ export class ConnectorFormPage implements OnInit { if (currentAdapter !== this.adapterLoadedFromServer()) { updates.fileSourceAdapterId = currentAdapter; } + // Same tri-state for the export-target mapping. + const currentExportAdapter = formValue.exportTargetAdapterId || ''; + if (currentExportAdapter !== this.exportAdapterLoadedFromServer()) { + updates.exportTargetAdapterId = currentExportAdapter; + } if (formValue.clientId && formValue.clientSecret) { updates.clientId = formValue.clientId; updates.clientSecret = formValue.clientSecret; @@ -1032,6 +1145,9 @@ export class ConnectorFormPage implements OnInit { if (formValue.fileSourceAdapterId) { createData.fileSourceAdapterId = formValue.fileSourceAdapterId; } + if (formValue.exportTargetAdapterId) { + createData.exportTargetAdapterId = formValue.exportTargetAdapterId; + } const created = await this.connectorsService.createConnector(createData); this.createdConnector.set(created); } diff --git a/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts b/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts index 2b4cf678..c31c5437 100644 --- a/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts +++ b/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts @@ -9,6 +9,8 @@ import { ConnectorUpdateRequest, FileSourceAdapter, FileSourceAdapterListResponse, + ExportTargetAdapter, + ExportTargetAdapterListResponse, } from '../models/connector.model'; function toSnakeCase(obj: Record): Record { @@ -50,6 +52,10 @@ export class ConnectorsService { () => `${this.config.appApiUrl()}/admin/file-source-adapters` ); + private readonly exportTargetAdaptersUrl = computed( + () => `${this.config.appApiUrl()}/admin/export-target-adapters` + ); + readonly connectorsResource = resource({ loader: async () => { await Promise.resolve(); @@ -69,6 +75,19 @@ export class ConnectorsService { } }); + /** + * The export-target adapter registry. Read-only and rarely changes — adapters + * ship in releases — so a single eager load when the admin service is first + * injected is sufficient. The write-direction mirror of + * {@link fileSourceAdaptersResource}. + */ + readonly exportTargetAdaptersResource = resource({ + loader: async () => { + await Promise.resolve(); + return this.fetchExportTargetAdapters(); + } + }); + getConnectors(): Connector[] { return this.connectorsResource.value()?.providers ?? []; } @@ -105,6 +124,20 @@ export class ConnectorsService { ); } + getExportTargetAdapters(): ExportTargetAdapter[] { + return this.exportTargetAdaptersResource.value()?.adapters ?? []; + } + + /** + * Fetch the export-target adapter registry. Like the file-source endpoint + * it serializes camelCase already, so no key translation is applied. + */ + async fetchExportTargetAdapters(): Promise { + return firstValueFrom( + this.http.get(`${this.exportTargetAdaptersUrl()}/`) + ); + } + async fetchConnector(providerId: string): Promise { const response = await firstValueFrom( this.http.get(`${this.baseUrl()}/${providerId}`) diff --git a/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.html b/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.html index 4b38f980..8b203f48 100644 --- a/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.html +++ b/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.html @@ -16,7 +16,9 @@ >

    - @if (view() === 'browser' && connector(); as conn) { + @if (isPicker) { + Choose a destination folder + } @else if (view() === 'browser' && connector(); as conn) { {{ conn.displayName }} } @else { Import from a connector @@ -182,7 +184,7 @@

    {{ crumb.name }} @@ -229,12 +231,23 @@

    - @if (activeSearch()) { + @if (isPicker) { + @if (activeSearch()) { + No folders match your search. + } @else { + This folder has no subfolders. + } + } @else if (activeSearch()) { No files match your search. } @else { This folder is empty. }

    + @if (isPicker && currentFolderId() !== null) { +

    + You can still save here, or open a subfolder. +

    + }

    } @else {
      @@ -296,30 +309,59 @@
    }
    diff --git a/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.spec.ts b/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.spec.ts index 6f55b508..3e347bba 100644 --- a/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.spec.ts +++ b/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.spec.ts @@ -23,7 +23,10 @@ function fileEntry(over: Partial): FileEntry { return { id: 'f1', name: 'a.txt', type: 'file', selectable: true, ...over }; } -function setup(fileSourceOverrides: Partial> = {}) { +function setup( + fileSourceOverrides: Partial> = {}, + dialogData: Record = { assistantId: 'AST-1' }, +) { const fileSourceService = { listFileSources: vi.fn().mockResolvedValue([CONNECTED]), listRoots: vi.fn().mockResolvedValue([{ id: 'root', name: 'My Drive' }]), @@ -48,7 +51,7 @@ function setup(fileSourceOverrides: Partial> = {}) { providers: [ provideHttpClient(), provideHttpClientTesting(), - { provide: DIALOG_DATA, useValue: { assistantId: 'AST-1' } }, + { provide: DIALOG_DATA, useValue: dialogData }, { provide: DialogRef, useValue: dialogRef }, { provide: FileSourceService, useValue: fileSourceService }, { provide: UserConnectorsService, useValue: connectorsService }, @@ -136,4 +139,64 @@ describe('FileSourceBrowserDialogComponent', () => { ]); expect(dialogRef.close).toHaveBeenCalledWith([{ documentId: 'DOC-1' }]); }); + + describe('pick-folder mode', () => { + it('opens straight into the targeted connector and loads roots', async () => { + const { component, fileSourceService } = setup( + // Two roots so it stays on the roots list rather than auto-drilling in. + { + listRoots: vi.fn().mockResolvedValue([ + { id: 'root', name: 'My Drive' }, + { id: 'shared', name: 'Shared with me' }, + ]), + }, + { connector: CONNECTED, mode: 'pick-folder' }, + ); + + await vi.waitFor(() => { + expect(fileSourceService.listRoots).toHaveBeenCalledWith('google'); + expect((component['view'] as () => string)()).toBe('browser'); + }); + expect((component['isPicker'] as boolean)).toBe(true); + // Nothing selectable until the user drills into a folder. + expect((component['canPickCurrentFolder'] as () => boolean)()).toBe(false); + }); + + it('shows only folders, not files, while browsing', async () => { + const { component } = setup( + { + browse: vi.fn().mockResolvedValue({ + entries: [ + fileEntry({ id: 'sub', name: 'Subfolder', type: 'folder', selectable: false }), + fileEntry({ id: 'doc', name: 'notes.txt', type: 'file', selectable: true }), + ], + breadcrumbs: [], + nextCursor: null, + }), + }, + { connector: CONNECTED, mode: 'pick-folder' }, + ); + // Single root auto-drills into the folder, populating entries. + await vi.waitFor(() => + expect((component['currentFolderId'] as () => string | null)()).toBe('root'), + ); + + const shown = (component['displayedEntries'] as () => FileEntry[])(); + expect(shown.map((e) => e.id)).toEqual(['sub']); + }); + + it('closes with the current folder as the selection', async () => { + const { component, dialogRef } = setup( + { browse: vi.fn().mockResolvedValue({ entries: [], breadcrumbs: [], nextCursor: null }) }, + { connector: CONNECTED, mode: 'pick-folder' }, + ); + await vi.waitFor(() => + expect((component['currentFolderId'] as () => string | null)()).toBe('root'), + ); + + expect((component['canPickCurrentFolder'] as () => boolean)()).toBe(true); + (component['pickCurrentFolder'] as () => void)(); + expect(dialogRef.close).toHaveBeenCalledWith({ folderId: 'root', folderName: 'My Drive' }); + }); + }); }); diff --git a/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.ts b/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.ts index 53951234..9546c8f4 100644 --- a/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.ts +++ b/frontend/ai.client/src/app/assistants/components/file-source-browser-dialog.component.ts @@ -35,29 +35,60 @@ import { UserConnectorsService } from '../../settings/connectors/services/user-c import { OAuthConsentService } from '../../services/oauth-consent/oauth-consent.service'; import { ToastService } from '../../services/toast/toast.service'; -/** Data passed in when the assistant editor opens the browser. */ +/** + * What the dialog is for. `import` (default) multi-selects files to ingest + * into an assistant; `pick-folder` walks the same folder tree but selects a + * single destination folder (the conversation-export folder picker) and never + * imports anything. + */ +export type FileSourceBrowserMode = 'import' | 'pick-folder'; + +/** Data passed in when a caller opens the browser. */ export interface FileSourceBrowserDialogData { - assistantId: string; + /** Required in `import` mode — the assistant the files are imported into. */ + assistantId?: string; /** * When set, the dialog opens straight into this connector — its folder * browser if already connected, or an inline Connect prompt if not — and * hides the source picker. The assistant editor surfaces each connector as * its own button, so the in-modal source list is redundant when targeted. + * `pick-folder` mode always targets a connector this way. */ connector?: FileSourceConnector; + /** Defaults to `import`. */ + mode?: FileSourceBrowserMode; +} + +/** A destination folder chosen in `pick-folder` mode. */ +export interface FolderSelection { + /** Provider folder id to write into; a provider root id is valid too. */ + folderId: string; + /** Human-readable name of the chosen folder, for display. */ + folderName: string; } +/** + * Closed value: the imported {@link Document} records in `import` mode, a + * {@link FolderSelection} in `pick-folder` mode, or `undefined` if cancelled. + */ +export type FileSourceBrowserDialogResult = Document[] | FolderSelection; + type DialogView = 'sources' | 'browser'; type ConnectPhase = 'initiating' | 'awaiting'; /** - * Modal that lets a user import files from a connected file source into an - * assistant's RAG index. + * Modal that walks a connected file source's folder tree. + * + * In `import` mode (default) the user multi-selects files to ingest into an + * assistant's RAG index. In `pick-folder` mode the same browser is reused to + * choose a single destination folder — the conversation-export folder picker — + * and nothing is imported. * * Flow: pick a file source (connecting it via the OAuth consent popup if - * needed) → browse the provider's folder tree or search → multi-select - * files → import. Closes with the created {@link Document} records, or - * `undefined` if cancelled. + * needed) → browse the provider's folder tree or search → either multi-select + * files and import, or select the current folder. Closes with the created + * {@link Document} records, a {@link FolderSelection}, or `undefined` if + * cancelled. */ @Component({ selector: 'app-file-source-browser-dialog', @@ -119,13 +150,19 @@ type ConnectPhase = 'initiating' | 'awaiting'; `, }) export class FileSourceBrowserDialogComponent { - private readonly dialogRef = inject>(DialogRef); + private readonly dialogRef = + inject>(DialogRef); private readonly data = inject(DIALOG_DATA); private readonly fileSourceService = inject(FileSourceService); private readonly connectorsService = inject(UserConnectorsService); private readonly consentService = inject(OAuthConsentService); private readonly toast = inject(ToastService); + /** `import` (default) or `pick-folder` for the export destination picker. */ + protected readonly mode: FileSourceBrowserMode = this.data.mode ?? 'import'; + /** True when choosing a destination folder rather than importing files. */ + protected readonly isPicker = this.mode === 'pick-folder'; + /** True when the dialog was opened targeting a single connector. */ protected readonly lockedToConnector = !!this.data.connector; @@ -146,6 +183,12 @@ export class FileSourceBrowserDialogComponent { protected readonly roots = signal([]); /** Current folder id; `null` means the top-level roots list is shown. */ protected readonly currentFolderId = signal(null); + /** + * Name of the current folder, tracked client-side as the user navigates (the + * adapter returns a flat page, not a path). Drives the `pick-folder` + * selection label; empty at the roots list. + */ + protected readonly currentFolderName = signal(''); protected readonly breadcrumbs = signal([]); protected readonly entries = signal([]); protected readonly nextCursor = signal(null); @@ -183,11 +226,20 @@ export class FileSourceBrowserDialogComponent { selectable: false, })); } + // The folder picker navigates folders only; files would just be noise. + if (this.isPicker) { + return this.entries().filter((entry) => entry.type === 'folder'); + } return this.entries(); }); protected readonly selectedCount = computed(() => this.selected().size); + /** True once the user has drilled into a folder/root they can select. */ + protected readonly canPickCurrentFolder = computed( + () => this.currentFolderId() !== null && !this.browserLoading(), + ); + constructor() { const preselected = this.data.connector; if (preselected) { @@ -312,7 +364,7 @@ export class FileSourceBrowserDialogComponent { this.roots.set(roots); // A single root is an implementation detail — drop the user straight in. if (roots.length === 1) { - await this.openFolder(roots[0].id); + await this.openFolder(roots[0].id, roots[0].name); } else { this.browserLoading.set(false); } @@ -333,7 +385,7 @@ export class FileSourceBrowserDialogComponent { } } - protected async openFolder(folderId: string): Promise { + protected async openFolder(folderId: string, folderName = ''): Promise { const connector = this.connector(); if (!connector) { return; @@ -341,6 +393,7 @@ export class FileSourceBrowserDialogComponent { this.activeSearch.set(null); this.searchTerm.set(''); this.currentFolderId.set(folderId); + this.currentFolderName.set(folderName); this.entries.set([]); this.breadcrumbs.set([]); this.nextCursor.set(null); @@ -364,6 +417,7 @@ export class FileSourceBrowserDialogComponent { this.activeSearch.set(null); this.searchTerm.set(''); this.currentFolderId.set(null); + this.currentFolderName.set(''); this.entries.set([]); this.breadcrumbs.set([]); this.nextCursor.set(null); @@ -373,12 +427,24 @@ export class FileSourceBrowserDialogComponent { protected onEntryActivate(entry: FileEntry): void { if (entry.type === 'folder') { - void this.openFolder(entry.id); + void this.openFolder(entry.id, entry.name); } else { this.toggleSelect(entry); } } + /** Close with the current folder as the chosen export destination. */ + protected pickCurrentFolder(): void { + const folderId = this.currentFolderId(); + if (folderId === null) { + return; + } + this.dialogRef.close({ + folderId, + folderName: this.currentFolderName() || 'Selected folder', + }); + } + protected toggleSelect(entry: FileEntry): void { if (entry.type !== 'file' || !entry.selectable) { return; @@ -466,14 +532,15 @@ export class FileSourceBrowserDialogComponent { protected async importSelected(): Promise { const connector = this.connector(); + const assistantId = this.data.assistantId; const files = [...this.selected().values()]; - if (!connector || files.length === 0 || this.importing()) { + if (!connector || !assistantId || files.length === 0 || this.importing()) { return; } this.importing.set(true); try { const response = await this.fileSourceService.importDocuments( - this.data.assistantId, + assistantId, connector.providerId, files, ); @@ -501,6 +568,7 @@ export class FileSourceBrowserDialogComponent { private resetBrowserState(): void { this.roots.set([]); this.currentFolderId.set(null); + this.currentFolderName.set(''); this.breadcrumbs.set([]); this.entries.set([]); this.nextCursor.set(null); diff --git a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html index 07eab159..532d175f 100644 --- a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html +++ b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html @@ -100,6 +100,16 @@