# Workspace Export

Investibook supports exporting a workspace as a compressed archive. Two modes are available depending on whether an S3 bucket is configured:

- **Local mode** — archive is stored on the backend filesystem and streamed directly to the browser.
- **S3 mode** — archive is uploaded to S3 and a 1-hour presigned download URL is returned.

## Overview

| Property | Local mode | S3 mode |
|---|---|---|
| Archive format | `.tar.gz` | `.tar.gz` |
| Trigger condition | `INVESTIBOOK_S3_BUCKET` not set | `INVESTIBOOK_S3_BUCKET` is set |
| Storage | `DATA_DIR/exports/{task_id}.tar.gz` | `s3://{bucket}/{prefix}/{workspace_id}/{ts}.tar.gz` |
| Download method | Backend streams the file (`FileResponse`) | 1-hour presigned S3 URL |
| Direct download endpoint | Enabled | **Disabled** (returns `403`) |
| Task persistence | In-memory (process lifetime) | In-memory (process lifetime) |
| Auth | Bearer token; workspace owner only | Bearer token; workspace owner only |

## User Flow

1. Click the **Export** button in the workspace header (owner only).
2. The frontend calls `POST /api/workspace/{id}/export`. The backend immediately returns a `task_id` and begins compressing in the background.
3. The Settings modal opens automatically on the **Exports** tab, which polls `GET /api/exports` every 3 seconds while any task is `processing`.
4. Once finished the row shows a **↓ Download** button:
   - **Local mode**: links to `/api/workspace/{id}/export/{task_id}/download` — the browser downloads the archive directly from the backend.
   - **S3 mode**: links to the presigned URL (valid for 1 hour). After expiry a new export must be triggered.

## API

### Start an export

```
POST /api/workspace/{workspace_id}/export
Authorization: Bearer <token>
```

**Response 200**

```json
{
  "task_id": "exp-a3f9c2d01b8e",
  "status": "processing"
}
```

**Errors**: `403` if not workspace owner, `404` if workspace not found.

---

### Download archive (local mode only)

```
GET /api/workspace/{workspace_id}/export/{task_id}/download
Authorization: Bearer <token>
```

Streams the locally-stored `.tar.gz` archive as `application/gzip`.

**Returns `403`** when `INVESTIBOOK_S3_BUCKET` is configured — use the presigned URL instead.

**Errors**: `404` if task not found or archive not ready.

---

### Poll a single export task

```
GET /api/workspace/{workspace_id}/export/{task_id}
Authorization: Bearer <token>
```

**Response 200** — completed (local mode)

```json
{
  "task_id": "exp-a3f9c2d01b8e",
  "status": "completed",
  "download_url": "/api/workspace/ws-.../export/exp-.../download",
  "local_file": "/data/exports/exp-a3f9c2d01b8e.tar.gz",
  "s3_key": null,
  "error": null,
  ...
}
```

**Response 200** — completed (S3 mode)

```json
{
  "task_id": "exp-a3f9c2d01b8e",
  "status": "completed",
  "download_url": "https://s3.amazonaws.com/...?X-Amz-Expires=3600&...",
  "local_file": null,
  "s3_key": "investibook_service/export_workspaces/ws-.../1741680000.tar.gz",
  "error": null,
  ...
}
```

**Response 200** — failed

```json
{
  "task_id": "exp-a3f9c2d01b8e",
  "status": "error",
  "download_url": null,
  "error": "aioboto3 is not installed; cannot export workspace",
  ...
}
```

---

### List all exports for the current user

```
GET /api/exports
Authorization: Bearer <token>
```

Returns all export tasks owned by the authenticated user, newest first. Used by the Settings → Exports panel.

```json
{
  "exports": [
    { "task_id": "...", "status": "completed", ... },
    { "task_id": "...", "status": "processing", ... }
  ]
}
```

## Archive Contents

The background functions in `backend/investibook/api/exports.py` iterate the workspace directory (`DATA_DIR/workspaces/{workspace_id}/`) and build a `tar.gz` on a thread pool to avoid blocking the async event loop. Chat history is fetched from SQLite and appended as a synthetic `chat_history.md` file.

**Excluded paths** (skipped unconditionally):

| Pattern | Reason |
|---|---|
| `.opencode/skills/**` | Large, fully reproducible from config |
| `.git/` | VCS metadata, not needed for portability |
| `node_modules/` | Reproducible via package manager |
| `__pycache__/` | Python bytecache |
| `.venv/` | Python virtual environment |

**S3 key format** (S3 mode only):

```
{export_s3_prefix}/{workspace_id}/{unix_timestamp}.tar.gz
```

Default prefix: `investibook_service/export_workspaces`.

**Local storage path** (local mode only):

```
DATA_DIR/exports/{task_id}.tar.gz
```

## Configuration

All settings follow the `INVESTIBOOK_` env var prefix (see `backend/services/config.py`).

| Setting | Env var | Default | Description |
|---|---|---|---|
| `s3_bucket` | `INVESTIBOOK_S3_BUCKET` | `""` | S3 bucket for exports. **Leave empty to use local mode.** |
| `export_s3_prefix` | `INVESTIBOOK_EXPORT_S3_PREFIX` | `investibook_service/export_workspaces` | S3 key prefix for exported archives |
| `aws_region` | `INVESTIBOOK_AWS_REGION` | `us-west-2` | AWS region |

AWS credentials are picked up by `aioboto3` via the standard boto3 credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / instance profile / `~/.aws/credentials`).

> **Note:** S3 mode also requires `aioboto3` to be installed (`uv add aioboto3`). In local mode this dependency is not needed.

## Task Lifecycle

```
POST /export  →  status: "processing"
                        │
               [background coroutine]
                        │
           ┌────────────┴────────────┐
           │  s3_bucket set?          │
          yes                        no
           │                          │
  upload to S3              save to DATA_DIR/exports/
  generate presigned URL    set download_url = /api/.../download
           │                          │
           └──────────┬───────────────┘
                      │
              ┌───────┴───────┐
           success          exception
              │                │
    status: "completed"  status: "error"
    download_url: "..."  error: "<message>"
```

Tasks are kept in `_export_tasks` (an in-memory `dict`) for the lifetime of the uvicorn process. They are not persisted to SQLite. Restarting the backend clears all task history.

## Frontend Components

### `components/SettingsModal/ExportsSection.tsx`

Renders the Exports tab inside the Settings modal.

- On mount: fetches `GET /api/exports`.
- While any task has `status === "processing"`: polls every 3 seconds; stops polling automatically when all tasks reach a terminal state.
- Shows a table row per task with workspace name, creation time, status badge, and (for completed tasks) a **↓ Download** button that opens `download_url` in a new tab (works for both local and presigned URLs).

### `components/WorkspaceHeader/index.tsx`

Added an **Export** button in the right-side action bar (owner only). Clicking it:
1. Calls `POST /api/workspace/{id}/export` to kick off the background task.
2. Sets `settingsSection = 'exports'` and opens the Settings modal so the user lands directly on the task tracker.

### `components/SettingsModal/index.tsx`

`'exports'` is included in the `Section` union type alongside `'api-keys'` and `'skills'`. Passing `initialSection="exports"` causes the modal to open directly on the Exports tab.

## Security Notes

- Only the workspace **owner** can start or query an export (`require_workspace_owner` dependency).
- **Local mode download endpoint** is guarded by auth and the same owner check.
- When S3 is configured the local download endpoint returns `403` to prevent bypassing the presigned-URL flow.
- Presigned URLs expire after 1 hour (`ExpiresIn=3600`). The S3 object persists but the URL becomes invalid; users must trigger a new export for a fresh link.
- No user-supplied path components are used in the S3 key or archive entry names — all paths derive from `workspace_id` (validated by `require_workspace_owner`) and the on-disk directory walk.
- Path traversal in the archive is prevented because entry names are built from `path.relative_to(ws_dir)`, which raises `ValueError` if the path escapes the workspace root.
