diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5ea8f20..5c236fc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -35,8 +35,52 @@ "Bash(dist/macos-arm64/docx-mcp:*)", "Bash(echo:*)", "Bash(grep:*)", - "Bash(mcptools call query:*)" + "Bash(mcptools call query:*)", + "Bash(git checkout:*)", + "Bash(git stash:*)", + "Bash(git rebase:*)", + "Bash(xargs cat:*)", + "WebSearch", + "WebFetch(domain:docs.rs)", + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(/Users/laurentvaldes/Projects/docx-mcp/dist/macos-arm64/docx-cli:*)", + "Bash(xargs:*)", + "Bash(find:*)", + "Bash(cargo tree:*)", + "Bash(pulumi version:*)", + "Bash(pulumi stack output:*)", + "Bash(STORAGE_GRPC_URL=http://localhost:50052 dotnet test:*)", + "Bash(docker compose:*)", + "Bash(wrangler:*)", + "Bash(gcloud services list:*)", + "Bash(gcloud alpha iap oauth-clients list:*)", + "Bash(gcloud auth application-default print-access-token:*)", + "Bash(source:*)", + "WebFetch(domain:www.pulumi.com)", + "WebFetch(domain:pypi.org)", + "WebFetch(domain:www.koyeb.com)", + "Bash(pip install:*)", + "Bash(pulumi preview:*)", + "Bash(gh release view:*)", + "Bash(pulumi plugin install:*)", + "WebFetch(domain:registry.terraform.io)", + "Bash(pulumi config get:*)", + "Bash(pulumi config set:*)", + "Bash(pulumi config rm:*)", + "Bash(koyeb service list:*)", + "Bash(koyeb deployments list:*)", + "WebFetch(domain:developers.cloudflare.com)", + "WebFetch(domain:mcp-auth.dev)", + "Bash(koyeb instances logs:*)", + "Bash(gh api:*)", + "Bash(gh search code:*)", + "Bash(gh pr view:*)", + "Bash(gh release list:*)" ], "deny": [] - } + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "tavily" + ] } diff --git a/.github/workflows/build-website.yml b/.github/workflows/build-website.yml index 061c2aa..7fb8a77 100644 --- a/.github/workflows/build-website.yml +++ b/.github/workflows/build-website.yml @@ -2,12 +2,25 @@ name: Build Website on: pull_request: - paths: - - 'website/**' - - '.github/workflows/build-website.yml' jobs: + changes: + runs-on: ubuntu-latest + outputs: + website: ${{ steps.filter.outputs.website }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + website: + - 'website/**' + - '.github/workflows/build-website.yml' + build: + needs: changes + if: needs.changes.outputs.website == 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 3c61760..dad2288 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -4,8 +4,32 @@ on: push: branches: [ main, master ] tags: [ 'v*' ] + paths: + - 'src/**' + - 'tests/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'Dockerfile' + - 'docker-compose*.yml' + - 'installers/**' + - 'publish.sh' + - '.github/workflows/docker-build.yml' + - '.github/scripts/**' pull_request: branches: [ main, master ] + paths: + - 'src/**' + - 'tests/**' + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'Dockerfile' + - 'docker-compose*.yml' + - 'installers/**' + - 'publish.sh' + - '.github/workflows/docker-build.yml' + - '.github/scripts/**' workflow_dispatch: inputs: create_release: @@ -19,15 +43,165 @@ env: DOTNET_VERSION: '10.0.x' jobs: + # ============================================================================= + # Build Rust Storage — Linux (staticlib + binary, always runs) + # ============================================================================= + build-storage: + name: Build Storage (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + artifact-name: linux-x64 + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + artifact-name: linux-arm64 + + steps: + - uses: actions/checkout@v4 + + - name: Install protoc + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation tools (Linux ARM64 on x64) + if: matrix.target == 'aarch64-unknown-linux-gnu' && matrix.runner == 'ubuntu-latest' + run: sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build staticlib (for embedding into .NET) + run: cargo build --release --target ${{ matrix.target }} -p docx-storage-local --lib + + - name: Build binary (for standalone server) + run: cargo build --release --target ${{ matrix.target }} -p docx-storage-local + + - name: Prepare staticlib artifact + run: | + mkdir -p dist/staticlib-${{ matrix.artifact-name }} + cp target/${{ matrix.target }}/release/libdocx_storage_local.a dist/staticlib-${{ matrix.artifact-name }}/ + + - name: Prepare binary artifact + run: | + mkdir -p dist/${{ matrix.artifact-name }} + cp target/${{ matrix.target }}/release/docx-storage-local dist/${{ matrix.artifact-name }}/ + chmod +x dist/${{ matrix.artifact-name }}/docx-storage-local + + - name: Upload staticlib artifact + uses: actions/upload-artifact@v4 + with: + name: storage-staticlib-${{ matrix.artifact-name }} + path: dist/staticlib-${{ matrix.artifact-name }} + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: storage-${{ matrix.artifact-name }} + path: dist/${{ matrix.artifact-name }} + + # ============================================================================= + # Build Rust Storage Staticlib (macOS + Windows — manual only, for embedding) + # ============================================================================= + build-storage-desktop: + name: Build Storage Staticlib (${{ matrix.target }}) + if: github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + # macOS + - target: x86_64-apple-darwin + runner: macos-15-intel # Intel runner + artifact-name: macos-x64 + lib-name: libdocx_storage_local.a + - target: aarch64-apple-darwin + runner: macos-latest # Apple Silicon runner + artifact-name: macos-arm64 + lib-name: libdocx_storage_local.a + # Windows + - target: x86_64-pc-windows-msvc + runner: windows-latest + artifact-name: windows-x64 + lib-name: docx_storage_local.lib + - target: aarch64-pc-windows-msvc + runner: windows-latest + artifact-name: windows-arm64 + lib-name: docx_storage_local.lib + + steps: + - uses: actions/checkout@v4 + + - name: Install protoc (macOS) + if: runner.os == 'macOS' + run: brew install protobuf + + - name: Install protoc (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install protoc -y + echo "C:\ProgramData\chocolatey\lib\protoc\tools\bin" >> $env:GITHUB_PATH + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build staticlib + run: cargo build --release --target ${{ matrix.target }} -p docx-storage-local --lib + + - name: Prepare artifact (macOS/Linux) + if: runner.os != 'Windows' + run: | + mkdir -p dist/staticlib-${{ matrix.artifact-name }} + cp target/${{ matrix.target }}/release/${{ matrix.lib-name }} dist/staticlib-${{ matrix.artifact-name }}/ + + - name: Prepare artifact (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path dist/staticlib-${{ matrix.artifact-name }} + Copy-Item target/${{ matrix.target }}/release/${{ matrix.lib-name }} dist/staticlib-${{ matrix.artifact-name }}/ + + - name: Upload staticlib artifact + uses: actions/upload-artifact@v4 + with: + name: storage-staticlib-${{ matrix.artifact-name }} + path: dist/staticlib-${{ matrix.artifact-name }} + # ============================================================================= # Tests # ============================================================================= test: name: Run Tests + needs: build-storage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Download storage staticlib + uses: actions/download-artifact@v4 + with: + name: storage-staticlib-linux-x64 + path: dist/staticlib-linux-x64 + + - name: Download storage binary (for remote-mode tests) + uses: actions/download-artifact@v4 + with: + name: storage-linux-x64 + path: dist/linux-x64 + + - name: Make storage server executable + run: chmod +x dist/linux-x64/docx-storage-local + - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -169,7 +343,8 @@ jobs: # ============================================================================= installer-windows: name: Windows Installer ${{ matrix.arch }} - needs: test + if: github.event_name == 'workflow_dispatch' + needs: [test, build-storage-desktop] runs-on: windows-latest strategy: matrix: @@ -178,13 +353,19 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Download storage staticlib + uses: actions/download-artifact@v4 + with: + name: storage-staticlib-windows-${{ matrix.arch }} + path: dist/staticlib-windows-${{ matrix.arch }} + - name: Setup .NET uses: actions/setup-dotnet@v4 with: dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-quality: preview - - name: Build MCP Server (NativeAOT) + - name: Build MCP Server (NativeAOT, embedded storage) run: | dotnet publish src/DocxMcp/DocxMcp.csproj ` --configuration Release ` @@ -192,9 +373,10 @@ jobs: --self-contained true ` -p:PublishAot=true ` -p:OptimizationPreference=Size ` + -p:RustStaticLibPath=${{ github.workspace }}/dist/staticlib-windows-${{ matrix.arch }}/docx_storage_local.lib ` --output dist/windows-${{ matrix.arch }} - - name: Build CLI (NativeAOT) + - name: Build CLI (NativeAOT, embedded storage) run: | dotnet publish src/DocxMcp.Cli/DocxMcp.Cli.csproj ` --configuration Release ` @@ -202,6 +384,7 @@ jobs: --self-contained true ` -p:PublishAot=true ` -p:OptimizationPreference=Size ` + -p:RustStaticLibPath=${{ github.workspace }}/dist/staticlib-windows-${{ matrix.arch }}/docx_storage_local.lib ` --output dist/windows-${{ matrix.arch }} - name: Extract version @@ -269,12 +452,25 @@ jobs: # ============================================================================= installer-macos: name: macOS Universal Installer - needs: test + if: github.event_name == 'workflow_dispatch' + needs: [test, build-storage-desktop] runs-on: macos-latest steps: - uses: actions/checkout@v4 + - name: Download storage staticlib (x64) + uses: actions/download-artifact@v4 + with: + name: storage-staticlib-macos-x64 + path: dist/staticlib-macos-x64 + + - name: Download storage staticlib (arm64) + uses: actions/download-artifact@v4 + with: + name: storage-staticlib-macos-arm64 + path: dist/staticlib-macos-arm64 + - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -289,6 +485,7 @@ jobs: --self-contained true \ -p:PublishAot=true \ -p:OptimizationPreference=Size \ + -p:RustStaticLibPath=${{ github.workspace }}/dist/staticlib-macos-x64/libdocx_storage_local.a \ --output dist/macos-x64 - name: Build MCP Server (arm64) @@ -299,6 +496,7 @@ jobs: --self-contained true \ -p:PublishAot=true \ -p:OptimizationPreference=Size \ + -p:RustStaticLibPath=${{ github.workspace }}/dist/staticlib-macos-arm64/libdocx_storage_local.a \ --output dist/macos-arm64 - name: Build CLI (x64) @@ -309,6 +507,7 @@ jobs: --self-contained true \ -p:PublishAot=true \ -p:OptimizationPreference=Size \ + -p:RustStaticLibPath=${{ github.workspace }}/dist/staticlib-macos-x64/libdocx_storage_local.a \ --output dist/macos-x64 - name: Build CLI (arm64) @@ -319,6 +518,7 @@ jobs: --self-contained true \ -p:PublishAot=true \ -p:OptimizationPreference=Size \ + -p:RustStaticLibPath=${{ github.workspace }}/dist/staticlib-macos-arm64/libdocx_storage_local.a \ --output dist/macos-arm64 - name: Create Universal Binaries @@ -432,7 +632,7 @@ jobs: release: name: Create Release needs: [docker-manifest, installer-windows, installer-macos] - if: startsWith(github.ref, 'refs/tags/v') + if: always() && startsWith(github.ref, 'refs/tags/v') && !contains(needs.*.result, 'failure') runs-on: ubuntu-latest permissions: contents: write @@ -443,12 +643,14 @@ jobs: fetch-depth: 0 # Fetch all history for tags - name: Download Windows installers + if: needs.installer-windows.result == 'success' uses: actions/download-artifact@v4 with: path: artifacts pattern: windows-*-installer - name: Download macOS installer + if: needs.installer-macos.result == 'success' uses: actions/download-artifact@v4 with: path: artifacts diff --git a/.gitignore b/.gitignore index 368e8d4..52ffbf0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,22 @@ packages/ # Coverage coverage/ TestResults/ + +# Rust +target/ +**/*.rs.bk +Cargo.lock +!crates/*/Cargo.lock + +# Pulumi / Python +infra/venv/ +__pycache__/ + +# Claude Code +.claude/plans/ +.claude/settings.local.json + +# MCP / IDE plugins +.mcp.json +.kilocode/ +.playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md index 90746ed..81c5e4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,9 +8,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # Build (requires .NET 10 SDK) dotnet build -# Run unit tests (xUnit, ~323 tests) +# Run unit tests (xUnit, ~428 tests) dotnet test tests/DocxMcp.Tests/ +# Run tests with Cloudflare R2 backend (dual-server mode) +# 1. Get credentials: source infra/env-setup.sh +# 2. Build: cargo build --release -p docx-storage-cloudflare +# 3. Launch server (in background): +# CLOUDFLARE_ACCOUNT_ID=... R2_BUCKET_NAME=... R2_ACCESS_KEY_ID=... \ +# R2_SECRET_ACCESS_KEY=... GRPC_PORT=50052 \ +# ./target/release/docx-storage-cloudflare & +# 4. Run tests: +STORAGE_GRPC_URL=http://localhost:50052 dotnet test tests/DocxMcp.Tests/ + # Run a single test by name dotnet test tests/DocxMcp.Tests/ --filter "FullyQualifiedName~TestMethodName" @@ -48,8 +58,24 @@ MCP stdio / CLI command → SessionManager (session lifecycle + undo/redo + WAL coordination) → DocxSession (in-memory MemoryStream + WordprocessingDocument) → Open XML SDK (DocumentFormat.OpenXml) + → SyncManager (file sync + auto-save, caller-orchestrated) ``` +### Dual-Server Storage Architecture (`src/DocxMcp.Grpc/`) + +Storage is split into two interfaces for dual-server deployment: + +- **`IHistoryStorage`** — Sessions, WAL, index, checkpoints. Maps to `StorageService` gRPC. Can be remote (Cloudflare R2) or local embedded. +- **`ISyncStorage`** — File sync + filesystem watch. Maps to `SourceSyncService` + `ExternalWatchService` gRPC. Always local (embedded staticlib). + +Implemented by `HistoryStorageClient` and `SyncStorageClient`. + +**Embedded mode** (no `STORAGE_GRPC_URL`): Both use in-memory channel via `NativeStorage.Init()` + `InMemoryPipeStream` (P/Invoke to Rust staticlib). + +**Dual mode** (`STORAGE_GRPC_URL` set): `IHistoryStorage` → remote gRPC, `ISyncStorage` → local embedded staticlib. Auto-save orchestration is caller-side: after each mutation, tool classes call `sync.MaybeAutoSave()`. + +The Rust storage server binary (`dist/{platform}/docx-storage-local`) is auto-launched by `GrpcLauncher` via Unix socket. **After modifying Rust code, rebuild and copy**: `cargo build --release -p docx-storage-local && cp target/release/docx-storage-local dist/macos-arm64/` + ### Typed Path System (`src/DocxMcp/Paths/`) Documents are navigated via typed paths like `/body/table[0]/row[1]/cell[0]/paragraph[*]`. @@ -84,10 +110,11 @@ Tools use attribute-based registration with DI: public sealed class SomeTools { [McpServerTool(Name = "tool_name"), Description("...")] - public static string ToolMethod(SessionManager sessions, string param) { ... } + public static string ToolMethod(SessionManager sessions, SyncManager sync, + ExternalChangeTracker? externalChangeTracker, string param) { ... } } ``` -`SessionManager` and other services are auto-injected from the DI container. +`SessionManager`, `SyncManager`, and `ExternalChangeTracker` are auto-injected from the DI container. Mutation tools call `sync.MaybeAutoSave()` after `sessions.AppendWal()`. ### Environment Variables @@ -97,6 +124,187 @@ public sealed class SomeTools | `DOCX_CHECKPOINT_INTERVAL` | `10` | Edits between checkpoints | | `DOCX_WAL_COMPACT_THRESHOLD` | `50` | WAL entries before compaction | | `DOCX_AUTO_SAVE` | `true` | Auto-save to source file after each edit | +| `STORAGE_GRPC_URL` | _(unset)_ | Remote gRPC URL for history storage (enables dual-server mode) | +| `SYNC_GRPC_URL` | _(unset)_ | Remote gRPC URL for sync/watch (e.g. `http://gdrive:50052`). Falls back to `STORAGE_GRPC_URL` if unset | + +### Docker Compose Deployment + +Two profiles are available: + +- **`proxy`** — Local development. `docx-storage-local` serves all 3 gRPC services on `:50051` (history + sync/watch for local files). +- **`cloud`** — Production. `docx-storage-cloudflare` (R2, StorageService) + `docx-storage-gdrive` (SourceSyncService + ExternalWatchService, multi-tenant OAuth tokens from D1). + +```bash +# Always source credentials first +source infra/env-setup.sh + +# Local dev +docker compose --profile proxy up -d + +# Production (R2 + Google Drive) +docker compose --profile cloud up -d +``` + +### Multi-Tenant Google Drive Architecture (`crates/docx-storage-gdrive/`) + +Google Drive sync uses per-tenant OAuth tokens stored in D1 (`oauth_connection` table). The gdrive server never holds static credentials — each operation resolves the token from D1 via `TokenManager`. + +**Flow:** Website OAuth consent → tokens stored in D1 → gdrive server reads tokens per-connection → auto-refresh via `refresh_token` grant. + +**URI format:** `gdrive://{connection_id}/{file_id}` — the `connection_id` identifies which OAuth connection (and thus which Google account) to use. + +**Key files:** +- Config: `crates/docx-storage-gdrive/src/config.rs` +- D1 client: `crates/docx-storage-gdrive/src/d1_client.rs` +- Token manager: `crates/docx-storage-gdrive/src/token_manager.rs` +- GDrive API: `crates/docx-storage-gdrive/src/gdrive.rs` +- OAuth routes: `website/src/pages/api/oauth/` +- OAuth connections lib: `website/src/lib/oauth-connections.ts` +- D1 migration: `website/migrations/0005_oauth_connections.sql` + +### Koyeb Deployment (Production) + +Four services on Koyeb app `docx-mcp`, managed via Pulumi (`infra/__main__.py`): + +| Service | Dockerfile | Port | Protocol | Route | Instance | +|---------|-----------|------|----------|-------|----------| +| `storage` | `Dockerfile.storage-cloudflare` | 50051 | tcp (mesh-only) | _(none)_ | nano | +| `gdrive` | `Dockerfile.gdrive` | 50052 | tcp (mesh-only) | _(none)_ | nano | +| `mcp-http` | `Dockerfile` | 3000 | tcp (mesh-only) | _(none)_ | small | +| `proxy` | `Dockerfile.proxy` | 8080 | http (public) | `/` | nano | + +Internal services use `protocol=tcp` with no routes — they are only reachable via Koyeb service mesh (e.g. `http://storage:50051`). This prevents route conflicts: if multiple services in the same app share route `/`, Koyeb's edge routes traffic to the wrong service → 502. + +**Custom domain:** `mcp.docx.lapoule.dev` → Koyeb CNAME (DNS-only, no Cloudflare proxy) + +#### Koyeb CLI cheat sheet + +```bash +# Always source credentials first +source infra/env-setup.sh + +# List services +koyeb services list --app docx-mcp + +# Describe a service (routing, scaling, git sha) +koyeb services describe docx-mcp/ +koyeb services describe docx-mcp/ -o json + +# List deployments for a service +koyeb deployments list --service docx-mcp/ + +# Describe a deployment (definition, build status) +koyeb deployments describe +koyeb deployments describe -o json + +# List running instances +koyeb instances list --app docx-mcp + +# Instance logs (historical range) +koyeb instances logs --start-time "2026-02-19T18:00:00Z" --end-time "2026-02-19T19:30:00Z" + +# Instance logs (tail, blocks until Ctrl-C) +koyeb instances logs --tail + +# Update a service (e.g. scale-to-zero) +koyeb services update docx-mcp/ --min-scale 0 +koyeb services update docx-mcp/ --min-scale 1 + +# Redeploy with latest commit +koyeb services update docx-mcp/ --git-sha '' + +# Redeploy specific commit +koyeb services update docx-mcp/ --git-sha +``` + +**Koyeb CLI gotchas:** +- `-o json` outputs one JSON object per line (not a JSON array) — use `head -1 | python3 -c "import json,sys; d=json.loads(sys.stdin.readline())"` to parse +- `--tail` flag blocks forever (no `--lines` limit) — use `timeout 10 koyeb instances logs --tail` or Ctrl-C +- No `--type build/runtime` flag on logs — all logs are mixed +- `koyeb logs` does NOT exist — use `koyeb instances logs ` +- `koyeb services logs` exists but is unreliable (empty output) — prefer `koyeb instances logs` +- `koyeb domains list` has no `--app` flag — lists all domains across all apps + +**Debugging 502 errors:** Koyeb uses Cloudflare as its edge/CDN — a `502` with `server: cloudflare` and `cf-ray` headers comes from Koyeb's Cloudflare layer, not our own Cloudflare zone. Common causes: +- **HTTP/2 (h2c) mismatch**: Koyeb's edge may connect to containers via HTTP/2 cleartext (h2c). If the server only speaks HTTP/1.1 (e.g. `axum::serve`), Koyeb returns 502 even though health checks pass (health checks use HTTP/1.1). Fix: use `hyper-util::server::conn::auto::Builder` for dual-stack HTTP/1.1 + h2c. +- **Health check testing upstream**: If `/health` checks upstream services (e.g. mcp-http), it can timeout during cold start, making Koyeb mark the container as unhealthy. Fix: `/health` should only test that the proxy itself is running. Use `/upstream-health` for deep checks. +- **Upstream unreachable**: If proxy is healthy but returns 502, check mcp-http logs first (`koyeb instances logs --tail`). + +**IMPORTANT — PAT tokens:** NEVER use fake/placeholder tokens like `dxs_test` or `dxs_fake`. The proxy validates every token against Cloudflare D1 and rejects invalid ones immediately. Always use a real PAT token from the D1 `pat` table (format: `dxs_<40-char-hex>`). To get one, query D1 directly or create one via the website. + +**Pulumi provider bug:** `scale_to_zero=True` on mesh-only services (no public route) fails with Pulumi provider validation error `"at least one route is required for services scaling to zero"`. The Koyeb API/CLI accepts it fine. Workaround: apply via CLI `koyeb services update --min-scale 0`, then set `scale_to_zero=True` in Pulumi to keep state aligned (Pulumi won't try to re-apply if already matching). + +#### Testing Dockerfiles locally + +Always test Dockerfile changes locally before pushing: + +```bash +# Build and verify (use --target to stop at a specific stage) +docker build -f Dockerfile --target runtime -t docx-mcp-test . +docker build -f Dockerfile.proxy -t proxy-test . +docker build -f Dockerfile.storage-cloudflare -t storage-test . +docker build -f Dockerfile.gdrive -t gdrive-test . + +# Full stack local test +source infra/env-setup.sh && docker compose --profile proxy up -d --build +``` + +#### Testing the MCP proxy with mcptools + +mcptools (`brew install mcptools`) speaks MCP protocol via stdio. For HTTP/SSE servers (proxy), use `npx mcp-remote` as a stdio-to-HTTP bridge. + +**Local proxy (docker compose):** + +```bash +# 1. Start the local stack +source infra/env-setup.sh && docker compose --profile proxy up -d + +# 2. List all MCP tools via local proxy +mcptools tools npx mcp-remote http://localhost:8080/mcp --header "Authorization: Bearer dxs_" + +# 3. Call a specific tool +mcptools call document_list npx mcp-remote http://localhost:8080/mcp --header "Authorization: Bearer dxs_" + +# 4. Interactive shell (call tools one by one) +mcptools shell npx mcp-remote http://localhost:8080/mcp --header "Authorization: Bearer dxs_" +``` + +**Koyeb proxy (production):** + +```bash +# Same commands, just change the URL to the Koyeb public endpoint +mcptools tools npx mcp-remote https://mcp.docx.lapoule.dev/mcp --header "Authorization: Bearer dxs_" +mcptools call document_list npx mcp-remote https://mcp.docx.lapoule.dev/mcp --header "Authorization: Bearer dxs_" +mcptools shell npx mcp-remote https://mcp.docx.lapoule.dev/mcp --header "Authorization: Bearer dxs_" +``` + +**Direct stdio testing (no proxy, no docker):** + +```bash +# Test MCP server directly via stdio (embedded storage, local only) +mcptools tools dotnet run --project src/DocxMcp/ +mcptools call document_list dotnet run --project src/DocxMcp/ +mcptools shell dotnet run --project src/DocxMcp/ +``` + +**mcptools gotchas:** +- mcptools only speaks **stdio** natively — for HTTP/SSE servers always use `npx mcp-remote ` as the command +- `mcptools tools ` does NOT work — it tries to exec the URL as a command +- The `--header` flag is passed through to `mcp-remote`, not to mcptools itself +- `mcptools configs ls` shows servers from Claude Desktop/Code configs but you can't use them directly as aliases + +#### Debugging services inside Koyeb + +`koyeb instances exec` requires a TTY — use `script -q /dev/null` wrapper: + +```bash +# Test connectivity from inside a container +script -q /dev/null koyeb instances exec -- curl -s http://mcp-http:3000/health + +# Test gRPC service (grpcurl is installed in mcp-http image) +script -q /dev/null koyeb instances exec -- grpcurl -plaintext storage:50051 list +script -q /dev/null koyeb instances exec -- grpcurl -plaintext storage:50051 storage.StorageService/HealthCheck +``` ## Key Conventions diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6855f6e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4215 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.122.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94c2ca0cba97e8e279eb6c0b2d0aa10db5959000e602ab2b7c02de6b85d4c19b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.93.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcb38bb33fc0a11f1ffc3e3e85669e0a11a37690b86f77e75306d8f369146a0" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ada8ffbea7bd1be1f53df1dadb0f8fdb04badb13185b3321b929d1ee3caad09" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6443ccadc777095d5ed13e21f5c364878c9f5bad4e35187a6cdbd863b0afcad" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcf418858f9f3edd228acb8759d77394fed7531cce78d02bdda499025368439" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b9c7354a3b13c66f60fe4616d6d1969c9fd36b1b5333a5dfb3ee716b33c588" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "docx-mcp-sse-proxy" +version = "1.6.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "hex", + "hyper 1.8.1", + "hyper-util", + "moka", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "docx-storage-cloudflare" +version = "1.6.0" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-sdk-s3", + "base64", + "bytes", + "chrono", + "clap", + "docx-storage-core", + "prost", + "prost-types", + "serde", + "serde_bytes", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-test", + "tonic", + "tonic-build", + "tonic-reflection", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "docx-storage-core" +version = "1.6.0" +dependencies = [ + "async-trait", + "chrono", + "serde", + "serde_bytes", + "serde_json", + "thiserror", +] + +[[package]] +name = "docx-storage-gdrive" +version = "1.6.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "clap", + "dashmap", + "docx-storage-core", + "hex", + "prost", + "prost-types", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-test", + "tonic", + "tonic-build", + "tonic-reflection", + "tracing", + "tracing-subscriber", + "uuid", + "wiremock", +] + +[[package]] +name = "docx-storage-local" +version = "1.6.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "chrono", + "clap", + "dashmap", + "dirs", + "docx-storage-core", + "fs2", + "futures", + "libc", + "notify", + "prost", + "prost-types", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-test", + "tonic", + "tonic-build", + "tonic-reflection", + "tracing", + "tracing-subscriber", + "uuid", + "windows-sys 0.59.0", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tonic-reflection" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9687bd5bfeafebdded2356950f278bba8226f0b32109537c4253406e09aafe1" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b489ec3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,81 @@ +[workspace] +resolver = "2" + +members = [ + "crates/docx-storage-core", + "crates/docx-storage-local", + "crates/docx-storage-cloudflare", + "crates/docx-storage-gdrive", + "crates/docx-mcp-sse-proxy", +] + +[workspace.package] +version = "1.6.0" +edition = "2021" +rust-version = "1.85" +license = "MIT" + +[workspace.dependencies] +# gRPC +tonic = "0.13" +prost = "0.13" +prost-types = "0.13" + +# Async runtime +tokio = { version = "1", features = ["full", "signal"] } +tokio-stream = { version = "0.1", features = ["net"] } + +# Web framework (for proxy) +axum = { version = "0.8", features = ["macros"] } +hyper = { version = "1", features = ["server", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["tokio", "server-auto", "http1", "http2"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } + +# HTTP client +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } + +# S3/R2 +aws-sdk-s3 = "1" +aws-config = "1" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Async utilities +async-trait = "0.1" +futures = "0.3" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# CLI +clap = { version = "4", features = ["derive", "env"] } + +# Cache (for proxy) +moka = { version = "0.12", features = ["future"] } + +# Crypto +sha2 = "0.10" +hex = "0.4" + +# Testing +tempfile = "3" + +[workspace.lints.rust] +# Using "deny" instead of "forbid" to allow local #[allow(unsafe_code)] +# for specific safe patterns like libc::kill(pid, 0) for process checking +unsafe_code = "deny" +warnings = "deny" + +[workspace.lints.clippy] +all = "warn" diff --git a/Dockerfile b/Dockerfile index a337aec..fc1a3a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,30 @@ -FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build +# ============================================================================= +# docx-mcp Full Stack Dockerfile +# Builds MCP server and CLI with embedded Rust storage (single binary) +# ============================================================================= + +# Stage 1: Build Rust staticlib +FROM rust:1.93-slim-bookworm AS rust-builder + +WORKDIR /rust + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +# Copy Rust workspace files +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +# Build the staticlib for embedding +RUN --mount=type=cache,id=cargo-mcp,target=/usr/local/cargo/registry \ + cargo build --release --package docx-storage-local --lib + +# Stage 2: Build .NET MCP server and CLI with embedded Rust storage +FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS dotnet-builder # NativeAOT requires clang as the platform linker RUN apt-get update && \ @@ -7,32 +33,63 @@ RUN apt-get update && \ WORKDIR /src -COPY . . +# Copy Rust staticlib from builder +COPY --from=rust-builder /rust/target/release/libdocx_storage_local.a /rust-lib/ + +# Copy .NET source +COPY DocxMcp.sln ./ +COPY proto/ ./proto/ +COPY src/ ./src/ +COPY tests/ ./tests/ -# Build both MCP server and CLI as NativeAOT binaries -RUN dotnet publish src/DocxMcp/DocxMcp.csproj \ +# Build MCP server with embedded storage +RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \ + dotnet publish src/DocxMcp/DocxMcp.csproj \ --configuration Release \ + -p:RustStaticLibPath=/rust-lib/libdocx_storage_local.a \ -o /app -RUN dotnet publish src/DocxMcp.Cli/DocxMcp.Cli.csproj \ +# Build CLI with embedded storage +RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \ + dotnet publish src/DocxMcp.Cli/DocxMcp.Cli.csproj \ --configuration Release \ + -p:RustStaticLibPath=/rust-lib/libdocx_storage_local.a \ -o /app/cli -# Runtime: minimal image with only the binaries -# The runtime-deps image already provides an 'app' user/group +# Stage 3: Runtime (single binary, no separate storage process) FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-preview AS runtime +# Install curl (health checks) and grpcurl (gRPC debugging) +RUN apt-get update && \ + apt-get install -y --no-install-recommends curl ca-certificates && \ + ARCH=$(dpkg --print-architecture) && \ + case "$ARCH" in amd64) GRPC_ARCH=x86_64 ;; arm64) GRPC_ARCH=arm64 ;; *) GRPC_ARCH=x86_64 ;; esac && \ + curl -sL "https://github.com/fullstorydev/grpcurl/releases/download/v1.9.1/grpcurl_1.9.1_linux_${GRPC_ARCH}.tar.gz" | tar xz -C /usr/local/bin grpcurl && \ + rm -rf /var/lib/apt/lists/* + WORKDIR /app -COPY --from=build /app/docx-mcp . -COPY --from=build /app/cli/docx-cli . -# Sessions persistence directory (WAL, baselines, checkpoints) -RUN mkdir -p /home/app/.docx-mcp/sessions && \ - chown -R app:app /home/app/.docx-mcp -VOLUME /home/app/.docx-mcp/sessions +# Copy binaries from builder (no docx-storage-local needed!) +COPY --from=dotnet-builder /app/docx-mcp ./ +COPY --from=dotnet-builder /app/cli/docx-cli ./ + +# Create directories +RUN mkdir -p /app/data && \ + chown -R app:app /app/data + +# Volumes for data persistence +VOLUME /app/data USER app -ENV DOCX_SESSIONS_DIR=/home/app/.docx-mcp/sessions +# Environment variables +ENV LOCAL_STORAGE_DIR=/app/data +# Default entrypoint is the MCP server ENTRYPOINT ["./docx-mcp"] + +# ============================================================================= +# Alternative entrypoints: +# - CLI: docker run --entrypoint ./docx-cli ... +# - Remote storage mode: docker run -e STORAGE_GRPC_URL=http://host:50051 ... +# ============================================================================= diff --git a/Dockerfile.gdrive b/Dockerfile.gdrive new file mode 100644 index 0000000..3c2c8a3 --- /dev/null +++ b/Dockerfile.gdrive @@ -0,0 +1,35 @@ +# docx-storage-gdrive — gRPC sync/watch server (Google Drive) + +FROM rust:1.93-slim-bookworm AS builder +WORKDIR /build + +RUN apt-get update && apt-get install -y \ + pkg-config protobuf-compiler libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +RUN --mount=type=cache,id=cargo-gdrive,target=/usr/local/cargo/registry \ + cargo build --release --package docx-storage-gdrive + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates netcat-openbsd && rm -rf /var/lib/apt/lists/* +RUN useradd -m -u 1000 docx +USER docx +WORKDIR /app + +COPY --from=builder /build/target/release/docx-storage-gdrive /app/docx-storage-gdrive + +ENV RUST_LOG=info GRPC_HOST=0.0.0.0 GRPC_PORT=50052 +EXPOSE 50052 + +# Required: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, D1_DATABASE_ID, +# GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET +# Optional: WATCH_POLL_INTERVAL (default 60s) + +HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \ + CMD ["nc", "-z", "localhost", "50052"] + +ENTRYPOINT ["/app/docx-storage-gdrive"] diff --git a/Dockerfile.proxy b/Dockerfile.proxy new file mode 100644 index 0000000..adf153f --- /dev/null +++ b/Dockerfile.proxy @@ -0,0 +1,33 @@ +# docx-mcp-sse-proxy — HTTP reverse proxy with PAT auth + +FROM rust:1.93-slim-bookworm AS builder +WORKDIR /build + +RUN apt-get update && apt-get install -y \ + pkg-config protobuf-compiler libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +RUN --mount=type=cache,id=cargo-proxy,target=/usr/local/cargo/registry \ + cargo build --release --package docx-mcp-sse-proxy + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/* +RUN useradd -m -u 1000 docx +USER docx +WORKDIR /app + +COPY --from=builder /build/target/release/docx-mcp-sse-proxy /app/docx-mcp-sse-proxy + +ENV RUST_LOG=info PROXY_HOST=0.0.0.0 PROXY_PORT=8080 +EXPOSE 8080 + +# Required: MCP_BACKEND_URL, CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, D1_DATABASE_ID + +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["curl", "-sf", "http://localhost:8080/health"] + +ENTRYPOINT ["/app/docx-mcp-sse-proxy"] diff --git a/Dockerfile.storage-cloudflare b/Dockerfile.storage-cloudflare new file mode 100644 index 0000000..4d6b63b --- /dev/null +++ b/Dockerfile.storage-cloudflare @@ -0,0 +1,33 @@ +# docx-storage-cloudflare — gRPC storage server (Cloudflare R2) + +FROM rust:1.93-slim-bookworm AS builder +WORKDIR /build + +RUN apt-get update && apt-get install -y \ + pkg-config protobuf-compiler libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +RUN --mount=type=cache,id=cargo-storage,target=/usr/local/cargo/registry \ + cargo build --release --package docx-storage-cloudflare + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates netcat-openbsd && rm -rf /var/lib/apt/lists/* +RUN useradd -m -u 1000 docx +USER docx +WORKDIR /app + +COPY --from=builder /build/target/release/docx-storage-cloudflare /app/docx-storage-cloudflare + +ENV RUST_LOG=info GRPC_HOST=0.0.0.0 GRPC_PORT=50051 +EXPOSE 50051 + +# Required: CLOUDFLARE_ACCOUNT_ID, R2_BUCKET_NAME, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY + +HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \ + CMD ["nc", "-z", "localhost", "50051"] + +ENTRYPOINT ["/app/docx-storage-cloudflare"] diff --git a/Dockerfile.storage-local b/Dockerfile.storage-local new file mode 100644 index 0000000..5cdb0ab --- /dev/null +++ b/Dockerfile.storage-local @@ -0,0 +1,32 @@ +# docx-storage-local — gRPC storage server (local filesystem) + +FROM rust:1.93-slim-bookworm AS builder +WORKDIR /build + +RUN apt-get update && apt-get install -y \ + pkg-config protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +RUN cargo build --release --package docx-storage-local + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates netcat-openbsd && rm -rf /var/lib/apt/lists/* +RUN useradd -m -u 1000 docx +USER docx +WORKDIR /app + +COPY --from=builder /build/target/release/docx-storage-local /app/docx-storage-local +RUN mkdir -p /app/data + +ENV RUST_LOG=info GRPC_HOST=0.0.0.0 GRPC_PORT=50051 LOCAL_STORAGE_DIR=/app/data +EXPOSE 50051 + +HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \ + CMD ["nc", "-z", "localhost", "50051"] + +ENTRYPOINT ["/app/docx-storage-local"] +CMD ["--transport", "tcp"] diff --git a/DocxMcp.sln b/DocxMcp.sln index 304ab50..73ff37b 100644 --- a/DocxMcp.sln +++ b/DocxMcp.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.Cli", "src\DocxMcp.Cli\DocxMcp.Cli.csproj", "{3B0B53E5-AF70-4F88-B383-04849B4CBCE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocxMcp.Grpc", "src\DocxMcp.Grpc\DocxMcp.Grpc.csproj", "{C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,11 +59,24 @@ Global {3B0B53E5-AF70-4F88-B383-04849B4CBCE0}.Release|x64.Build.0 = Release|Any CPU {3B0B53E5-AF70-4F88-B383-04849B4CBCE0}.Release|x86.ActiveCfg = Release|Any CPU {3B0B53E5-AF70-4F88-B383-04849B4CBCE0}.Release|x86.Build.0 = Release|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Debug|x64.Build.0 = Debug|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Debug|x86.Build.0 = Debug|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Release|x64.ActiveCfg = Release|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Release|x64.Build.0 = Release|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Release|x86.ActiveCfg = Release|Any CPU + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {3B0B53E5-AF70-4F88-B383-04849B4CBCE0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C4D5E6F7-A8B9-0123-CDEF-456789ABCDEF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/crates/docx-mcp-sse-proxy/Cargo.toml b/crates/docx-mcp-sse-proxy/Cargo.toml new file mode 100644 index 0000000..9b33e78 --- /dev/null +++ b/crates/docx-mcp-sse-proxy/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "docx-mcp-sse-proxy" +description = "HTTP reverse proxy with D1 auth for docx-mcp multi-tenant architecture" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +# Web framework +axum.workspace = true +hyper.workspace = true +hyper-util.workspace = true +tower.workspace = true +tower-http.workspace = true +tokio.workspace = true + +# HTTP client (D1 API + backend forwarding) +reqwest = { workspace = true, features = ["stream"] } + +# Cache +moka.workspace = true + +# Crypto +sha2.workspace = true +hex.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Time +chrono.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# CLI +clap.workspace = true + +[[bin]] +name = "docx-mcp-sse-proxy" +path = "src/main.rs" + +[lints] +workspace = true diff --git a/crates/docx-mcp-sse-proxy/Dockerfile b/crates/docx-mcp-sse-proxy/Dockerfile new file mode 100644 index 0000000..75d5933 --- /dev/null +++ b/crates/docx-mcp-sse-proxy/Dockerfile @@ -0,0 +1,62 @@ +# ============================================================================= +# docx-mcp-sse-proxy Dockerfile +# Multi-stage build for the SSE/HTTP proxy with PAT authentication +# ============================================================================= + +# Stage 1: Build +FROM rust:1.93-slim-bookworm AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + protobuf-compiler \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +# Build the proxy server +RUN cargo build --release --package docx-mcp-sse-proxy + +# Stage 2: Runtime +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 docx +USER docx + +WORKDIR /app + +# Copy the binary from builder +COPY --from=builder /build/target/release/docx-mcp-sse-proxy /app/docx-mcp-sse-proxy + +# Environment defaults +ENV RUST_LOG=info +ENV PROXY_HOST=0.0.0.0 +ENV PROXY_PORT=8080 + +# Required environment variables (must be set at runtime): +# MCP_BACKEND_URL (e.g., http://mcp-http:3000) +# CLOUDFLARE_ACCOUNT_ID +# CLOUDFLARE_API_TOKEN +# D1_DATABASE_ID + +# Expose HTTP port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["curl", "-f", "http://localhost:8080/health"] || exit 1 + +# Run the proxy +ENTRYPOINT ["/app/docx-mcp-sse-proxy"] diff --git a/crates/docx-mcp-sse-proxy/src/auth.rs b/crates/docx-mcp-sse-proxy/src/auth.rs new file mode 100644 index 0000000..4dfc188 --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/auth.rs @@ -0,0 +1,318 @@ +//! PAT token validation via Cloudflare D1 API. +//! +//! Validates Personal Access Tokens against a D1 database using the +//! Cloudflare REST API. Includes a moka cache for performance. + +use std::sync::Arc; +use std::time::Duration; + +use moka::future::Cache; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing::{debug, warn}; + +use crate::error::{ProxyError, Result}; + +/// PAT token prefix expected by the system. +const TOKEN_PREFIX: &str = "dxs_"; + +/// Result of a PAT validation. +#[derive(Debug, Clone)] +pub struct PatValidationResult { + pub tenant_id: String, + pub pat_id: String, +} + +/// Cached validation result (either success or known-invalid). +#[derive(Debug, Clone)] +enum CachedResult { + Valid(PatValidationResult), + Invalid, +} + +/// D1 query request body. +#[derive(Serialize)] +struct D1QueryRequest { + sql: String, + params: Vec, +} + +/// D1 API response structure. +#[derive(Deserialize)] +struct D1Response { + success: bool, + result: Option>, + errors: Option>, +} + +#[derive(Deserialize)] +struct D1QueryResult { + results: Vec, +} + +#[derive(Deserialize)] +struct D1Error { + message: String, +} + +/// PAT record from D1. +#[derive(Deserialize)] +struct PatRecord { + id: String, + #[serde(rename = "tenantId")] + tenant_id: String, + #[serde(rename = "expiresAt")] + expires_at: Option, +} + +/// PAT validator with D1 backend and caching. +pub struct PatValidator { + client: Client, + account_id: String, + api_token: String, + database_id: String, + cache: Cache, + negative_cache_ttl: Duration, +} + +impl PatValidator { + /// Create a new PAT validator. + pub fn new( + account_id: String, + api_token: String, + database_id: String, + cache_ttl_secs: u64, + negative_cache_ttl_secs: u64, + ) -> Self { + let cache = Cache::builder() + .time_to_live(Duration::from_secs(cache_ttl_secs)) + .max_capacity(10_000) + .build(); + + Self { + client: Client::new(), + account_id, + api_token, + database_id, + cache, + negative_cache_ttl: Duration::from_secs(negative_cache_ttl_secs), + } + } + + /// Validate a PAT token. + pub async fn validate(&self, token: &str) -> Result { + // Check token prefix + if !token.starts_with(TOKEN_PREFIX) { + return Err(ProxyError::InvalidToken); + } + + // Compute token hash for cache key + let token_hash = self.hash_token(token); + + // Check cache first + if let Some(cached) = self.cache.get(&token_hash).await { + match cached { + CachedResult::Valid(result) => { + debug!("PAT validation cache hit (valid) for {}", &token[..12]); + return Ok(result); + } + CachedResult::Invalid => { + debug!("PAT validation cache hit (invalid) for {}", &token[..12]); + return Err(ProxyError::InvalidToken); + } + } + } + + // Query D1 + debug!("PAT validation cache miss, querying D1 for {}", &token[..12]); + match self.query_d1(&token_hash).await { + Ok(Some(result)) => { + self.cache + .insert(token_hash.clone(), CachedResult::Valid(result.clone())) + .await; + Ok(result) + } + Ok(None) => { + // Cache negative result with shorter TTL + let cache_clone = self.cache.clone(); + let token_hash_clone = token_hash.clone(); + let ttl = self.negative_cache_ttl; + tokio::spawn(async move { + cache_clone + .insert(token_hash_clone, CachedResult::Invalid) + .await; + tokio::time::sleep(ttl).await; + // Entry will auto-expire based on cache TTL + }); + Err(ProxyError::InvalidToken) + } + Err(e) => { + warn!("D1 query failed: {}", e); + Err(e) + } + } + } + + /// Hash a token using SHA-256. + fn hash_token(&self, token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) + } + + /// Query D1 for the PAT record. + async fn query_d1(&self, token_hash: &str) -> Result> { + let url = format!( + "https://api.cloudflare.com/client/v4/accounts/{}/d1/database/{}/query", + self.account_id, self.database_id + ); + + let query = D1QueryRequest { + sql: "SELECT id, tenantId, expiresAt FROM personal_access_token WHERE tokenHash = ?1" + .to_string(), + params: vec![token_hash.to_string()], + }; + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Content-Type", "application/json") + .json(&query) + .send() + .await + .map_err(|e| ProxyError::D1Error(e.to_string()))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| ProxyError::D1Error(e.to_string()))?; + + if !status.is_success() { + return Err(ProxyError::D1Error(format!( + "D1 API returned {}: {}", + status, body + ))); + } + + let d1_response: D1Response = + serde_json::from_str(&body).map_err(|e| ProxyError::D1Error(e.to_string()))?; + + if !d1_response.success { + let error_msg = d1_response + .errors + .map(|errs| errs.into_iter().map(|e| e.message).collect::>().join(", ")) + .unwrap_or_else(|| "Unknown D1 error".to_string()); + return Err(ProxyError::D1Error(error_msg)); + } + + // Extract the first result + let record = d1_response + .result + .and_then(|mut results| results.pop()) + .and_then(|mut query_result| query_result.results.pop()); + + match record { + Some(pat) => { + // Check expiration + if let Some(expires_at) = &pat.expires_at { + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expires_at) { + if expires < chrono::Utc::now() { + debug!("PAT {} is expired", &pat.id[..8]); + return Ok(None); + } + } + } + + // Update last_used_at asynchronously + self.update_last_used(&pat.id).await; + + Ok(Some(PatValidationResult { + tenant_id: pat.tenant_id, + pat_id: pat.id, + })) + } + None => Ok(None), + } + } + + /// Update the last_used_at timestamp (fire-and-forget). + async fn update_last_used(&self, pat_id: &str) { + let url = format!( + "https://api.cloudflare.com/client/v4/accounts/{}/d1/database/{}/query", + self.account_id, self.database_id + ); + + let now = chrono::Utc::now().to_rfc3339(); + let query = D1QueryRequest { + sql: "UPDATE personal_access_token SET lastUsedAt = ?1 WHERE id = ?2".to_string(), + params: vec![now, pat_id.to_string()], + }; + + let client = self.client.clone(); + let api_token = self.api_token.clone(); + tokio::spawn(async move { + if let Err(e) = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_token)) + .header("Content-Type", "application/json") + .json(&query) + .send() + .await + { + warn!("Failed to update lastUsedAt: {}", e); + } + }); + } +} + +/// Shared validator wrapped in Arc. +pub type SharedPatValidator = Arc; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_token() { + // Create a minimal validator just to test hash_token + let validator = PatValidator::new( + "test_account".to_string(), + "test_token".to_string(), + "test_db".to_string(), + 300, + 60, + ); + + let token = "dxs_abcdef1234567890"; + let hash = validator.hash_token(token); + + // Hash should be 64 hex chars (SHA-256) + assert_eq!(hash.len(), 64); + + // Same token should produce same hash + let hash2 = validator.hash_token(token); + assert_eq!(hash, hash2); + + // Different token should produce different hash + let hash3 = validator.hash_token("dxs_different"); + assert_ne!(hash, hash3); + } + + #[tokio::test] + async fn test_invalid_prefix() { + let validator = PatValidator::new( + "test_account".to_string(), + "test_token".to_string(), + "test_db".to_string(), + 300, + 60, + ); + + // Token without dxs_ prefix should fail + let result = validator.validate("invalid_token").await; + assert!(matches!(result, Err(ProxyError::InvalidToken))); + } +} diff --git a/crates/docx-mcp-sse-proxy/src/config.rs b/crates/docx-mcp-sse-proxy/src/config.rs new file mode 100644 index 0000000..aeb91bb --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/config.rs @@ -0,0 +1,47 @@ +use clap::Parser; + +/// Configuration for the docx-mcp-proxy server. +#[derive(Parser, Debug, Clone)] +#[command(name = "docx-mcp-proxy")] +#[command(about = "HTTP reverse proxy with D1 auth for docx-mcp multi-tenant architecture")] +pub struct Config { + /// Host to bind to + #[arg(long, default_value = "0.0.0.0", env = "PROXY_HOST")] + pub host: String, + + /// Port to bind to + #[arg(long, default_value = "8080", env = "PROXY_PORT")] + pub port: u16, + + /// URL of the .NET MCP backend (HTTP mode) + #[arg(long, env = "MCP_BACKEND_URL")] + pub mcp_backend_url: String, + + /// Cloudflare Account ID + #[arg(long, env = "CLOUDFLARE_ACCOUNT_ID")] + pub cloudflare_account_id: Option, + + /// Cloudflare API Token (with D1 read permission) + #[arg(long, env = "CLOUDFLARE_API_TOKEN")] + pub cloudflare_api_token: Option, + + /// D1 Database ID + #[arg(long, env = "D1_DATABASE_ID")] + pub d1_database_id: Option, + + /// PAT cache TTL in seconds + #[arg(long, default_value = "300", env = "PAT_CACHE_TTL_SECS")] + pub pat_cache_ttl_secs: u64, + + /// Negative cache TTL for invalid PATs + #[arg(long, default_value = "60", env = "PAT_NEGATIVE_CACHE_TTL_SECS")] + pub pat_negative_cache_ttl_secs: u64, + + /// Resource server URL (for OAuth protected resource metadata) + #[arg(long, env = "RESOURCE_URL")] + pub resource_url: Option, + + /// Authorization server URL (for OAuth discovery) + #[arg(long, env = "AUTH_SERVER_URL")] + pub auth_server_url: Option, +} diff --git a/crates/docx-mcp-sse-proxy/src/error.rs b/crates/docx-mcp-sse-proxy/src/error.rs new file mode 100644 index 0000000..8b8c44b --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/error.rs @@ -0,0 +1,100 @@ +//! Error types for the HTTP reverse proxy. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +/// Application-level errors. +#[derive(Debug, thiserror::Error)] +pub enum ProxyError { + #[error("Authentication required")] + Unauthorized, + + #[error("Invalid or expired PAT token")] + InvalidToken, + + #[error("D1 API error: {0}")] + D1Error(String), + + #[error("Backend error: {0}")] + BackendError(String), + + #[error("Backend temporarily unavailable after {1} retries: {0}")] + BackendUnavailable(String, u32), + + #[error("Invalid JSON: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("Session recovery failed: {0}")] + SessionRecoveryFailed(String), + + #[error("Internal error: {0}")] + Internal(String), +} + +// Thread-local context for resource metadata URL (used in WWW-Authenticate header). +// Set by the handler before returning auth errors. +std::thread_local! { + static RESOURCE_METADATA_URL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +/// Set the resource metadata URL for WWW-Authenticate headers in 401 responses. +pub fn set_resource_metadata_url(url: Option) { + RESOURCE_METADATA_URL.with(|cell| { + *cell.borrow_mut() = url; + }); +} + +impl IntoResponse for ProxyError { + fn into_response(self) -> Response { + #[derive(Serialize)] + struct ErrorBody { + error: String, + code: &'static str, + } + + let (status, code) = match &self { + ProxyError::Unauthorized => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED"), + ProxyError::InvalidToken => (StatusCode::UNAUTHORIZED, "INVALID_TOKEN"), + ProxyError::D1Error(_) => (StatusCode::BAD_GATEWAY, "D1_ERROR"), + ProxyError::BackendError(_) => (StatusCode::BAD_GATEWAY, "BACKEND_ERROR"), + ProxyError::BackendUnavailable(_, _) => { + (StatusCode::SERVICE_UNAVAILABLE, "BACKEND_UNAVAILABLE") + } + ProxyError::SessionRecoveryFailed(_) => { + (StatusCode::BAD_GATEWAY, "SESSION_RECOVERY_FAILED") + } + ProxyError::JsonError(_) => (StatusCode::BAD_REQUEST, "INVALID_JSON"), + ProxyError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR"), + }; + + let body = ErrorBody { + error: self.to_string(), + code, + }; + + let mut response = (status, axum::Json(body)).into_response(); + + // Add WWW-Authenticate header on 401 responses + if status == StatusCode::UNAUTHORIZED { + RESOURCE_METADATA_URL.with(|cell| { + if let Some(ref url) = *cell.borrow() { + let header_value = format!( + "Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource\"", + url + ); + if let Ok(val) = axum::http::HeaderValue::from_str(&header_value) { + response.headers_mut().insert( + axum::http::header::WWW_AUTHENTICATE, + val, + ); + } + } + }); + } + + response + } +} + +pub type Result = std::result::Result; diff --git a/crates/docx-mcp-sse-proxy/src/handlers.rs b/crates/docx-mcp-sse-proxy/src/handlers.rs new file mode 100644 index 0000000..668d96b --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/handlers.rs @@ -0,0 +1,720 @@ +//! HTTP handlers for the reverse proxy. +//! +//! Implements: +//! - POST/GET/DELETE /mcp{/*rest} - Forward to .NET MCP backend +//! - GET /health - Health check endpoint +//! +//! Session recovery: when the backend returns 404 (session lost after restart), +//! the proxy transparently re-initializes the MCP session and retries the request. + +use std::sync::Arc; +use std::time::Duration; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::{header, HeaderMap, HeaderValue, Method}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use axum::body::Bytes; +use reqwest::Client as HttpClient; +use serde::Serialize; +use serde_json::Value; +use tracing::{debug, info, warn}; + +use crate::auth::SharedPatValidator; +use crate::error::{set_resource_metadata_url, ProxyError}; +use crate::oauth::{OAuthValidator, SharedOAuthValidator}; +use crate::session::SessionRegistry; + +/// Application state shared across handlers. +#[derive(Clone)] +pub struct AppState { + pub validator: Option, + pub oauth_validator: Option, + pub backend_url: String, + pub http_client: HttpClient, + pub sessions: Arc, + pub resource_url: Option, + pub auth_server_url: Option, +} + +/// Health check response. +#[derive(Serialize)] +pub struct HealthResponse { + pub healthy: bool, + pub version: &'static str, + pub auth_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend_healthy: Option, +} + +/// GET /health - Liveness check (proxy only, no upstream dependency). +pub async fn health_handler(State(state): State) -> Json { + Json(HealthResponse { + healthy: true, + version: env!("CARGO_PKG_VERSION"), + auth_enabled: state.validator.is_some(), + backend_healthy: None, + }) +} + +/// GET /upstream-health - Deep health check (proxy + upstream mcp-http). +pub async fn upstream_health_handler(State(state): State) -> Json { + let backend_ok = state + .http_client + .get(format!("{}/health", state.backend_url)) + .timeout(Duration::from_secs(3)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false); + + Json(HealthResponse { + healthy: backend_ok, + version: env!("CARGO_PKG_VERSION"), + auth_enabled: state.validator.is_some(), + backend_healthy: Some(backend_ok), + }) +} + +/// GET /.well-known/oauth-protected-resource - OAuth 2.0 Protected Resource Metadata. +pub async fn oauth_metadata_handler( + State(state): State, +) -> std::result::Result { + let resource = state + .resource_url + .as_deref() + .unwrap_or("https://mcp.docx.lapoule.dev"); + let auth_server = state + .auth_server_url + .as_deref() + .unwrap_or("https://docx.lapoule.dev"); + + let metadata = serde_json::json!({ + "resource": resource, + "authorization_servers": [auth_server], + "bearer_methods_supported": ["header"], + "scopes_supported": ["mcp:tools"] + }); + + let body = serde_json::to_string(&metadata) + .map_err(|e| ProxyError::Internal(format!("Failed to serialize metadata: {}", e)))?; + + let response = Response::builder() + .status(axum::http::StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from(body)) + .map_err(|e| ProxyError::Internal(format!("Failed to build response: {}", e)))?; + + Ok(response) +} + +/// Extract Bearer token from Authorization header. +fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) +} + +/// Maximum number of retry attempts for transient backend errors. +/// Budget: 500+1000+2000+4000+5000×4 = ~27.5s (covers .NET cold start). +const MAX_RETRIES: u32 = 8; +/// Initial backoff delay in milliseconds. +const INITIAL_BACKOFF_MS: u64 = 500; +/// Maximum backoff delay in milliseconds (cap for exponential backoff). +const MAX_BACKOFF_MS: u64 = 5_000; +/// Timeout for each individual backend request. +const FORWARD_TIMEOUT_SECS: u64 = 30; + +/// Headers to forward from the client to the backend. +const FORWARD_HEADERS: &[header::HeaderName] = &[header::CONTENT_TYPE, header::ACCEPT]; + +/// MCP-specific header for session tracking. +const MCP_SESSION_ID: &str = "mcp-session-id"; +/// SSE resumption header (client sends this to resume from a specific event). +const LAST_EVENT_ID: &str = "last-event-id"; +const X_TENANT_ID: &str = "x-tenant-id"; + +/// Check if a JSON body is an MCP `initialize` request. +fn is_initialize_request(body: &[u8]) -> bool { + if body.is_empty() { + return false; + } + // Fast path: check for the method string before full parse + let Ok(val) = serde_json::from_slice::(body) else { + return false; + }; + val.get("method").and_then(|m| m.as_str()) == Some("initialize") +} + +/// Outcome of forwarding a request to the backend. +struct BackendResponse { + status: axum::http::StatusCode, + headers: HeaderMap, + is_sse: bool, + /// The backend response (not yet consumed). Only available for non-SSE responses. + body_bytes: Option, + /// For SSE responses, we keep the raw reqwest response to stream from. + raw_response: Option, +} + +/// Check if an HTTP status code is retryable (transient server error). +fn is_retryable_status(status: axum::http::StatusCode) -> bool { + matches!(status.as_u16(), 502 | 503) +} + +/// Check if a proxy error is retryable (connection errors). +fn is_retryable_error(err: &ProxyError) -> bool { + match err { + ProxyError::BackendError(msg) => { + // Network-level failures: reqwest wraps the root cause in + // "error sending request for url (...)" which may NOT contain + // the inner "Connection refused" text depending on the platform. + msg.contains("connection refused") + || msg.contains("Connection refused") + || msg.contains("connect error") + || msg.contains("dns error") + || msg.contains("timed out") + || msg.contains("error sending request") + || msg.contains("connection reset") + || msg.contains("broken pipe") + } + _ => false, + } +} + +/// Send a request to the backend with retry for transient errors. +#[allow(clippy::too_many_arguments)] +async fn send_to_backend_with_retry( + http_client: &HttpClient, + backend_url: &str, + method: &Method, + path: &str, + query: &str, + client_headers: &HeaderMap, + tenant_id: &str, + session_id_override: Option<&str>, + body: Bytes, +) -> Result { + let started = std::time::Instant::now(); + let mut last_error = None; + for attempt in 0..=MAX_RETRIES { + if attempt > 0 { + let delay = (INITIAL_BACKOFF_MS * 2u64.pow(attempt - 1)).min(MAX_BACKOFF_MS); + warn!( + "Retrying backend request ({}/{}) after {}ms", + attempt, MAX_RETRIES, delay + ); + tokio::time::sleep(Duration::from_millis(delay)).await; + } + match send_to_backend( + http_client, + backend_url, + method, + path, + query, + client_headers, + tenant_id, + session_id_override, + body.clone(), + ) + .await + { + Ok(resp) if is_retryable_status(resp.status) && attempt < MAX_RETRIES => { + warn!( + "Backend returned {}, will retry ({}/{})", + resp.status, + attempt + 1, + MAX_RETRIES + ); + last_error = Some(ProxyError::BackendUnavailable( + format!("Backend returned {}", resp.status), + attempt + 1, + )); + } + Ok(resp) => { + if attempt > 0 { + info!( + "Backend request succeeded after {} attempts in {:.1}s", + attempt + 1, + started.elapsed().as_secs_f64() + ); + } + return Ok(resp); + } + Err(e) if is_retryable_error(&e) && attempt < MAX_RETRIES => { + warn!( + "Backend error: {}, will retry ({}/{})", + e, + attempt + 1, + MAX_RETRIES + ); + last_error = Some(e); + } + // Last attempt failed with retryable error → wrap as BackendUnavailable (503) + Err(e) if is_retryable_error(&e) => { + return Err(ProxyError::BackendUnavailable( + e.to_string(), + MAX_RETRIES, + )); + } + Err(e) => return Err(e), + } + } + warn!( + "All {} retries exhausted after {:.1}s", + MAX_RETRIES, + started.elapsed().as_secs_f64() + ); + Err(last_error.map_or_else( + || ProxyError::BackendUnavailable("All retries exhausted".into(), MAX_RETRIES), + |e| ProxyError::BackendUnavailable(e.to_string(), MAX_RETRIES), + )) +} + +/// Send a request to the backend, returning status + headers + body. +#[allow(clippy::too_many_arguments)] +async fn send_to_backend( + http_client: &HttpClient, + backend_url: &str, + method: &Method, + path: &str, + query: &str, + client_headers: &HeaderMap, + tenant_id: &str, + session_id_override: Option<&str>, + body: Bytes, +) -> Result { + let url = format!("{}{}{}", backend_url, path, query); + + debug!("Forwarding {} {} -> {}", method, path, url); + + let mut req = http_client.request( + reqwest::Method::from_bytes(method.as_str().as_bytes()) + .map_err(|e| ProxyError::Internal(format!("Invalid method: {}", e)))?, + &url, + ); + + // Forward relevant headers + for header_name in FORWARD_HEADERS { + if let Some(value) = client_headers.get(header_name) { + if let Ok(s) = value.to_str() { + req = req.header(header_name.as_str(), s); + } + } + } + + // Use override session ID if provided, otherwise forward client's + if let Some(sid) = session_id_override { + req = req.header(MCP_SESSION_ID, sid); + } else if let Some(value) = client_headers.get(MCP_SESSION_ID) { + if let Ok(s) = value.to_str() { + req = req.header(MCP_SESSION_ID, s); + } + } + + // Forward Last-Event-ID for SSE stream resumption + if let Some(value) = client_headers.get(LAST_EVENT_ID) { + if let Ok(s) = value.to_str() { + req = req.header(LAST_EVENT_ID, s); + } + } + + // Inject tenant ID + req = req.header(X_TENANT_ID, tenant_id); + + // Forward body + if !body.is_empty() { + debug!( + "Request body ({} bytes): {}", + body.len(), + String::from_utf8_lossy(&body[..body.len().min(2048)]) + ); + req = req.body(body); + } + + // Send with timeout + let resp = req + .timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS)) + .send() + .await + .map_err(|e| ProxyError::BackendError(format!("Failed to reach backend: {}", e)))?; + + let status = axum::http::StatusCode::from_u16(resp.status().as_u16()) + .unwrap_or(axum::http::StatusCode::BAD_GATEWAY); + + debug!( + "Backend response: {} (content-type: {:?})", + status, + resp.headers().get("content-type") + ); + + let mut response_headers = HeaderMap::new(); + + // Forward content-type + if let Some(ct) = resp.headers().get(reqwest::header::CONTENT_TYPE) { + if let Ok(v) = HeaderValue::from_bytes(ct.as_bytes()) { + response_headers.insert(header::CONTENT_TYPE, v); + } + } + + // Forward Mcp-Session-Id from backend + if let Some(session_id) = resp.headers().get(MCP_SESSION_ID) { + if let Ok(v) = HeaderValue::from_bytes(session_id.as_bytes()) { + response_headers.insert( + header::HeaderName::from_static("mcp-session-id"), + v, + ); + } + } + + let is_sse = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| v.contains("text/event-stream")) + .unwrap_or(false); + + if is_sse { + Ok(BackendResponse { + status, + headers: response_headers, + is_sse: true, + body_bytes: None, + raw_response: Some(resp), + }) + } else { + let body_bytes = resp + .bytes() + .await + .map_err(|e| ProxyError::BackendError(format!("Failed to read backend response: {}", e)))?; + + debug!( + "Response body ({} bytes): {}", + body_bytes.len(), + String::from_utf8_lossy(&body_bytes[..body_bytes.len().min(2048)]) + ); + + Ok(BackendResponse { + status, + headers: response_headers, + is_sse: false, + body_bytes: Some(body_bytes), + raw_response: None, + }) + } +} + +/// Convert a BackendResponse into an axum Response. +fn into_response(br: BackendResponse) -> Result { + if br.is_sse { + let raw = br.raw_response.expect("SSE response must have raw_response"); + debug!("Starting SSE stream forwarding"); + let stream = raw.bytes_stream(); + let body = Body::from_stream(stream); + + let mut response = Response::builder() + .status(br.status) + .body(body) + .map_err(|e| ProxyError::Internal(format!("Failed to build response: {}", e)))?; + + *response.headers_mut() = br.headers; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/event-stream"), + ); + + Ok(response) + } else { + let body_bytes = br.body_bytes.unwrap_or_default(); + let mut response = (br.status, body_bytes).into_response(); + + for (name, value) in br.headers { + if let Some(name) = name { + response.headers_mut().insert(name, value); + } + } + + Ok(response) + } +} + +/// Extract the Mcp-Session-Id value from response headers. +fn extract_session_id_from_headers(headers: &HeaderMap) -> Option { + headers + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) +} + +/// Perform a synthetic MCP initialize + notifications/initialized handshake +/// against the backend to obtain a new session ID. +async fn reinitialize_session( + http_client: &HttpClient, + backend_url: &str, + tenant_id: &str, +) -> Result { + info!("Sending synthetic initialize to backend for tenant {}", tenant_id); + + let init_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "docx-mcp-sse-proxy", + "version": env!("CARGO_PKG_VERSION") + } + } + }); + + let url = format!("{}/mcp", backend_url); + + let resp = http_client + .post(&url) + .header("Content-Type", "application/json") + .header(X_TENANT_ID, tenant_id) + .json(&init_body) + .send() + .await + .map_err(|e| { + ProxyError::SessionRecoveryFailed(format!("Initialize request failed: {}", e)) + })?; + + if !resp.status().is_success() { + return Err(ProxyError::SessionRecoveryFailed(format!( + "Initialize returned {}", + resp.status() + ))); + } + + let new_session_id = resp + .headers() + .get(MCP_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| { + ProxyError::SessionRecoveryFailed( + "Initialize response missing Mcp-Session-Id header".into(), + ) + })?; + + // Read the init response body (we don't need it, but must consume it) + let _ = resp.bytes().await; + + // Send notifications/initialized + let notif_body = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + + let notif_resp = http_client + .post(&url) + .header("Content-Type", "application/json") + .header(MCP_SESSION_ID, &new_session_id) + .header(X_TENANT_ID, tenant_id) + .json(¬if_body) + .send() + .await + .map_err(|e| { + ProxyError::SessionRecoveryFailed(format!( + "notifications/initialized request failed: {}", + e + )) + })?; + + if !notif_resp.status().is_success() { + warn!( + "notifications/initialized returned {} (non-fatal)", + notif_resp.status() + ); + } + + // Consume body + let _ = notif_resp.bytes().await; + + info!( + "Session recovered for tenant {}: new session ID {}", + tenant_id, new_session_id + ); + + Ok(new_session_id) +} + +/// Forward any request on /mcp (POST, GET, DELETE) to the .NET backend. +/// +/// This is a transparent reverse proxy with session recovery: +/// 1. Validates PAT → extracts tenant_id +/// 2. Forwards the request to {MCP_BACKEND_URL}/mcp with X-Tenant-Id header +/// 3. If backend returns 404 (session lost), transparently re-initializes and retries +/// 4. Streams the response back (SSE or JSON) +pub async fn mcp_forward_handler( + State(state): State, + req: Request, +) -> std::result::Result { + // --- 1. Authenticate (PAT or OAuth) --- + // Set resource metadata URL for WWW-Authenticate header on 401 + set_resource_metadata_url(state.resource_url.clone()); + + let tenant_id = if state.validator.is_some() || state.oauth_validator.is_some() { + let token = extract_bearer_token(req.headers()).ok_or(ProxyError::Unauthorized)?; + + if OAuthValidator::is_oauth_token(token) { + // Try OAuth token (oat_...) + let oauth_validator = state + .oauth_validator + .as_ref() + .ok_or(ProxyError::InvalidToken)?; + let validation = oauth_validator.validate(token).await?; + info!( + "Authenticated request for tenant {} (OAuth: {}...)", + validation.tenant_id, + &token[..12.min(token.len())] + ); + validation.tenant_id + } else { + // Try PAT token (dxs_...) + let pat_validator = state.validator.as_ref().ok_or(ProxyError::InvalidToken)?; + let validation = pat_validator.validate(token).await?; + info!( + "Authenticated request for tenant {} (PAT: {}...)", + validation.tenant_id, + &validation.pat_id[..8.min(validation.pat_id.len())] + ); + validation.tenant_id + } + } else { + debug!("Auth not configured, using default tenant"); + String::new() + }; + + // --- 2. Capture request parts --- + let method = req.method().clone(); + let uri = req.uri().clone(); + let path = uri.path().to_string(); + let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default(); + let client_headers = req.headers().clone(); + + let body_bytes: Bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024) + .await + .map_err(|e| ProxyError::Internal(format!("Failed to read body: {}", e)))?; + + let is_init = is_initialize_request(&body_bytes); + let is_delete = method == Method::DELETE; + + // --- 3. Resolve session ID --- + // For initialize: don't inject a session ID (backend creates a new one). + // For other requests: use registry session ID if available, else fall through + // to whatever the client sent. + let registry_session_id = if !is_init { + state.sessions.get_session_id(&tenant_id).await + } else { + None + }; + + // --- 4. Forward to backend --- + let backend_resp = send_to_backend_with_retry( + &state.http_client, + &state.backend_url, + &method, + &path, + &query, + &client_headers, + &tenant_id, + registry_session_id.as_deref(), + body_bytes.clone(), + ) + .await?; + + // --- 5. Handle 404 → session recovery --- + if backend_resp.status == axum::http::StatusCode::NOT_FOUND && !is_init && !is_delete { + info!( + "Session expired for tenant {}, attempting recovery", + tenant_id + ); + + // Invalidate the stale session + state.sessions.invalidate(&tenant_id).await; + + // Acquire per-tenant recovery lock (serializes concurrent recoveries) + let _guard = state.sessions.acquire_recovery_lock(&tenant_id).await; + + // Double-check: another request may have already recovered + if let Some(new_sid) = state.sessions.get_session_id(&tenant_id).await { + debug!( + "Session already recovered by another request for tenant {}", + tenant_id + ); + // Retry with the recovered session ID + let retry_resp = send_to_backend( + &state.http_client, + &state.backend_url, + &method, + &path, + &query, + &client_headers, + &tenant_id, + Some(&new_sid), + body_bytes, + ) + .await?; + + // Cache any new session ID from the retry + if let Some(sid) = extract_session_id_from_headers(&retry_resp.headers) { + state.sessions.set_session_id(&tenant_id, sid).await; + } + + return into_response(retry_resp); + } + + // We are the first to recover: re-initialize + let new_session_id = reinitialize_session( + &state.http_client, + &state.backend_url, + &tenant_id, + ) + .await?; + + state + .sessions + .set_session_id(&tenant_id, new_session_id.clone()) + .await; + + // Retry the original request with the new session ID + let retry_resp = send_to_backend( + &state.http_client, + &state.backend_url, + &method, + &path, + &query, + &client_headers, + &tenant_id, + Some(&new_session_id), + body_bytes, + ) + .await?; + + // Cache any updated session ID + if let Some(sid) = extract_session_id_from_headers(&retry_resp.headers) { + state.sessions.set_session_id(&tenant_id, sid).await; + } + + return into_response(retry_resp); + } + + // --- 6. Normal path: cache session ID and return response --- + if let Some(sid) = extract_session_id_from_headers(&backend_resp.headers) { + state.sessions.set_session_id(&tenant_id, sid).await; + } + + // On DELETE, clear the registry entry + if is_delete && backend_resp.status.is_success() { + state.sessions.invalidate(&tenant_id).await; + } + + into_response(backend_resp) +} diff --git a/crates/docx-mcp-sse-proxy/src/main.rs b/crates/docx-mcp-sse-proxy/src/main.rs new file mode 100644 index 0000000..5dc23b4 --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/main.rs @@ -0,0 +1,206 @@ +//! HTTP reverse proxy for docx-mcp multi-tenant architecture. +//! +//! This proxy: +//! - Receives MCP Streamable HTTP requests (POST/GET/DELETE /mcp) +//! - Validates PAT tokens via Cloudflare D1 +//! - Extracts tenant_id from validated tokens +//! - Forwards requests to the .NET MCP HTTP backend with X-Tenant-Id header +//! - Streams responses (SSE or JSON) back to clients + +use std::sync::Arc; + +use axum::routing::{any, get}; +use axum::Router; +use clap::Parser; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder; +use tokio::net::TcpListener; +use tokio::signal; +use tower::Service; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::trace::TraceLayer; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; + +mod auth; +mod config; +mod error; +mod handlers; +mod oauth; +mod session; + +use auth::{PatValidator, SharedPatValidator}; +use config::Config; +use handlers::{health_handler, mcp_forward_handler, oauth_metadata_handler, upstream_health_handler, AppState}; +use oauth::{OAuthValidator, SharedOAuthValidator}; +use session::SessionRegistry; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let config = Config::parse(); + + info!( + "Starting docx-mcp-sse-proxy v{}", + env!("CARGO_PKG_VERSION") + ); + info!(" Host: {}", config.host); + info!(" Port: {}", config.port); + info!(" Backend: {}", config.mcp_backend_url); + + // Create PAT and OAuth validators if D1 credentials are configured + let (validator, oauth_validator): (Option, Option) = + if config.cloudflare_account_id.is_some() + && config.cloudflare_api_token.is_some() + && config.d1_database_id.is_some() + { + let account_id = config.cloudflare_account_id.clone().unwrap(); + let api_token = config.cloudflare_api_token.clone().unwrap(); + let database_id = config.d1_database_id.clone().unwrap(); + + info!(" Auth: D1 PAT + OAuth validation enabled"); + info!( + " Cache TTL: {}s (negative: {}s)", + config.pat_cache_ttl_secs, config.pat_negative_cache_ttl_secs + ); + + let pat = Arc::new(PatValidator::new( + account_id.clone(), + api_token.clone(), + database_id.clone(), + config.pat_cache_ttl_secs, + config.pat_negative_cache_ttl_secs, + )); + + let oauth = Arc::new(OAuthValidator::new( + account_id, + api_token, + database_id, + config.pat_cache_ttl_secs, + config.pat_negative_cache_ttl_secs, + )); + + (Some(pat), Some(oauth)) + } else { + warn!(" Auth: DISABLED (no D1 credentials configured)"); + warn!(" Set CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, and D1_DATABASE_ID to enable auth"); + (None, None) + }; + + // Create HTTP client for forwarding + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(300)) + .build() + .expect("Failed to create HTTP client"); + + // Normalize backend URL (strip trailing slash) + let backend_url = config.mcp_backend_url.trim_end_matches('/').to_string(); + + // OAuth resource metadata config + let resource_url = config.resource_url.clone(); + let auth_server_url = config.auth_server_url.clone(); + if let Some(ref url) = resource_url { + info!(" Resource URL: {}", url); + } + if let Some(ref url) = auth_server_url { + info!(" Auth Server URL: {}", url); + } + + // Build application state + let state = AppState { + validator, + oauth_validator, + backend_url, + http_client, + sessions: Arc::new(SessionRegistry::new()), + resource_url, + auth_server_url, + }; + + // Configure CORS + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + // Build router + let app = Router::new() + .route("/health", get(health_handler)) + .route("/upstream-health", get(upstream_health_handler)) + .route( + "/.well-known/oauth-protected-resource", + get(oauth_metadata_handler), + ) + .route("/mcp", any(mcp_forward_handler)) + .route("/mcp/{*rest}", any(mcp_forward_handler)) + .layer(cors) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + // Bind and serve (HTTP/1.1 + HTTP/2 h2c dual-stack) + let addr = format!("{}:{}", config.host, config.port); + let listener = TcpListener::bind(&addr).await?; + info!("Listening on http://{} (HTTP/1.1 + h2c)", addr); + + let shutdown = shutdown_signal(); + tokio::pin!(shutdown); + + loop { + tokio::select! { + result = listener.accept() => { + let (stream, _remote_addr) = result?; + let tower_service = app.clone(); + tokio::spawn(async move { + let hyper_service = hyper::service::service_fn(move |req| { + tower_service.clone().call(req) + }); + if let Err(err) = Builder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(TokioIo::new(stream), hyper_service) + .await + { + tracing::debug!("connection error: {err}"); + } + }); + } + _ = &mut shutdown => { + info!("Shutting down"); + break; + } + } + } + + info!("Server shutdown complete"); + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + info!("Received Ctrl+C, initiating shutdown"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM handler") + .recv() + .await; + info!("Received SIGTERM, initiating shutdown"); + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/crates/docx-mcp-sse-proxy/src/oauth.rs b/crates/docx-mcp-sse-proxy/src/oauth.rs new file mode 100644 index 0000000..ebc4dc9 --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/oauth.rs @@ -0,0 +1,256 @@ +//! OAuth access token validation via Cloudflare D1 API. +//! +//! Validates opaque OAuth access tokens (oat_...) against the D1 database +//! using the Cloudflare REST API. Always queries D1 directly (no cache) so that +//! token revocation takes effect immediately. + +use std::sync::Arc; + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing::{debug, warn}; + +use crate::error::{ProxyError, Result}; + +/// OAuth access token prefix. +const TOKEN_PREFIX: &str = "oat_"; + +/// Result of an OAuth token validation. +#[derive(Debug, Clone)] +pub struct OAuthValidationResult { + pub tenant_id: String, + #[allow(dead_code)] + pub scope: String, +} + +/// D1 query request body. +#[derive(Serialize)] +struct D1QueryRequest { + sql: String, + params: Vec, +} + +/// D1 API response structure. +#[derive(Deserialize)] +struct D1Response { + success: bool, + result: Option>, + errors: Option>, +} + +#[derive(Deserialize)] +struct D1QueryResult { + results: Vec, +} + +#[derive(Deserialize)] +struct D1Error { + message: String, +} + +/// OAuth access token record from D1. +#[derive(Deserialize)] +struct OAuthTokenRecord { + id: String, + #[serde(rename = "tenantId")] + tenant_id: String, + scope: String, + #[serde(rename = "expiresAt")] + expires_at: String, +} + +/// OAuth token validator with D1 backend. +pub struct OAuthValidator { + client: Client, + account_id: String, + api_token: String, + database_id: String, +} + +impl OAuthValidator { + /// Create a new OAuth validator. + pub fn new( + account_id: String, + api_token: String, + database_id: String, + _cache_ttl_secs: u64, + _negative_cache_ttl_secs: u64, + ) -> Self { + Self { + client: Client::new(), + account_id, + api_token, + database_id, + } + } + + /// Check if a token has the OAuth prefix. + pub fn is_oauth_token(token: &str) -> bool { + token.starts_with(TOKEN_PREFIX) + } + + /// Validate an OAuth access token. + pub async fn validate(&self, token: &str) -> Result { + if !token.starts_with(TOKEN_PREFIX) { + return Err(ProxyError::InvalidToken); + } + + let token_hash = self.hash_token(token); + + // Always validate against D1 (no cache for OAuth tokens — revocation must be immediate) + debug!( + "Validating OAuth token against D1 for {}", + &token[..12.min(token.len())] + ); + match self.query_d1(&token_hash).await { + Ok(Some(result)) => Ok(result), + Ok(None) => Err(ProxyError::InvalidToken), + Err(e) => { + warn!("D1 query failed for OAuth token: {}", e); + Err(e) + } + } + } + + fn hash_token(&self, token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) + } + + async fn query_d1(&self, token_hash: &str) -> Result> { + let url = format!( + "https://api.cloudflare.com/client/v4/accounts/{}/d1/database/{}/query", + self.account_id, self.database_id + ); + + let query = D1QueryRequest { + sql: "SELECT id, tenantId, scope, expiresAt FROM oauth_access_token WHERE tokenHash = ?1" + .to_string(), + params: vec![token_hash.to_string()], + }; + + let response = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Content-Type", "application/json") + .json(&query) + .send() + .await + .map_err(|e| ProxyError::D1Error(e.to_string()))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| ProxyError::D1Error(e.to_string()))?; + + if !status.is_success() { + return Err(ProxyError::D1Error(format!( + "D1 API returned {}: {}", + status, body + ))); + } + + let d1_response: D1Response = + serde_json::from_str(&body).map_err(|e| ProxyError::D1Error(e.to_string()))?; + + if !d1_response.success { + let error_msg = d1_response + .errors + .map(|errs| { + errs.into_iter() + .map(|e| e.message) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "Unknown D1 error".to_string()); + return Err(ProxyError::D1Error(error_msg)); + } + + let record = d1_response + .result + .and_then(|mut results| results.pop()) + .and_then(|mut query_result| query_result.results.pop()); + + match record { + Some(token_record) => { + // Check expiration + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&token_record.expires_at) + { + if expires < chrono::Utc::now() { + debug!("OAuth token {} is expired", &token_record.id[..8]); + return Ok(None); + } + } + + // Update last_used_at asynchronously + self.update_last_used(&token_record.id).await; + + Ok(Some(OAuthValidationResult { + tenant_id: token_record.tenant_id, + scope: token_record.scope, + })) + } + None => Ok(None), + } + } + + async fn update_last_used(&self, token_id: &str) { + let url = format!( + "https://api.cloudflare.com/client/v4/accounts/{}/d1/database/{}/query", + self.account_id, self.database_id + ); + + let now = chrono::Utc::now().to_rfc3339(); + let query = D1QueryRequest { + sql: "UPDATE oauth_access_token SET lastUsedAt = ?1 WHERE id = ?2".to_string(), + params: vec![now, token_id.to_string()], + }; + + let client = self.client.clone(); + let api_token = self.api_token.clone(); + tokio::spawn(async move { + if let Err(e) = client + .post(&url) + .header("Authorization", format!("Bearer {}", api_token)) + .header("Content-Type", "application/json") + .json(&query) + .send() + .await + { + warn!("Failed to update OAuth token lastUsedAt: {}", e); + } + }); + } +} + +pub type SharedOAuthValidator = Arc; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_oauth_token() { + assert!(OAuthValidator::is_oauth_token("oat_abcdef1234567890")); + assert!(!OAuthValidator::is_oauth_token("dxs_abcdef1234567890")); + assert!(!OAuthValidator::is_oauth_token("invalid")); + } + + #[tokio::test] + async fn test_invalid_prefix() { + let validator = OAuthValidator::new( + "test_account".to_string(), + "test_token".to_string(), + "test_db".to_string(), + 300, + 60, + ); + + let result = validator.validate("invalid_token").await; + assert!(matches!(result, Err(ProxyError::InvalidToken))); + } +} diff --git a/crates/docx-mcp-sse-proxy/src/session.rs b/crates/docx-mcp-sse-proxy/src/session.rs new file mode 100644 index 0000000..550ec61 --- /dev/null +++ b/crates/docx-mcp-sse-proxy/src/session.rs @@ -0,0 +1,74 @@ +//! Per-tenant MCP session registry with recovery coordination. +//! +//! The backend .NET MCP server keeps transport sessions in memory. +//! When it restarts, those sessions are lost and clients get 404. +//! This registry tracks the current backend session ID per tenant +//! and coordinates recovery (re-initialize) when a 404 is detected. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard, RwLock}; + +/// Tracks the current backend MCP session ID for each tenant +/// and serializes recovery attempts per tenant. +pub struct SessionRegistry { + inner: Mutex>>, +} + +struct TenantEntry { + /// Current backend session ID (read-heavy, write-rare). + session_id: RwLock>, + /// Serializes re-initialization attempts so only one request + /// performs the initialize handshake per tenant. + recovery_lock: Arc>, +} + +impl SessionRegistry { + pub fn new() -> Self { + Self { + inner: Mutex::new(HashMap::new()), + } + } + + /// Get or create the entry for a tenant. + fn entry(&self, tenant_id: &str) -> Arc { + let mut map = self.inner.lock().expect("session registry poisoned"); + map.entry(tenant_id.to_string()) + .or_insert_with(|| { + Arc::new(TenantEntry { + session_id: RwLock::new(None), + recovery_lock: Arc::new(AsyncMutex::new(())), + }) + }) + .clone() + } + + /// Get the current backend session ID for a tenant (if any). + pub async fn get_session_id(&self, tenant_id: &str) -> Option { + let entry = self.entry(tenant_id); + let guard = entry.session_id.read().await; + guard.clone() + } + + /// Store a new backend session ID for a tenant. + pub async fn set_session_id(&self, tenant_id: &str, session_id: String) { + let entry = self.entry(tenant_id); + *entry.session_id.write().await = Some(session_id); + } + + /// Clear the session ID for a tenant (e.g. after detecting 404). + pub async fn invalidate(&self, tenant_id: &str) { + let entry = self.entry(tenant_id); + *entry.session_id.write().await = None; + } + + /// Acquire the recovery lock for a tenant. Only one recovery + /// attempt proceeds at a time; others wait and then check if + /// a new session ID was already established. + pub async fn acquire_recovery_lock(&self, tenant_id: &str) -> OwnedMutexGuard<()> { + let entry = self.entry(tenant_id); + let lock = Arc::clone(&entry.recovery_lock); + lock.lock_owned().await + } +} diff --git a/crates/docx-storage-cloudflare/Cargo.toml b/crates/docx-storage-cloudflare/Cargo.toml new file mode 100644 index 0000000..c6f4f83 --- /dev/null +++ b/crates/docx-storage-cloudflare/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "docx-storage-cloudflare" +description = "Cloudflare R2 storage backend for docx-mcp multi-tenant architecture" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +# Core traits +docx-storage-core = { path = "../docx-storage-core" } + +# gRPC +tonic.workspace = true +tonic-reflection = "0.13" +prost.workspace = true +prost-types.workspace = true +tokio.workspace = true +tokio-stream.workspace = true + +# S3/R2 client (R2 is S3-compatible) +aws-sdk-s3.workspace = true +aws-config.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true +serde_bytes = "0.11" + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Async utilities +async-trait.workspace = true + +# Time +chrono.workspace = true + +# CLI +clap.workspace = true + +# Base64 encoding +base64 = "0.22" + +# Bytes +bytes = "1" + +[build-dependencies] +tonic-build = "0.13" + +[dev-dependencies] +tempfile.workspace = true +tokio-test = "0.4" + +[[bin]] +name = "docx-storage-cloudflare" +path = "src/main.rs" + +[lints] +workspace = true diff --git a/crates/docx-storage-cloudflare/Dockerfile b/crates/docx-storage-cloudflare/Dockerfile new file mode 100644 index 0000000..bf78d91 --- /dev/null +++ b/crates/docx-storage-cloudflare/Dockerfile @@ -0,0 +1,68 @@ +# ============================================================================= +# docx-storage-cloudflare Dockerfile +# Multi-stage build for the gRPC storage server (Cloudflare R2 + KV) +# ============================================================================= + +# Stage 1: Build +FROM rust:1.93-slim-bookworm AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + protobuf-compiler \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +# Build the storage server +RUN cargo build --release --package docx-storage-cloudflare + +# Stage 2: Runtime +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies (minimal) +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 docx +USER docx + +WORKDIR /app + +# Copy the binary from builder +COPY --from=builder /build/target/release/docx-storage-cloudflare /app/docx-storage-cloudflare + +# Environment defaults +ENV RUST_LOG=info +ENV GRPC_HOST=0.0.0.0 +ENV GRPC_PORT=50051 + +# Required environment variables (must be set at runtime): +# CLOUDFLARE_ACCOUNT_ID +# CLOUDFLARE_API_TOKEN +# R2_BUCKET_NAME +# KV_NAMESPACE_ID +# R2_ACCESS_KEY_ID +# R2_SECRET_ACCESS_KEY + +# Optional: +# WATCH_POLL_INTERVAL (default: 30 seconds) + +# Expose gRPC port +EXPOSE 50051 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["timeout", "5", "grpc_health_probe", "-addr=localhost:50051"] || exit 1 + +# Run the server +ENTRYPOINT ["/app/docx-storage-cloudflare"] +CMD ["--transport", "tcp"] diff --git a/crates/docx-storage-cloudflare/build.rs b/crates/docx-storage-cloudflare/build.rs new file mode 100644 index 0000000..3f0b33f --- /dev/null +++ b/crates/docx-storage-cloudflare/build.rs @@ -0,0 +1,11 @@ +fn main() -> Result<(), Box> { + // Compile the protobuf definitions + tonic_build::configure() + .build_server(true) + .build_client(false) + .file_descriptor_set_path( + std::path::PathBuf::from(std::env::var("OUT_DIR")?).join("storage_descriptor.bin"), + ) + .compile_protos(&["../../proto/storage.proto"], &["../../proto"])?; + Ok(()) +} diff --git a/crates/docx-storage-cloudflare/src/config.rs b/crates/docx-storage-cloudflare/src/config.rs new file mode 100644 index 0000000..dec33f5 --- /dev/null +++ b/crates/docx-storage-cloudflare/src/config.rs @@ -0,0 +1,41 @@ +use clap::Parser; + +/// Configuration for the docx-storage-cloudflare server. +#[derive(Parser, Debug, Clone)] +#[command(name = "docx-storage-cloudflare")] +#[command(about = "Cloudflare R2 gRPC storage server for docx-mcp")] +pub struct Config { + /// TCP host to bind to + #[arg(long, default_value = "0.0.0.0", env = "GRPC_HOST")] + pub host: String, + + /// TCP port to bind to + #[arg(long, default_value = "50051", env = "GRPC_PORT")] + pub port: u16, + + /// Cloudflare account ID + #[arg(long, env = "CLOUDFLARE_ACCOUNT_ID")] + pub cloudflare_account_id: String, + + /// R2 bucket name for session/checkpoint/index storage + #[arg(long, env = "R2_BUCKET_NAME")] + pub r2_bucket_name: String, + + /// R2 access key ID (for S3-compatible API) + #[arg(long, env = "R2_ACCESS_KEY_ID")] + pub r2_access_key_id: String, + + /// R2 secret access key (for S3-compatible API) + #[arg(long, env = "R2_SECRET_ACCESS_KEY")] + pub r2_secret_access_key: String, +} + +impl Config { + /// Get the R2 endpoint URL for S3-compatible API. + pub fn r2_endpoint(&self) -> String { + format!( + "https://{}.r2.cloudflarestorage.com", + self.cloudflare_account_id + ) + } +} diff --git a/crates/docx-storage-cloudflare/src/error.rs b/crates/docx-storage-cloudflare/src/error.rs new file mode 100644 index 0000000..1549774 --- /dev/null +++ b/crates/docx-storage-cloudflare/src/error.rs @@ -0,0 +1,27 @@ +// Re-export from docx-storage-core +pub use docx_storage_core::StorageError; + +/// Convert StorageError to tonic::Status +pub fn storage_error_to_status(err: StorageError) -> tonic::Status { + match err { + StorageError::Io(msg) => tonic::Status::internal(msg), + StorageError::Serialization(msg) => tonic::Status::internal(msg), + StorageError::NotFound(msg) => tonic::Status::not_found(msg), + StorageError::Lock(msg) => tonic::Status::failed_precondition(msg), + StorageError::InvalidArgument(msg) => tonic::Status::invalid_argument(msg), + StorageError::Internal(msg) => tonic::Status::internal(msg), + StorageError::Sync(msg) => tonic::Status::internal(msg), + StorageError::Watch(msg) => tonic::Status::internal(msg), + } +} + +/// Extension trait for converting StorageError Result to tonic::Status Result +pub trait StorageResultExt { + fn map_storage_err(self) -> Result; +} + +impl StorageResultExt for Result { + fn map_storage_err(self) -> Result { + self.map_err(storage_error_to_status) + } +} diff --git a/crates/docx-storage-cloudflare/src/main.rs b/crates/docx-storage-cloudflare/src/main.rs new file mode 100644 index 0000000..0cee2b3 --- /dev/null +++ b/crates/docx-storage-cloudflare/src/main.rs @@ -0,0 +1,127 @@ +mod config; +mod error; +mod service; +mod storage; + +use std::sync::Arc; + +use aws_config::Region; +use aws_sdk_s3::config::{BehaviorVersion, Credentials}; +use clap::Parser; +use tokio::signal; +use tokio::sync::watch as tokio_watch; +use tonic::transport::Server; +use tonic_reflection::server::Builder as ReflectionBuilder; +use tracing::info; +use tracing_subscriber::EnvFilter; + +use config::Config; +use service::proto::storage_service_server::StorageServiceServer; +use service::StorageServiceImpl; +use storage::R2Storage; + +/// File descriptor set for gRPC reflection +pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("storage_descriptor"); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let config = Config::parse(); + + info!("Starting docx-storage-cloudflare server"); + info!(" R2 bucket: {}", config.r2_bucket_name); + + // Create S3 client for R2 + let credentials = Credentials::new( + &config.r2_access_key_id, + &config.r2_secret_access_key, + None, + None, + "r2", + ); + + let s3_config = aws_sdk_s3::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .credentials_provider(credentials) + .region(Region::new("auto")) + .endpoint_url(config.r2_endpoint()) + .force_path_style(true) + .build(); + + let s3_client = aws_sdk_s3::Client::from_conf(s3_config); + + // Create storage backend (R2 only — no sync/watch, Cloudflare is just a WAL/session store) + let storage = Arc::new(R2Storage::new( + s3_client, + config.r2_bucket_name.clone(), + )); + + // Create gRPC service (StorageService only) + let storage_service = StorageServiceImpl::new(storage); + let storage_svc = StorageServiceServer::new(storage_service); + + // Create shutdown signal + let mut shutdown_rx = create_shutdown_signal(); + let shutdown_future = async move { + let _ = shutdown_rx.wait_for(|&v| v).await; + }; + + // Create reflection service + let reflection_svc = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1()?; + + // Start server + let addr = format!("{}:{}", config.host, config.port).parse()?; + info!("Listening on tcp://{}", addr); + + Server::builder() + .add_service(reflection_svc) + .add_service(storage_svc) + .serve_with_shutdown(addr, shutdown_future) + .await?; + + info!("Server shutdown complete"); + Ok(()) +} + +/// Create a shutdown signal that triggers on Ctrl+C or SIGTERM. +fn create_shutdown_signal() -> tokio_watch::Receiver { + let (tx, rx) = tokio_watch::channel(false); + + tokio::spawn(async move { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + info!("Received Ctrl+C, initiating shutdown"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM handler") + .recv() + .await; + info!("Received SIGTERM, initiating shutdown"); + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + let _ = tx.send(true); + }); + + rx +} diff --git a/crates/docx-storage-cloudflare/src/service.rs b/crates/docx-storage-cloudflare/src/service.rs new file mode 100644 index 0000000..d842cf0 --- /dev/null +++ b/crates/docx-storage-cloudflare/src/service.rs @@ -0,0 +1,662 @@ +use std::pin::Pin; +use std::sync::Arc; + +use tokio::sync::mpsc; +use tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt}; +use tonic::{Request, Response, Status, Streaming}; +use tracing::{debug, instrument}; + +use crate::error::StorageResultExt; +use crate::storage::{R2Storage, StorageBackend}; + +// Include the generated protobuf code +pub mod proto { + tonic::include_proto!("docx.storage"); +} + +use proto::storage_service_server::StorageService; +use proto::*; + +/// Default chunk size for streaming: 256KB +const DEFAULT_CHUNK_SIZE: usize = 256 * 1024; + +/// Implementation of the StorageService gRPC service. +pub struct StorageServiceImpl { + storage: Arc, + version: String, + chunk_size: usize, +} + +impl StorageServiceImpl { + pub fn new(storage: Arc) -> Self { + Self { + storage, + version: env!("CARGO_PKG_VERSION").to_string(), + chunk_size: DEFAULT_CHUNK_SIZE, + } + } + + /// Extract tenant_id from request context. + fn get_tenant_id(context: Option<&TenantContext>) -> Result<&str, Status> { + context + .map(|c| c.tenant_id.as_str()) + .ok_or_else(|| Status::invalid_argument("tenant context is required")) + } +} + +type StreamResult = Pin> + Send>>; + +#[tonic::async_trait] +impl StorageService for StorageServiceImpl { + type LoadSessionStream = StreamResult; + type LoadCheckpointStream = StreamResult; + + // ========================================================================= + // Session Operations (Streaming) + // ========================================================================= + + #[instrument(skip(self, request), level = "debug")] + async fn load_session( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id.clone(); + + let result = self + .storage + .load_session(&tenant_id, &session_id) + .await + .map_storage_err()?; + + let (tx, rx) = mpsc::channel(4); + let chunk_size = self.chunk_size; + + tokio::spawn(async move { + match result { + Some(data) => { + let total_size = data.len() as u64; + let chunks: Vec> = data.chunks(chunk_size).map(|c| c.to_vec()).collect(); + let total_chunks = chunks.len(); + + for (i, chunk) in chunks.into_iter().enumerate() { + let is_first = i == 0; + let is_last = i == total_chunks - 1; + + let msg = DataChunk { + data: chunk, + is_last, + found: is_first, + total_size: if is_first { total_size } else { 0 }, + }; + + if tx.send(Ok(msg)).await.is_err() { + break; + } + } + } + None => { + let _ = tx + .send(Ok(DataChunk { + data: vec![], + is_last: true, + found: false, + total_size: 0, + })) + .await; + } + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + #[instrument(skip(self, request), level = "debug")] + async fn save_session( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + + let mut tenant_id: Option = None; + let mut session_id: Option = None; + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + + if tenant_id.is_none() { + tenant_id = chunk.context.map(|c| c.tenant_id); + session_id = Some(chunk.session_id); + } + + data.extend(chunk.data); + + if chunk.is_last { + break; + } + } + + let tenant_id = tenant_id + .ok_or_else(|| Status::invalid_argument("tenant context is required in first chunk"))?; + let session_id = session_id + .filter(|s| !s.is_empty()) + .ok_or_else(|| Status::invalid_argument("session_id is required in first chunk"))?; + + debug!( + "Saving session {} for tenant {} ({} bytes)", + session_id, + tenant_id, + data.len() + ); + + self.storage + .save_session(&tenant_id, &session_id, &data) + .await + .map_storage_err()?; + + Ok(Response::new(SaveSessionResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_sessions( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let sessions = self + .storage + .list_sessions(tenant_id) + .await + .map_storage_err()?; + + let sessions = sessions + .into_iter() + .map(|s| SessionInfo { + session_id: s.session_id, + source_path: s.source_path.unwrap_or_default(), + created_at_unix: s.created_at.timestamp(), + modified_at_unix: s.modified_at.timestamp(), + size_bytes: s.size_bytes as i64, + }) + .collect(); + + Ok(Response::new(ListSessionsResponse { sessions })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn delete_session( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let existed = self + .storage + .delete_session(tenant_id, &req.session_id) + .await + .map_storage_err()?; + + Ok(Response::new(DeleteSessionResponse { + success: true, + existed, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn session_exists( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let exists = self + .storage + .session_exists(tenant_id, &req.session_id) + .await + .map_storage_err()?; + + // Read pending_external_change from the index + let pending_external_change = if exists { + self.storage + .load_index(tenant_id) + .await + .map_storage_err()? + .and_then(|idx| idx.get(&req.session_id).map(|e| e.pending_external_change)) + .unwrap_or(false) + } else { + false + }; + + Ok(Response::new(SessionExistsResponse { exists, pending_external_change })) + } + + // ========================================================================= + // Index Operations (Atomic - ETag-based CAS, no external lock) + // ========================================================================= + + #[instrument(skip(self, request), level = "debug")] + async fn load_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let result = self + .storage + .load_index(tenant_id) + .await + .map_storage_err()?; + + let (index_json, found) = match result { + Some(index) => { + let json = serde_json::to_vec(&index) + .map_err(|e| Status::internal(format!("Failed to serialize index: {}", e)))?; + (json, true) + } + None => (vec![], false), + }; + + Ok(Response::new(LoadIndexResponse { index_json, found })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn add_session_to_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id; + let entry = req + .entry + .ok_or_else(|| Status::invalid_argument("entry is required"))?; + + // Capture values for the closure + let sid = session_id.clone(); + let mut already_exists = false; + + self.storage + .cas_index(&tenant_id, |index| { + if index.contains(&sid) { + already_exists = true; + } else { + already_exists = false; + index.upsert(crate::storage::SessionIndexEntry { + id: sid.clone(), + source_path: if entry.source_path.is_empty() { + None + } else { + Some(entry.source_path.clone()) + }, + auto_sync: true, + created_at: chrono::DateTime::from_timestamp(entry.created_at_unix, 0) + .unwrap_or_else(chrono::Utc::now), + last_modified_at: chrono::DateTime::from_timestamp( + entry.modified_at_unix, + 0, + ) + .unwrap_or_else(chrono::Utc::now), + docx_file: Some(format!("{}.docx", sid)), + wal_count: entry.wal_position, + cursor_position: entry.wal_position, + checkpoint_positions: entry.checkpoint_positions.clone(), + pending_external_change: entry.pending_external_change, + }); + } + }) + .await + .map_storage_err()?; + + Ok(Response::new(AddSessionToIndexResponse { + success: true, + already_exists, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn update_session_in_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id; + + let sid = session_id.clone(); + let mut not_found = false; + + // Clone req fields for the closure + let modified_at_unix = req.modified_at_unix; + let wal_position = req.wal_position; + let cursor_position = req.cursor_position; + let pending_external_change = req.pending_external_change; + let source_path = req.source_path.clone(); + let add_checkpoint_positions = req.add_checkpoint_positions.clone(); + let remove_checkpoint_positions = req.remove_checkpoint_positions.clone(); + + self.storage + .cas_index(&tenant_id, |index| { + if !index.contains(&sid) { + not_found = true; + return; + } + not_found = false; + let entry = index.get_mut(&sid).unwrap(); + + if let Some(modified_at) = modified_at_unix { + entry.last_modified_at = chrono::DateTime::from_timestamp(modified_at, 0) + .unwrap_or_else(chrono::Utc::now); + } + if let Some(wal_pos) = wal_position { + entry.wal_count = wal_pos; + if cursor_position.is_none() { + entry.cursor_position = wal_pos; + } + } + if let Some(cursor_pos) = cursor_position { + entry.cursor_position = cursor_pos; + } + if let Some(pending) = pending_external_change { + entry.pending_external_change = pending; + } + if let Some(ref sp) = source_path { + entry.source_path = if sp.is_empty() { None } else { Some(sp.clone()) }; + } + + for pos in &add_checkpoint_positions { + if !entry.checkpoint_positions.contains(pos) { + entry.checkpoint_positions.push(*pos); + } + } + + entry + .checkpoint_positions + .retain(|p| !remove_checkpoint_positions.contains(p)); + + entry.checkpoint_positions.sort(); + }) + .await + .map_storage_err()?; + + Ok(Response::new(UpdateSessionInIndexResponse { + success: !not_found, + not_found, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn remove_session_from_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id; + + let sid = session_id.clone(); + let mut existed = false; + + self.storage + .cas_index(&tenant_id, |index| { + existed = index.remove(&sid).is_some(); + }) + .await + .map_storage_err()?; + + Ok(Response::new(RemoveSessionFromIndexResponse { + success: true, + existed, + })) + } + + // ========================================================================= + // WAL Operations + // ========================================================================= + + #[instrument(skip(self, request), level = "debug", fields(entries_count = request.get_ref().entries.len()))] + async fn append_wal( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let entries: Vec = req + .entries + .into_iter() + .map(|e| crate::storage::WalEntry { + position: e.position, + operation: e.operation, + path: e.path, + patch_json: e.patch_json, + timestamp: chrono::DateTime::from_timestamp(e.timestamp_unix, 0) + .unwrap_or_else(chrono::Utc::now), + }) + .collect(); + + let new_position = self + .storage + .append_wal(tenant_id, &req.session_id, &entries) + .await + .map_storage_err()?; + + Ok(Response::new(AppendWalResponse { + success: true, + new_position, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn read_wal( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let limit = if req.limit > 0 { Some(req.limit) } else { None }; + + let (entries, has_more) = self + .storage + .read_wal(tenant_id, &req.session_id, req.from_position, limit) + .await + .map_storage_err()?; + + let entries = entries + .into_iter() + .map(|e| WalEntry { + position: e.position, + operation: e.operation, + path: e.path, + patch_json: e.patch_json, + timestamp_unix: e.timestamp.timestamp(), + }) + .collect(); + + Ok(Response::new(ReadWalResponse { entries, has_more })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn truncate_wal( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let entries_removed = self + .storage + .truncate_wal(tenant_id, &req.session_id, req.keep_from_position) + .await + .map_storage_err()?; + + Ok(Response::new(TruncateWalResponse { + success: true, + entries_removed, + })) + } + + // ========================================================================= + // Checkpoint Operations (Streaming) + // ========================================================================= + + #[instrument(skip(self, request), level = "debug")] + async fn save_checkpoint( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + + let mut tenant_id: Option = None; + let mut session_id: Option = None; + let mut position: u64 = 0; + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + + if tenant_id.is_none() { + tenant_id = chunk.context.map(|c| c.tenant_id); + session_id = Some(chunk.session_id); + position = chunk.position; + } + + data.extend(chunk.data); + + if chunk.is_last { + break; + } + } + + let tenant_id = tenant_id + .ok_or_else(|| Status::invalid_argument("tenant context is required in first chunk"))?; + let session_id = session_id + .filter(|s| !s.is_empty()) + .ok_or_else(|| Status::invalid_argument("session_id is required in first chunk"))?; + + debug!( + "Saving checkpoint at position {} for session {} tenant {} ({} bytes)", + position, + session_id, + tenant_id, + data.len() + ); + + self.storage + .save_checkpoint(&tenant_id, &session_id, position, &data) + .await + .map_storage_err()?; + + Ok(Response::new(SaveCheckpointResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn load_checkpoint( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id.clone(); + let position = req.position; + + let result = self + .storage + .load_checkpoint(&tenant_id, &session_id, position) + .await + .map_storage_err()?; + + let (tx, rx) = mpsc::channel(4); + let chunk_size = self.chunk_size; + + tokio::spawn(async move { + match result { + Some((data, actual_position)) => { + let total_size = data.len() as u64; + let chunks: Vec> = data.chunks(chunk_size).map(|c| c.to_vec()).collect(); + let total_chunks = chunks.len(); + + for (i, chunk) in chunks.into_iter().enumerate() { + let is_first = i == 0; + let is_last = i == total_chunks - 1; + + let msg = LoadCheckpointChunk { + data: chunk, + is_last, + found: is_first, + position: if is_first { actual_position } else { 0 }, + total_size: if is_first { total_size } else { 0 }, + }; + + if tx.send(Ok(msg)).await.is_err() { + break; + } + } + } + None => { + let _ = tx + .send(Ok(LoadCheckpointChunk { + data: vec![], + is_last: true, + found: false, + position: 0, + total_size: 0, + })) + .await; + } + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_checkpoints( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let checkpoints = self + .storage + .list_checkpoints(tenant_id, &req.session_id) + .await + .map_storage_err()?; + + let checkpoints = checkpoints + .into_iter() + .map(|c| CheckpointInfo { + position: c.position, + created_at_unix: c.created_at.timestamp(), + size_bytes: c.size_bytes as i64, + }) + .collect(); + + Ok(Response::new(ListCheckpointsResponse { checkpoints })) + } + + // ========================================================================= + // Health Check + // ========================================================================= + + #[instrument(skip(self), level = "debug")] + async fn health_check( + &self, + _request: Request, + ) -> Result, Status> { + debug!("Health check requested"); + Ok(Response::new(HealthCheckResponse { + healthy: true, + backend: self.storage.backend_name().to_string(), + version: self.version.clone(), + })) + } +} diff --git a/crates/docx-storage-cloudflare/src/storage/mod.rs b/crates/docx-storage-cloudflare/src/storage/mod.rs new file mode 100644 index 0000000..31a2f1a --- /dev/null +++ b/crates/docx-storage-cloudflare/src/storage/mod.rs @@ -0,0 +1,6 @@ +mod r2; + +pub use r2::R2Storage; + +// Re-export from core +pub use docx_storage_core::{SessionIndexEntry, StorageBackend, WalEntry}; diff --git a/crates/docx-storage-cloudflare/src/storage/r2.rs b/crates/docx-storage-cloudflare/src/storage/r2.rs new file mode 100644 index 0000000..f6221dd --- /dev/null +++ b/crates/docx-storage-cloudflare/src/storage/r2.rs @@ -0,0 +1,1036 @@ +use std::time::Duration; + +use async_trait::async_trait; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::Client as S3Client; +use docx_storage_core::{ + CheckpointInfo, SessionIndex, SessionInfo, StorageBackend, StorageError, WalEntry, +}; +use tracing::{debug, instrument, warn}; + +/// Maximum retries for transient errors (429 / 5xx). +const MAX_RETRIES: u32 = 5; +/// Base delay for exponential backoff. +const BASE_DELAY_MS: u64 = 200; +/// Maximum retries for CAS (compare-and-swap) loops. +const CAS_MAX_RETRIES: u32 = 10; + +/// R2 storage backend using Cloudflare R2 (S3-compatible) with ETag-based optimistic locking. +/// +/// Storage layout in R2: +/// ``` +/// {bucket}/ +/// {tenant_id}/ +/// index.json # Session index (was in KV, now in R2) +/// sessions/ +/// {session_id}.docx # Session document +/// {session_id}.wal # WAL file (JSONL format) +/// {session_id}.ckpt.{pos}.docx # Checkpoint files +/// ``` +#[derive(Clone)] +pub struct R2Storage { + s3_client: S3Client, + bucket_name: String, +} + +impl R2Storage { + /// Create a new R2Storage backend. + pub fn new(s3_client: S3Client, bucket_name: String) -> Self { + Self { + s3_client, + bucket_name, + } + } + + /// Get the S3 key for a session document. + fn session_key(&self, tenant_id: &str, session_id: &str) -> String { + format!("{}/sessions/{}.docx", tenant_id, session_id) + } + + /// Get the S3 key for a session WAL file. + fn wal_key(&self, tenant_id: &str, session_id: &str) -> String { + format!("{}/sessions/{}.wal", tenant_id, session_id) + } + + /// Get the S3 key for a checkpoint. + fn checkpoint_key(&self, tenant_id: &str, session_id: &str, position: u64) -> String { + format!("{}/sessions/{}.ckpt.{}.docx", tenant_id, session_id, position) + } + + /// Get the R2 key for a tenant's index. + fn index_key(&self, tenant_id: &str) -> String { + format!("{}/index.json", tenant_id) + } + + // ========================================================================= + // Retry helper + // ========================================================================= + + /// Sleep with exponential backoff + jitter. + async fn backoff_sleep(attempt: u32) { + let base = Duration::from_millis(BASE_DELAY_MS * 2u64.pow(attempt)); + let jitter = Duration::from_millis(rand_jitter()); + tokio::time::sleep(base + jitter).await; + } + + /// Check if an S3 error is retryable (429 or 5xx). + fn is_retryable_s3_error(err: &aws_sdk_s3::error::SdkError) -> bool { + use aws_sdk_s3::error::SdkError; + match err { + SdkError::ServiceError(e) => { + let raw = e.raw(); + let status = raw.status().as_u16(); + status == 429 || (500..=504).contains(&status) + } + SdkError::ResponseError(e) => { + let status = e.raw().status().as_u16(); + status == 429 || (500..=504).contains(&status) + } + SdkError::TimeoutError(_) | SdkError::DispatchFailure(_) => true, + _ => false, + } + } + + /// Check if an S3 error is a 412 Precondition Failed. + fn is_precondition_failed(err: &aws_sdk_s3::error::SdkError) -> bool { + use aws_sdk_s3::error::SdkError; + match err { + SdkError::ServiceError(e) => e.raw().status().as_u16() == 412, + SdkError::ResponseError(e) => e.raw().status().as_u16() == 412, + _ => false, + } + } + + // ========================================================================= + // R2 primitives with retry + // ========================================================================= + + /// Get an object from R2, with retry on transient errors. + async fn get_object(&self, key: &str) -> Result>, StorageError> { + for attempt in 0..=MAX_RETRIES { + let result = self + .s3_client + .get_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await; + + match result { + Ok(output) => { + let bytes = output + .body + .collect() + .await + .map_err(|e| { + StorageError::Io(format!("Failed to read R2 object body: {}", e)) + })? + .into_bytes(); + return Ok(Some(bytes.to_vec())); + } + Err(e) => { + if Self::is_retryable_s3_error(&e) && attempt < MAX_RETRIES { + warn!(attempt, key, "R2 get_object retryable error, retrying"); + Self::backoff_sleep(attempt).await; + continue; + } + let service_error = e.into_service_error(); + if service_error.is_no_such_key() { + return Ok(None); + } + return Err(StorageError::Io(format!( + "R2 get_object error: {}", + service_error + ))); + } + } + } + unreachable!() + } + + /// Get an object from R2 along with its ETag, with retry on transient errors. + /// Returns `None` if the object does not exist. + async fn get_object_with_etag( + &self, + key: &str, + ) -> Result, String)>, StorageError> { + for attempt in 0..=MAX_RETRIES { + let result = self + .s3_client + .get_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await; + + match result { + Ok(output) => { + let etag = output + .e_tag() + .unwrap_or("") + .to_string(); + let bytes = output + .body + .collect() + .await + .map_err(|e| { + StorageError::Io(format!("Failed to read R2 object body: {}", e)) + })? + .into_bytes(); + return Ok(Some((bytes.to_vec(), etag))); + } + Err(e) => { + if Self::is_retryable_s3_error(&e) && attempt < MAX_RETRIES { + warn!(attempt, key, "R2 get_object_with_etag retryable error, retrying"); + Self::backoff_sleep(attempt).await; + continue; + } + let service_error = e.into_service_error(); + if service_error.is_no_such_key() { + return Ok(None); + } + return Err(StorageError::Io(format!( + "R2 get_object_with_etag error: {}", + service_error + ))); + } + } + } + unreachable!() + } + + /// Put an object to R2, with retry on transient errors. + async fn put_object(&self, key: &str, data: &[u8]) -> Result<(), StorageError> { + for attempt in 0..=MAX_RETRIES { + let result = self + .s3_client + .put_object() + .bucket(&self.bucket_name) + .key(key) + .body(ByteStream::from(data.to_vec())) + .send() + .await; + + match result { + Ok(_) => return Ok(()), + Err(e) => { + if Self::is_retryable_s3_error(&e) && attempt < MAX_RETRIES { + warn!(attempt, key, "R2 put_object retryable error, retrying"); + Self::backoff_sleep(attempt).await; + continue; + } + return Err(StorageError::Io(format!("R2 put_object error: {}", e))); + } + } + } + unreachable!() + } + + /// Conditionally put an object using ETag. + /// + /// - If `expected_etag` is `Some(etag)`: uses `If-Match` (update existing). + /// - If `expected_etag` is `None`: uses `If-None-Match: *` (create new, fail if exists). + /// + /// Returns the new ETag on success, or `StorageError::Lock` on 412. + /// Retries on transient 429/5xx errors. + async fn put_object_conditional( + &self, + key: &str, + data: &[u8], + expected_etag: Option<&str>, + ) -> Result { + for attempt in 0..=MAX_RETRIES { + let mut req = self + .s3_client + .put_object() + .bucket(&self.bucket_name) + .key(key) + .body(ByteStream::from(data.to_vec())); + + if let Some(etag) = expected_etag { + req = req.if_match(etag); + } else { + req = req.if_none_match("*"); + } + + let result = req.send().await; + + match result { + Ok(output) => { + let new_etag = output + .e_tag() + .unwrap_or("") + .to_string(); + return Ok(new_etag); + } + Err(e) => { + if Self::is_precondition_failed(&e) { + return Err(StorageError::Lock( + "ETag mismatch: object was modified concurrently".to_string(), + )); + } + if Self::is_retryable_s3_error(&e) && attempt < MAX_RETRIES { + warn!( + attempt, + key, "R2 put_object_conditional retryable error, retrying" + ); + Self::backoff_sleep(attempt).await; + continue; + } + return Err(StorageError::Io(format!( + "R2 put_object_conditional error: {}", + e + ))); + } + } + } + unreachable!() + } + + /// Delete an object from R2, with retry on transient errors. + async fn delete_object(&self, key: &str) -> Result<(), StorageError> { + for attempt in 0..=MAX_RETRIES { + let result = self + .s3_client + .delete_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await; + + match result { + Ok(_) => return Ok(()), + Err(e) => { + if Self::is_retryable_s3_error(&e) && attempt < MAX_RETRIES { + warn!(attempt, key, "R2 delete_object retryable error, retrying"); + Self::backoff_sleep(attempt).await; + continue; + } + return Err(StorageError::Io(format!("R2 delete_object error: {}", e))); + } + } + } + unreachable!() + } + + /// List objects with a prefix, with retry on transient errors. + async fn list_objects(&self, prefix: &str) -> Result, StorageError> { + let mut keys = Vec::new(); + let mut continuation_token: Option = None; + + loop { + let mut request = self + .s3_client + .list_objects_v2() + .bucket(&self.bucket_name) + .prefix(prefix); + + if let Some(token) = continuation_token.take() { + request = request.continuation_token(token); + } + + let output = { + let mut last_err = None; + let mut result = None; + for attempt in 0..=MAX_RETRIES { + match request.clone().send().await { + Ok(o) => { + result = Some(o); + break; + } + Err(e) => { + if Self::is_retryable_s3_error(&e) && attempt < MAX_RETRIES { + warn!( + attempt, + prefix, "R2 list_objects retryable error, retrying" + ); + Self::backoff_sleep(attempt).await; + last_err = Some(e); + continue; + } + return Err(StorageError::Io(format!( + "R2 list_objects error: {}", + e + ))); + } + } + } + result.ok_or_else(|| { + StorageError::Io(format!( + "R2 list_objects exhausted retries: {:?}", + last_err + )) + })? + }; + + if let Some(contents) = output.contents { + for obj in contents { + if let Some(key) = obj.key { + keys.push(key); + } + } + } + + if output.is_truncated.unwrap_or(false) { + continuation_token = output.next_continuation_token; + } else { + break; + } + } + + Ok(keys) + } + + // ========================================================================= + // CAS (Compare-And-Swap) operations + // ========================================================================= + + /// Atomically read-modify-write the session index using ETag-based CAS. + /// + /// 1. GET index with ETag + /// 2. Apply `mutator` to the deserialized index + /// 3. PUT with If-Match (or If-None-Match: * for new) + /// 4. On 412, retry from step 1 (up to `CAS_MAX_RETRIES`) + pub async fn cas_index( + &self, + tenant_id: &str, + mut mutator: F, + ) -> Result + where + F: FnMut(&mut SessionIndex), + { + let key = self.index_key(tenant_id); + + for attempt in 0..CAS_MAX_RETRIES { + // Step 1: Read current index + ETag + let (mut index, etag) = match self.get_object_with_etag(&key).await? { + Some((data, etag)) => { + let index: SessionIndex = serde_json::from_slice(&data).map_err(|e| { + StorageError::Serialization(format!("Failed to parse index: {}", e)) + })?; + (index, Some(etag)) + } + None => (SessionIndex::default(), None), + }; + + // Step 2: Apply mutation + mutator(&mut index); + + // Step 3: Serialize and conditional write + let json = serde_json::to_vec(&index).map_err(|e| { + StorageError::Serialization(format!("Failed to serialize index: {}", e)) + })?; + + match self + .put_object_conditional(&key, &json, etag.as_deref()) + .await + { + Ok(_) => { + debug!( + attempt, + tenant_id, + sessions = index.sessions.len(), + "CAS index succeeded" + ); + return Ok(index); + } + Err(StorageError::Lock(_)) => { + // Step 4: ETag mismatch — retry with jitter + warn!( + attempt, + tenant_id, "CAS index conflict (412), retrying" + ); + Self::backoff_sleep(attempt).await; + continue; + } + Err(e) => return Err(e), + } + } + + Err(StorageError::Lock(format!( + "CAS index exhausted {} retries for tenant {}", + CAS_MAX_RETRIES, tenant_id + ))) + } + + /// Atomically append WAL entries using ETag-based CAS. + async fn cas_append_wal( + &self, + tenant_id: &str, + session_id: &str, + entries: &[WalEntry], + ) -> Result { + if entries.is_empty() { + return Ok(0); + } + + let key = self.wal_key(tenant_id, session_id); + + for attempt in 0..CAS_MAX_RETRIES { + // Read current WAL + ETag + let (mut wal_data, etag) = match self.get_object_with_etag(&key).await? { + Some((data, etag)) if data.len() >= 8 => { + let data_len = i64::from_le_bytes(data[..8].try_into().unwrap()) as usize; + let used_len = 8 + data_len; + let mut truncated = data; + truncated.truncate(used_len.min(truncated.len())); + (truncated, Some(etag)) + } + _ => { + // New file - start with 8-byte header (data_len = 0) + (vec![0u8; 8], None) + } + }; + + // Append new entries as JSONL + let mut last_position = 0u64; + for entry in entries { + wal_data.extend_from_slice(&entry.patch_json); + if !entry.patch_json.ends_with(b"\n") { + wal_data.push(b'\n'); + } + last_position = entry.position; + } + + // Update header with data length + let data_len = (wal_data.len() - 8) as i64; + wal_data[..8].copy_from_slice(&data_len.to_le_bytes()); + + // Conditional write + match self + .put_object_conditional(&key, &wal_data, etag.as_deref()) + .await + { + Ok(_) => { + debug!( + "Appended {} WAL entries, last position: {}", + entries.len(), + last_position + ); + return Ok(last_position); + } + Err(StorageError::Lock(_)) => { + warn!( + attempt, + session_id, "WAL append conflict (412), retrying" + ); + Self::backoff_sleep(attempt).await; + continue; + } + Err(e) => return Err(e), + } + } + + Err(StorageError::Lock(format!( + "WAL append exhausted {} retries for session {}", + CAS_MAX_RETRIES, session_id + ))) + } + + /// Atomically truncate WAL using ETag-based CAS. + async fn cas_truncate_wal( + &self, + tenant_id: &str, + session_id: &str, + keep_count: u64, + entries: Vec, + ) -> Result { + let (to_keep, to_remove): (Vec<_>, Vec<_>) = + entries.into_iter().partition(|e| e.position <= keep_count); + + let removed_count = to_remove.len() as u64; + if removed_count == 0 { + return Ok(0); + } + + let key = self.wal_key(tenant_id, session_id); + + for attempt in 0..CAS_MAX_RETRIES { + // Get current ETag + let etag = match self.get_object_with_etag(&key).await? { + Some((_, etag)) => Some(etag), + None => return Ok(0), + }; + + // Build new WAL with only kept entries + let mut wal_data = vec![0u8; 8]; // Header placeholder + for entry in &to_keep { + wal_data.extend_from_slice(&entry.patch_json); + if !entry.patch_json.ends_with(b"\n") { + wal_data.push(b'\n'); + } + } + + // Update header + let data_len = (wal_data.len() - 8) as i64; + wal_data[..8].copy_from_slice(&data_len.to_le_bytes()); + + match self + .put_object_conditional(&key, &wal_data, etag.as_deref()) + .await + { + Ok(_) => { + debug!( + "Truncated WAL, removed {} entries, kept {}", + removed_count, + to_keep.len() + ); + return Ok(removed_count); + } + Err(StorageError::Lock(_)) => { + warn!( + attempt, + session_id, "WAL truncate conflict (412), retrying" + ); + Self::backoff_sleep(attempt).await; + continue; + } + Err(e) => return Err(e), + } + } + + Err(StorageError::Lock(format!( + "WAL truncate exhausted {} retries for session {}", + CAS_MAX_RETRIES, session_id + ))) + } +} + +/// Simple jitter: random-ish value 0..50ms using timestamp nanos. +fn rand_jitter() -> u64 { + use std::time::SystemTime; + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.subsec_nanos() as u64 % 50) + .unwrap_or(0) +} + +#[async_trait] +impl StorageBackend for R2Storage { + fn backend_name(&self) -> &'static str { + "r2" + } + + // ========================================================================= + // Session Operations + // ========================================================================= + + #[instrument(skip(self), level = "debug")] + async fn load_session( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result>, StorageError> { + let key = self.session_key(tenant_id, session_id); + let result = self.get_object(&key).await?; + if result.is_some() { + debug!("Loaded session {} from R2", session_id); + } + Ok(result) + } + + #[instrument(skip(self, data), level = "debug", fields(data_len = data.len()))] + async fn save_session( + &self, + tenant_id: &str, + session_id: &str, + data: &[u8], + ) -> Result<(), StorageError> { + let key = self.session_key(tenant_id, session_id); + self.put_object(&key, data).await?; + debug!("Saved session {} to R2 ({} bytes)", session_id, data.len()); + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn delete_session( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result { + let session_key = self.session_key(tenant_id, session_id); + let wal_key = self.wal_key(tenant_id, session_id); + + // Check if session exists + let existed = self.get_object(&session_key).await?.is_some(); + + // Delete session file + if let Err(e) = self.delete_object(&session_key).await { + warn!("Failed to delete session file: {}", e); + } + + // Delete WAL + if let Err(e) = self.delete_object(&wal_key).await { + warn!("Failed to delete WAL file: {}", e); + } + + // Delete all checkpoints + let checkpoints = self.list_checkpoints(tenant_id, session_id).await?; + for ckpt in checkpoints { + let ckpt_key = self.checkpoint_key(tenant_id, session_id, ckpt.position); + if let Err(e) = self.delete_object(&ckpt_key).await { + warn!("Failed to delete checkpoint: {}", e); + } + } + + debug!("Deleted session {} (existed: {})", session_id, existed); + Ok(existed) + } + + #[instrument(skip(self), level = "debug")] + async fn list_sessions(&self, tenant_id: &str) -> Result, StorageError> { + let prefix = format!("{}/sessions/", tenant_id); + let keys = self.list_objects(&prefix).await?; + + let mut sessions = Vec::new(); + for key in keys { + // Only include .docx files that aren't checkpoints + if key.ends_with(".docx") && !key.contains(".ckpt.") { + let session_id = key + .strip_prefix(&prefix) + .and_then(|s| s.strip_suffix(".docx")) + .unwrap_or_default() + .to_string(); + + if !session_id.is_empty() { + // Get object metadata for size/timestamps + let head = self + .s3_client + .head_object() + .bucket(&self.bucket_name) + .key(&key) + .send() + .await; + + let (size_bytes, modified_at) = match head { + Ok(output) => { + let size = output.content_length.unwrap_or(0) as u64; + let modified = output + .last_modified + .and_then(|dt| { + chrono::DateTime::from_timestamp(dt.secs(), dt.subsec_nanos()) + }) + .unwrap_or_else(chrono::Utc::now); + (size, modified) + } + Err(_) => (0, chrono::Utc::now()), + }; + + sessions.push(SessionInfo { + session_id, + source_path: None, + created_at: modified_at, // R2 doesn't store creation time + modified_at, + size_bytes, + }); + } + } + } + + debug!( + "Listed {} sessions for tenant {}", + sessions.len(), + tenant_id + ); + Ok(sessions) + } + + #[instrument(skip(self), level = "debug")] + async fn session_exists( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result { + let key = self.session_key(tenant_id, session_id); + let result = self + .s3_client + .head_object() + .bucket(&self.bucket_name) + .key(&key) + .send() + .await; + + match result { + Ok(_) => Ok(true), + Err(e) => { + let service_error = e.into_service_error(); + if service_error.is_not_found() { + Ok(false) + } else { + Err(StorageError::Io(format!( + "R2 head_object error: {}", + service_error + ))) + } + } + } + } + + // ========================================================================= + // Index Operations (stored in R2 with ETag-based CAS) + // ========================================================================= + + #[instrument(skip(self), level = "debug")] + async fn load_index(&self, tenant_id: &str) -> Result, StorageError> { + let key = self.index_key(tenant_id); + match self.get_object(&key).await? { + Some(data) => { + let index: SessionIndex = serde_json::from_slice(&data).map_err(|e| { + StorageError::Serialization(format!("Failed to parse index: {}", e)) + })?; + debug!( + "Loaded index with {} sessions from R2", + index.sessions.len() + ); + Ok(Some(index)) + } + None => Ok(None), + } + } + + #[instrument(skip(self, index), level = "debug", fields(sessions = index.sessions.len()))] + async fn save_index( + &self, + tenant_id: &str, + index: &SessionIndex, + ) -> Result<(), StorageError> { + let key = self.index_key(tenant_id); + let json = serde_json::to_vec(index).map_err(|e| { + StorageError::Serialization(format!("Failed to serialize index: {}", e)) + })?; + self.put_object(&key, &json).await?; + debug!("Saved index with {} sessions to R2", index.sessions.len()); + Ok(()) + } + + // ========================================================================= + // WAL Operations (ETag-based CAS for atomic append/truncate) + // ========================================================================= + + #[instrument(skip(self, entries), level = "debug", fields(entries_count = entries.len()))] + async fn append_wal( + &self, + tenant_id: &str, + session_id: &str, + entries: &[WalEntry], + ) -> Result { + self.cas_append_wal(tenant_id, session_id, entries).await + } + + #[instrument(skip(self), level = "debug")] + async fn read_wal( + &self, + tenant_id: &str, + session_id: &str, + from_position: u64, + limit: Option, + ) -> Result<(Vec, bool), StorageError> { + let key = self.wal_key(tenant_id, session_id); + + let raw_data = match self.get_object(&key).await? { + Some(data) => data, + None => return Ok((vec![], false)), + }; + + if raw_data.len() < 8 { + return Ok((vec![], false)); + } + + // Parse header + let data_len = i64::from_le_bytes(raw_data[..8].try_into().unwrap()) as usize; + if data_len == 0 { + return Ok((vec![], false)); + } + + // Extract JSONL portion + let end = (8 + data_len).min(raw_data.len()); + let jsonl_data = &raw_data[8..end]; + + let content = std::str::from_utf8(jsonl_data).map_err(|e| { + StorageError::Io(format!("WAL is not valid UTF-8: {}", e)) + })?; + + // Parse JSONL - each line is a .NET WalEntry JSON + let mut entries = Vec::new(); + let limit = limit.unwrap_or(u64::MAX); + let mut position = 1u64; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if position >= from_position { + let value: serde_json::Value = serde_json::from_str(line).map_err(|e| { + StorageError::Serialization(format!( + "Failed to parse WAL entry at position {}: {}", + position, e + )) + })?; + + let timestamp = value + .get("timestamp") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(chrono::Utc::now); + + entries.push(WalEntry { + position, + operation: String::new(), + path: String::new(), + patch_json: line.as_bytes().to_vec(), + timestamp, + }); + + if entries.len() as u64 >= limit { + return Ok((entries, true)); + } + } + + position += 1; + } + + debug!( + "Read {} WAL entries from position {}", + entries.len(), + from_position + ); + Ok((entries, false)) + } + + #[instrument(skip(self), level = "debug")] + async fn truncate_wal( + &self, + tenant_id: &str, + session_id: &str, + keep_count: u64, + ) -> Result { + let (entries, _) = self.read_wal(tenant_id, session_id, 0, None).await?; + self.cas_truncate_wal(tenant_id, session_id, keep_count, entries) + .await + } + + // ========================================================================= + // Checkpoint Operations + // ========================================================================= + + #[instrument(skip(self, data), level = "debug", fields(data_len = data.len()))] + async fn save_checkpoint( + &self, + tenant_id: &str, + session_id: &str, + position: u64, + data: &[u8], + ) -> Result<(), StorageError> { + let key = self.checkpoint_key(tenant_id, session_id, position); + self.put_object(&key, data).await?; + debug!( + "Saved checkpoint at position {} ({} bytes)", + position, + data.len() + ); + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn load_checkpoint( + &self, + tenant_id: &str, + session_id: &str, + position: u64, + ) -> Result, u64)>, StorageError> { + if position == 0 { + // Load latest checkpoint + let checkpoints = self.list_checkpoints(tenant_id, session_id).await?; + if let Some(latest) = checkpoints.last() { + let key = self.checkpoint_key(tenant_id, session_id, latest.position); + if let Some(data) = self.get_object(&key).await? { + debug!( + "Loaded latest checkpoint at position {} ({} bytes)", + latest.position, + data.len() + ); + return Ok(Some((data, latest.position))); + } + } + return Ok(None); + } + + let key = self.checkpoint_key(tenant_id, session_id, position); + match self.get_object(&key).await? { + Some(data) => { + debug!( + "Loaded checkpoint at position {} ({} bytes)", + position, + data.len() + ); + Ok(Some((data, position))) + } + None => Ok(None), + } + } + + #[instrument(skip(self), level = "debug")] + async fn list_checkpoints( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let prefix = format!("{}/sessions/{}.ckpt.", tenant_id, session_id); + let keys = self.list_objects(&prefix).await?; + + let mut checkpoints = Vec::new(); + for key in keys { + if key.ends_with(".docx") { + // Extract position from key: {tenant}/sessions/{session}.ckpt.{position}.docx + let position_str = key + .strip_prefix(&prefix) + .and_then(|s| s.strip_suffix(".docx")) + .unwrap_or("0"); + + if let Ok(position) = position_str.parse::() { + // Get object metadata + let head = self + .s3_client + .head_object() + .bucket(&self.bucket_name) + .key(&key) + .send() + .await; + + let (size_bytes, created_at) = match head { + Ok(output) => { + let size = output.content_length.unwrap_or(0) as u64; + let created = output + .last_modified + .and_then(|dt| { + chrono::DateTime::from_timestamp(dt.secs(), dt.subsec_nanos()) + }) + .unwrap_or_else(chrono::Utc::now); + (size, created) + } + Err(_) => (0, chrono::Utc::now()), + }; + + checkpoints.push(CheckpointInfo { + position, + created_at, + size_bytes, + }); + } + } + } + + // Sort by position + checkpoints.sort_by_key(|c| c.position); + + debug!( + "Listed {} checkpoints for session {}", + checkpoints.len(), + session_id + ); + Ok(checkpoints) + } +} diff --git a/crates/docx-storage-core/Cargo.toml b/crates/docx-storage-core/Cargo.toml new file mode 100644 index 0000000..e630177 --- /dev/null +++ b/crates/docx-storage-core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "docx-storage-core" +description = "Core traits and types for docx-mcp storage backends" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +# Async +async-trait.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true +serde_bytes = "0.11" + +# Time +chrono.workspace = true + +# Error handling +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/docx-storage-core/src/browse.rs b/crates/docx-storage-core/src/browse.rs new file mode 100644 index 0000000..5830450 --- /dev/null +++ b/crates/docx-storage-core/src/browse.rs @@ -0,0 +1,69 @@ +use async_trait::async_trait; + +use crate::error::StorageError; +use crate::sync::SourceType; + +/// Information about an available storage connection. +#[derive(Debug, Clone)] +pub struct ConnectionInfo { + /// Connection ID (empty string for local) + pub connection_id: String, + /// Source type + pub source_type: SourceType, + /// Display name ("Local filesystem", "Mon Drive perso", etc.) + pub display_name: String, + /// Provider account identifier (email for GDrive, empty for local) + pub provider_account_id: Option, +} + +/// A file or folder entry from a connection. +#[derive(Debug, Clone)] +pub struct FileEntry { + /// File/folder name + pub name: String, + /// Human-readable path (local: absolute, cloud: display path) + pub path: String, + /// Provider-specific file ID (GDrive file ID, empty for local) + pub file_id: Option, + /// Whether this is a folder + pub is_folder: bool, + /// Size in bytes (0 for folders) + pub size_bytes: u64, + /// Last modified timestamp (Unix seconds) + pub modified_at: i64, + /// MIME type (if known) + pub mime_type: Option, +} + +/// Result of listing files with pagination. +#[derive(Debug, Clone)] +pub struct FileListResult { + pub files: Vec, + pub next_page_token: Option, +} + +/// Backend trait for browsing storage connections and their files. +#[async_trait] +pub trait BrowsableBackend: Send + Sync { + /// List available connections for a tenant. + async fn list_connections(&self, tenant_id: &str) -> Result, StorageError>; + + /// List files in a folder of a connection. + async fn list_files( + &self, + tenant_id: &str, + connection_id: &str, + path: &str, + page_token: Option<&str>, + page_size: u32, + ) -> Result; + + /// Download a file from a connection. + async fn download_file( + &self, + tenant_id: &str, + connection_id: &str, + path: &str, + file_id: Option<&str>, + ) -> Result, StorageError>; +} diff --git a/crates/docx-storage-core/src/error.rs b/crates/docx-storage-core/src/error.rs new file mode 100644 index 0000000..4adad89 --- /dev/null +++ b/crates/docx-storage-core/src/error.rs @@ -0,0 +1,29 @@ +use thiserror::Error; + +/// Errors that can occur in the storage layer. +#[derive(Error, Debug)] +pub enum StorageError { + #[error("I/O error: {0}")] + Io(String), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Lock error: {0}")] + Lock(String), + + #[error("Invalid argument: {0}")] + InvalidArgument(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error("Sync error: {0}")] + Sync(String), + + #[error("Watch error: {0}")] + Watch(String), +} diff --git a/crates/docx-storage-core/src/lib.rs b/crates/docx-storage-core/src/lib.rs new file mode 100644 index 0000000..c34ba68 --- /dev/null +++ b/crates/docx-storage-core/src/lib.rs @@ -0,0 +1,24 @@ +//! Core traits and types for docx-mcp storage backends. +//! +//! This crate defines the abstractions shared between local and cloud storage implementations: +//! - `StorageBackend`: Session, index, WAL, and checkpoint operations +//! - `SyncBackend`: Auto-save and source synchronization +//! - `WatchBackend`: External change detection +//! - `BrowsableBackend`: Connection browsing and file listing +//! - `LockManager`: Distributed locking for atomic operations + +mod browse; +mod error; +mod lock; +mod storage; +mod sync; +mod watch; + +pub use browse::{BrowsableBackend, ConnectionInfo, FileEntry, FileListResult}; +pub use error::StorageError; +pub use lock::{LockAcquireResult, LockManager}; +pub use storage::{ + CheckpointInfo, SessionIndex, SessionIndexEntry, SessionInfo, StorageBackend, WalEntry, +}; +pub use sync::{SourceDescriptor, SourceType, SyncBackend, SyncStatus}; +pub use watch::{ExternalChangeEvent, ExternalChangeType, SourceMetadata, WatchBackend}; diff --git a/crates/docx-storage-core/src/lock.rs b/crates/docx-storage-core/src/lock.rs new file mode 100644 index 0000000..327394a --- /dev/null +++ b/crates/docx-storage-core/src/lock.rs @@ -0,0 +1,63 @@ +use std::time::Duration; + +use async_trait::async_trait; + +use crate::error::StorageError; + +/// Result of a lock acquisition attempt. +#[derive(Debug, Clone)] +pub struct LockAcquireResult { + /// Whether the lock was acquired. + pub acquired: bool, +} + +impl LockAcquireResult { + /// Create a successful acquisition result. + pub fn acquired() -> Self { + Self { acquired: true } + } + + /// Create a failed acquisition result (lock held by another). + pub fn not_acquired() -> Self { + Self { acquired: false } + } +} + +/// Lock manager abstraction for tenant-aware distributed locking. +/// +/// Locks are on the pair `(tenant_id, resource_id)` to ensure tenant isolation. +/// The maximum number of concurrent locks = T tenants × F files per tenant. +/// +/// Note: This is used internally by atomic index operations. Locking is not +/// exposed to clients - the server handles it transparently. +#[async_trait] +pub trait LockManager: Send + Sync { + /// Attempt to acquire a lock on `(tenant_id, resource_id)`. + /// + /// # Arguments + /// * `tenant_id` - Tenant identifier for isolation + /// * `resource_id` - Resource to lock (e.g., session_id) + /// * `holder_id` - Unique identifier for this lock holder (UUID recommended) + /// * `ttl` - Time-to-live for the lock to prevent orphaned locks + /// + /// # Returns + /// * `Ok(result)` - Lock result with acquisition status + async fn acquire( + &self, + tenant_id: &str, + resource_id: &str, + holder_id: &str, + ttl: Duration, + ) -> Result; + + /// Release a lock. + /// + /// The lock is only released if `holder_id` matches the current holder. + /// Silently succeeds if the lock doesn't exist or is held by someone else. + async fn release( + &self, + tenant_id: &str, + resource_id: &str, + holder_id: &str, + ) -> Result<(), StorageError>; +} diff --git a/crates/docx-storage-core/src/storage.rs b/crates/docx-storage-core/src/storage.rs new file mode 100644 index 0000000..c49389f --- /dev/null +++ b/crates/docx-storage-core/src/storage.rs @@ -0,0 +1,250 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::StorageError; + +/// Information about a session stored in the backend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub session_id: String, + pub source_path: Option, + pub created_at: chrono::DateTime, + pub modified_at: chrono::DateTime, + pub size_bytes: u64, +} + +/// A single WAL entry representing an edit operation. +/// +/// The `patch_json` field contains the raw JSON bytes of the .NET WalEntry. +/// The Rust server doesn't parse this - it just stores and retrieves raw bytes. +/// The `position` field is assigned by the server when appending. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalEntry { + /// Position in WAL (1-indexed, assigned by server) + pub position: u64, + /// Operation type (for debugging/logging only) + #[serde(default)] + pub operation: String, + /// Target path (for debugging/logging only) + #[serde(default)] + pub path: String, + /// Raw JSON bytes of the .NET WalEntry - stored as-is on disk + #[serde(with = "serde_bytes")] + pub patch_json: Vec, + /// Timestamp + pub timestamp: chrono::DateTime, +} + +/// Information about a checkpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckpointInfo { + pub position: u64, + pub created_at: chrono::DateTime, + pub size_bytes: u64, +} + +/// The session index containing metadata about all sessions for a tenant. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SessionIndex { + /// Schema version + #[serde(default = "default_version")] + pub version: u32, + /// Array of session entries + #[serde(default)] + pub sessions: Vec, +} + +fn default_version() -> u32 { + 1 +} + +impl SessionIndex { + /// Get a session entry by ID. + #[allow(dead_code)] + pub fn get(&self, session_id: &str) -> Option<&SessionIndexEntry> { + self.sessions.iter().find(|s| s.id == session_id) + } + + /// Get a mutable session entry by ID. + pub fn get_mut(&mut self, session_id: &str) -> Option<&mut SessionIndexEntry> { + self.sessions.iter_mut().find(|s| s.id == session_id) + } + + /// Insert or update a session entry. + pub fn upsert(&mut self, entry: SessionIndexEntry) { + if let Some(existing) = self.get_mut(&entry.id) { + *existing = entry; + } else { + self.sessions.push(entry); + } + } + + /// Remove a session entry by ID. + pub fn remove(&mut self, session_id: &str) -> Option { + if let Some(pos) = self.sessions.iter().position(|s| s.id == session_id) { + Some(self.sessions.remove(pos)) + } else { + None + } + } + + /// Check if a session exists. + pub fn contains(&self, session_id: &str) -> bool { + self.sessions.iter().any(|s| s.id == session_id) + } +} + +/// A single session entry in the index. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionIndexEntry { + /// Session ID + pub id: String, + /// Original source file path + pub source_path: Option, + /// Auto-sync enabled for this session + #[serde(default = "default_auto_sync")] + pub auto_sync: bool, + /// When the session was created + pub created_at: chrono::DateTime, + /// When the session was last modified + #[serde(alias = "modified_at")] + pub last_modified_at: chrono::DateTime, + /// The DOCX filename (e.g., "abc123.docx") + #[serde(default)] + pub docx_file: Option, + /// WAL entry count + #[serde(alias = "wal_position", default)] + pub wal_count: u64, + /// Current cursor position in WAL + #[serde(default)] + pub cursor_position: u64, + /// Checkpoint positions + #[serde(default)] + pub checkpoint_positions: Vec, + /// Whether there is a pending external change for this session + #[serde(default)] + pub pending_external_change: bool, +} + +fn default_auto_sync() -> bool { + true +} + +/// Storage backend abstraction for tenant-aware document storage. +/// +/// All methods take `tenant_id` as the first parameter to ensure isolation. +/// Implementations must organize data by tenant (e.g., `{base}/{tenant_id}/`). +#[async_trait] +pub trait StorageBackend: Send + Sync { + /// Returns the backend identifier (e.g., "local", "r2"). + fn backend_name(&self) -> &'static str; + + // ========================================================================= + // Session Operations + // ========================================================================= + + /// Load a session's DOCX bytes. + async fn load_session( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result>, StorageError>; + + /// Save a session's DOCX bytes. + async fn save_session( + &self, + tenant_id: &str, + session_id: &str, + data: &[u8], + ) -> Result<(), StorageError>; + + /// Delete a session and all associated data (WAL, checkpoints). + async fn delete_session( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result; + + /// List all sessions for a tenant. + async fn list_sessions(&self, tenant_id: &str) -> Result, StorageError>; + + /// Check if a session exists. + async fn session_exists( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result; + + // ========================================================================= + // Index Operations + // ========================================================================= + + /// Load the session index for a tenant. + async fn load_index(&self, tenant_id: &str) -> Result, StorageError>; + + /// Save the session index for a tenant. + async fn save_index( + &self, + tenant_id: &str, + index: &SessionIndex, + ) -> Result<(), StorageError>; + + // ========================================================================= + // WAL Operations + // ========================================================================= + + /// Append entries to a session's WAL. + async fn append_wal( + &self, + tenant_id: &str, + session_id: &str, + entries: &[WalEntry], + ) -> Result; + + /// Read WAL entries starting from a position. + async fn read_wal( + &self, + tenant_id: &str, + session_id: &str, + from_position: u64, + limit: Option, + ) -> Result<(Vec, bool), StorageError>; + + /// Truncate WAL, keeping only the first N entries. + /// - keep_count = 0: delete all entries + /// - keep_count = N: keep entries with position <= N + async fn truncate_wal( + &self, + tenant_id: &str, + session_id: &str, + keep_count: u64, + ) -> Result; + + // ========================================================================= + // Checkpoint Operations + // ========================================================================= + + /// Save a checkpoint at a specific WAL position. + async fn save_checkpoint( + &self, + tenant_id: &str, + session_id: &str, + position: u64, + data: &[u8], + ) -> Result<(), StorageError>; + + /// Load a checkpoint. If position is 0, load the latest. + async fn load_checkpoint( + &self, + tenant_id: &str, + session_id: &str, + position: u64, + ) -> Result, u64)>, StorageError>; + + /// List all checkpoints for a session. + async fn list_checkpoints( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError>; +} diff --git a/crates/docx-storage-core/src/sync.rs b/crates/docx-storage-core/src/sync.rs new file mode 100644 index 0000000..d3f6c3a --- /dev/null +++ b/crates/docx-storage-core/src/sync.rs @@ -0,0 +1,150 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::StorageError; + +/// Source types supported by the sync service. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceType { + LocalFile, + SharePoint, + OneDrive, + S3, + R2, + GoogleDrive, +} + +impl Default for SourceType { + fn default() -> Self { + Self::LocalFile + } +} + +/// Typed descriptor for an external source. +/// +/// Resolution rule: for API operations, use `file_id` if non-empty, else `path`. +/// For display, always use `path`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceDescriptor { + /// Type of the source + #[serde(rename = "type")] + pub source_type: SourceType, + /// OAuth connection ID (empty for local) + #[serde(default)] + pub connection_id: Option, + /// Human-readable path (local: absolute path, cloud: display path in drive) + pub path: String, + /// Provider-specific file identifier (GDrive file ID, OneDrive item ID). + /// Empty for local (path is the identifier). + #[serde(default)] + pub file_id: Option, +} + +impl SourceDescriptor { + /// Returns the identifier to use for API operations. + /// `file_id` if present and non-empty, otherwise `path`. + pub fn effective_id(&self) -> &str { + self.file_id + .as_deref() + .filter(|id| !id.is_empty()) + .unwrap_or(&self.path) + } +} + +/// Status of sync for a session. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncStatus { + /// Session ID + pub session_id: String, + /// Source descriptor + pub source: SourceDescriptor, + /// Whether auto-sync is enabled + pub auto_sync_enabled: bool, + /// Unix timestamp of last successful sync + pub last_synced_at: Option, + /// Whether there are pending changes not yet synced + pub has_pending_changes: bool, + /// Last error message, if any + pub last_error: Option, +} + +/// Sync backend abstraction for syncing session changes to external sources. +/// +/// This handles the auto-save functionality for various source types: +/// - Local files (current behavior) +/// - SharePoint documents +/// - OneDrive files +/// - Google Drive files +#[async_trait] +pub trait SyncBackend: Send + Sync { + /// Register a session's source for sync tracking. + /// + /// # Arguments + /// * `tenant_id` - Tenant identifier + /// * `session_id` - Session identifier + /// * `source` - Source descriptor + /// * `auto_sync` - Whether to enable auto-sync on WAL append + async fn register_source( + &self, + tenant_id: &str, + session_id: &str, + source: SourceDescriptor, + auto_sync: bool, + ) -> Result<(), StorageError>; + + /// Unregister a source (on session close). + async fn unregister_source( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result<(), StorageError>; + + /// Update source configuration (change target file, toggle auto-sync). + /// + /// # Arguments + /// * `tenant_id` - Tenant identifier + /// * `session_id` - Session identifier + /// * `source` - New source descriptor (None to keep existing) + /// * `auto_sync` - New auto-sync setting (None to keep existing) + async fn update_source( + &self, + tenant_id: &str, + session_id: &str, + source: Option, + auto_sync: Option, + ) -> Result<(), StorageError>; + + /// Sync current document data to the external source. + /// + /// # Arguments + /// * `tenant_id` - Tenant identifier + /// * `session_id` - Session identifier + /// * `data` - DOCX bytes to sync + /// + /// # Returns + /// Unix timestamp of successful sync + async fn sync_to_source( + &self, + tenant_id: &str, + session_id: &str, + data: &[u8], + ) -> Result; + + /// Get sync status for a session. + async fn get_sync_status( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError>; + + /// List all registered sources for a tenant. + async fn list_sources(&self, tenant_id: &str) -> Result, StorageError>; + + /// Check if auto-sync is enabled for a session. + async fn is_auto_sync_enabled( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result; +} diff --git a/crates/docx-storage-core/src/watch.rs b/crates/docx-storage-core/src/watch.rs new file mode 100644 index 0000000..b98d46c --- /dev/null +++ b/crates/docx-storage-core/src/watch.rs @@ -0,0 +1,111 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::StorageError; +use crate::sync::SourceDescriptor; + +/// Types of external changes that can be detected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalChangeType { + Modified, + Deleted, + Renamed, + PermissionChanged, +} + +/// Metadata about a source file for comparison. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceMetadata { + /// File size in bytes + pub size_bytes: u64, + /// Last modification time (Unix timestamp) + pub modified_at: i64, + /// ETag for HTTP-based sources + pub etag: Option, + /// Version ID for versioned sources (S3, SharePoint) + pub version_id: Option, + /// SHA-256 content hash (if available) + pub content_hash: Option>, +} + +/// Event representing an external change to a source. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalChangeEvent { + /// Session ID affected + pub session_id: String, + /// Type of change + pub change_type: ExternalChangeType, + /// Previous metadata (if known) + pub old_metadata: Option, + /// New metadata + pub new_metadata: Option, + /// Unix timestamp when change was detected + pub detected_at: i64, + /// New URI for rename events + pub new_uri: Option, +} + +/// Watch backend abstraction for monitoring external sources for changes. +/// +/// This is used to detect when external sources are modified outside of docx-mcp, +/// enabling conflict detection and re-sync notifications. +/// +/// Different implementations support different mechanisms: +/// - Local files: `notify` crate for filesystem events +/// - R2/S3: Polling-based change detection +/// - SharePoint/OneDrive: Webhooks or polling +#[async_trait] +pub trait WatchBackend: Send + Sync { + /// Start watching a source for external changes. + /// + /// # Arguments + /// * `tenant_id` - Tenant identifier + /// * `session_id` - Session identifier + /// * `source` - Source descriptor + /// * `poll_interval_secs` - Polling interval for backends that don't support push (0 = default) + /// + /// # Returns + /// Unique watch ID for this session + async fn start_watch( + &self, + tenant_id: &str, + session_id: &str, + source: &SourceDescriptor, + poll_interval_secs: u32, + ) -> Result; + + /// Stop watching a source. + async fn stop_watch(&self, tenant_id: &str, session_id: &str) -> Result<(), StorageError>; + + /// Poll for changes (for backends that don't support push notifications). + /// + /// Returns `Some(event)` if a change was detected, `None` otherwise. + async fn check_for_changes( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError>; + + /// Get current source metadata (for comparison). + async fn get_source_metadata( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError>; + + /// Get known (cached) metadata for a session. + async fn get_known_metadata( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError>; + + /// Update known metadata after a successful sync. + async fn update_known_metadata( + &self, + tenant_id: &str, + session_id: &str, + metadata: SourceMetadata, + ) -> Result<(), StorageError>; +} diff --git a/crates/docx-storage-gdrive/Cargo.toml b/crates/docx-storage-gdrive/Cargo.toml new file mode 100644 index 0000000..7a181a8 --- /dev/null +++ b/crates/docx-storage-gdrive/Cargo.toml @@ -0,0 +1,68 @@ +[package] +name = "docx-storage-gdrive" +description = "Google Drive sync/watch backend for docx-mcp multi-tenant architecture" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +# Core traits +docx-storage-core = { path = "../docx-storage-core" } + +# gRPC +tonic.workspace = true +tonic-reflection = "0.13" +prost.workspace = true +prost-types.workspace = true +tokio.workspace = true +tokio-stream.workspace = true + +# HTTP client (Google Drive API + D1 REST API + OAuth2 token refresh) +reqwest.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Async utilities +async-trait.workspace = true +async-stream = "0.3" + +# Time +chrono.workspace = true + +# UUID (watch IDs) +uuid = { version = "1", features = ["v4"] } + +# CLI +clap.workspace = true + +# Concurrent data structures +dashmap = "6" + +# Crypto (MD5 checksum hex decoding) +hex.workspace = true + +[build-dependencies] +tonic-build = "0.13" + +[dev-dependencies] +tempfile.workspace = true +tokio-test = "0.4" +wiremock = "0.6" + +[[bin]] +name = "docx-storage-gdrive" +path = "src/main.rs" + +[lints] +workspace = true diff --git a/crates/docx-storage-gdrive/Dockerfile b/crates/docx-storage-gdrive/Dockerfile new file mode 100644 index 0000000..ea63669 --- /dev/null +++ b/crates/docx-storage-gdrive/Dockerfile @@ -0,0 +1,62 @@ +# ============================================================================= +# docx-storage-gdrive Dockerfile +# Multi-stage build for the Google Drive sync/watch gRPC server +# ============================================================================= + +# Stage 1: Build +FROM rust:1.93-slim-bookworm AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + protobuf-compiler \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +# Build the gdrive server +RUN cargo build --release --package docx-storage-gdrive + +# Stage 2: Runtime +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies (ca-certificates for Google API TLS) +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 docx +USER docx + +WORKDIR /app + +# Copy the binary from builder +COPY --from=builder /build/target/release/docx-storage-gdrive /app/docx-storage-gdrive + +# Environment defaults +ENV RUST_LOG=info +ENV GRPC_HOST=0.0.0.0 +ENV GRPC_PORT=50052 + +# Required environment variables (must be set at runtime): +# GOOGLE_CREDENTIALS_JSON (service account key JSON string or file path) + +# Optional: +# WATCH_POLL_INTERVAL (default: 60 seconds) + +# Expose gRPC port +EXPOSE 50052 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["timeout", "5", "sh", "-c", "echo > /dev/tcp/localhost/50052"] || exit 1 + +# Run the server +ENTRYPOINT ["/app/docx-storage-gdrive"] diff --git a/crates/docx-storage-gdrive/build.rs b/crates/docx-storage-gdrive/build.rs new file mode 100644 index 0000000..3f0b33f --- /dev/null +++ b/crates/docx-storage-gdrive/build.rs @@ -0,0 +1,11 @@ +fn main() -> Result<(), Box> { + // Compile the protobuf definitions + tonic_build::configure() + .build_server(true) + .build_client(false) + .file_descriptor_set_path( + std::path::PathBuf::from(std::env::var("OUT_DIR")?).join("storage_descriptor.bin"), + ) + .compile_protos(&["../../proto/storage.proto"], &["../../proto"])?; + Ok(()) +} diff --git a/crates/docx-storage-gdrive/src/browse.rs b/crates/docx-storage-gdrive/src/browse.rs new file mode 100644 index 0000000..9e7cbe4 --- /dev/null +++ b/crates/docx-storage-gdrive/src/browse.rs @@ -0,0 +1,168 @@ +//! Google Drive BrowsableBackend implementation (multi-tenant). +//! +//! Lists connections from D1, browses files via Drive API, downloads files. + +use std::sync::Arc; + +use async_trait::async_trait; +use docx_storage_core::{ + BrowsableBackend, ConnectionInfo, FileEntry, FileListResult, SourceType, StorageError, +}; +use tracing::{debug, instrument}; + +use crate::d1_client::D1Client; +use crate::gdrive::GDriveClient; +use crate::token_manager::TokenManager; + +/// Google Drive browsable backend (multi-tenant, token per-connection). +pub struct GDriveBrowsableBackend { + d1: Arc, + client: Arc, + token_manager: Arc, +} + +impl GDriveBrowsableBackend { + pub fn new( + d1: Arc, + client: Arc, + token_manager: Arc, + ) -> Self { + Self { + d1, + client, + token_manager, + } + } +} + +#[async_trait] +impl BrowsableBackend for GDriveBrowsableBackend { + #[instrument(skip(self), level = "debug")] + async fn list_connections( + &self, + tenant_id: &str, + ) -> Result, StorageError> { + let connections = self + .d1 + .list_connections(tenant_id, "google_drive") + .await + .map_err(|e| StorageError::Sync(format!("D1 error listing connections: {}", e)))?; + + let result = connections + .into_iter() + .map(|c| ConnectionInfo { + connection_id: c.id, + source_type: SourceType::GoogleDrive, + display_name: c.display_name, + provider_account_id: c.provider_account_id, + }) + .collect::>(); + + debug!( + "Listed {} Google Drive connections for tenant {}", + result.len(), + tenant_id + ); + + Ok(result) + } + + #[instrument(skip(self), level = "debug")] + async fn list_files( + &self, + tenant_id: &str, + connection_id: &str, + path: &str, + page_token: Option<&str>, + page_size: u32, + ) -> Result { + let token = self + .token_manager + .get_valid_token(tenant_id, connection_id) + .await + .map_err(|e| StorageError::Sync(format!("Token error: {}", e)))?; + + // Use "root" as parent ID when path is empty (Drive root) + let parent_id = if path.is_empty() { "root" } else { path }; + + let (entries, next_page_token) = self + .client + .list_files(&token, parent_id, page_token, page_size) + .await + .map_err(|e| StorageError::Sync(format!("Google Drive list error: {}", e)))?; + + let files = entries + .into_iter() + .map(|e| { + let is_folder = e.mime_type == "application/vnd.google-apps.folder"; + let size_bytes = e + .size + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let modified_at = e + .modified_time + .as_ref() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + + FileEntry { + name: e.name, + path: e.id.clone(), // For Google Drive, path = file ID (used for navigation) + file_id: Some(e.id), + is_folder, + size_bytes, + modified_at, + mime_type: Some(e.mime_type), + } + }) + .collect(); + + Ok(FileListResult { + files, + next_page_token, + }) + } + + #[instrument(skip(self), level = "debug")] + async fn download_file( + &self, + tenant_id: &str, + connection_id: &str, + _path: &str, + file_id: Option<&str>, + ) -> Result, StorageError> { + let token = self + .token_manager + .get_valid_token(tenant_id, connection_id) + .await + .map_err(|e| StorageError::Sync(format!("Token error: {}", e)))?; + + // For Google Drive, file_id is the primary identifier + let effective_id = file_id.ok_or_else(|| { + StorageError::Sync("file_id is required for Google Drive downloads".to_string()) + })?; + + let data = self + .client + .download_file(&token, effective_id) + .await + .map_err(|e| StorageError::Sync(format!("Google Drive download error: {}", e)))?; + + match data { + Some(bytes) => { + debug!( + "Downloaded {} bytes from Google Drive file {}", + bytes.len(), + effective_id + ); + Ok(bytes) + } + None => Err(StorageError::NotFound(format!( + "Google Drive file not found: {}", + effective_id + ))), + } + } +} diff --git a/crates/docx-storage-gdrive/src/config.rs b/crates/docx-storage-gdrive/src/config.rs new file mode 100644 index 0000000..a3db5c1 --- /dev/null +++ b/crates/docx-storage-gdrive/src/config.rs @@ -0,0 +1,39 @@ +use clap::Parser; + +/// Configuration for the docx-storage-gdrive server. +#[derive(Parser, Debug, Clone)] +#[command(name = "docx-storage-gdrive")] +#[command(about = "Google Drive sync/watch gRPC server for docx-mcp (multi-tenant, tokens from D1)")] +pub struct Config { + /// TCP host to bind to + #[arg(long, default_value = "0.0.0.0", env = "GRPC_HOST")] + pub host: String, + + /// TCP port to bind to + #[arg(long, default_value = "50052", env = "GRPC_PORT")] + pub port: u16, + + /// Cloudflare Account ID (for D1 API access) + #[arg(long, env = "CLOUDFLARE_ACCOUNT_ID")] + pub cloudflare_account_id: String, + + /// Cloudflare API Token (for D1 API access) + #[arg(long, env = "CLOUDFLARE_API_TOKEN")] + pub cloudflare_api_token: String, + + /// D1 Database ID (stores oauth_connection table) + #[arg(long, env = "D1_DATABASE_ID")] + pub d1_database_id: String, + + /// Google OAuth2 Client ID (for token refresh) + #[arg(long, env = "GOOGLE_CLIENT_ID")] + pub google_client_id: String, + + /// Google OAuth2 Client Secret (for token refresh) + #[arg(long, env = "GOOGLE_CLIENT_SECRET")] + pub google_client_secret: String, + + /// Polling interval for external watch (seconds) + #[arg(long, default_value = "60", env = "WATCH_POLL_INTERVAL")] + pub watch_poll_interval_secs: u32, +} diff --git a/crates/docx-storage-gdrive/src/d1_client.rs b/crates/docx-storage-gdrive/src/d1_client.rs new file mode 100644 index 0000000..9e2a164 --- /dev/null +++ b/crates/docx-storage-gdrive/src/d1_client.rs @@ -0,0 +1,201 @@ +//! D1 client for reading OAuth connections via Cloudflare REST API. +//! +//! Mirrors the pattern from `docx-mcp-sse-proxy/src/auth.rs`. + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +/// An OAuth connection record from D1. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct OAuthConnection { + pub id: String, + #[serde(rename = "tenantId")] + pub tenant_id: String, + pub provider: String, + #[serde(rename = "displayName")] + pub display_name: String, + #[serde(rename = "providerAccountId")] + pub provider_account_id: Option, + #[serde(rename = "accessToken")] + pub access_token: String, + #[serde(rename = "refreshToken")] + pub refresh_token: String, + #[serde(rename = "tokenExpiresAt")] + pub token_expires_at: Option, + pub scopes: String, +} + +/// D1 query request body. +#[derive(Serialize)] +struct D1QueryRequest { + sql: String, + params: Vec, +} + +/// D1 API response structure. +#[derive(Deserialize)] +struct D1Response { + success: bool, + result: Option>, + errors: Option>, +} + +#[derive(Deserialize)] +struct D1QueryResult { + results: Vec, +} + +#[derive(Deserialize)] +struct D1Error { + message: String, +} + +/// Client for querying D1 oauth_connection table via Cloudflare REST API. +pub struct D1Client { + http: Client, + account_id: String, + api_token: String, + database_id: String, +} + +impl D1Client { + pub fn new(account_id: String, api_token: String, database_id: String) -> Self { + Self { + http: Client::new(), + account_id, + api_token, + database_id, + } + } + + fn query_url(&self) -> String { + format!( + "https://api.cloudflare.com/client/v4/accounts/{}/d1/database/{}/query", + self.account_id, self.database_id + ) + } + + /// Execute a D1 query and return raw results. + async fn execute_query( + &self, + sql: &str, + params: Vec, + ) -> anyhow::Result> { + let query = D1QueryRequest { + sql: sql.to_string(), + params, + }; + + let response = self + .http + .post(&self.query_url()) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Content-Type", "application/json") + .json(&query) + .send() + .await?; + + let status = response.status(); + let body = response.text().await?; + + if !status.is_success() { + anyhow::bail!("D1 API returned {}: {}", status, body); + } + + let d1_response: D1Response = serde_json::from_str(&body)?; + + if !d1_response.success { + let error_msg = d1_response + .errors + .map(|errs| { + errs.into_iter() + .map(|e| e.message) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "Unknown D1 error".to_string()); + anyhow::bail!("D1 query failed: {}", error_msg); + } + + Ok(d1_response + .result + .and_then(|mut r| r.pop()) + .map(|qr| qr.results) + .unwrap_or_default()) + } + + /// Get an OAuth connection by ID, scoped to the given tenant. + pub async fn get_connection( + &self, + tenant_id: &str, + connection_id: &str, + ) -> anyhow::Result> { + let results = self + .execute_query( + "SELECT id, tenantId, provider, displayName, providerAccountId, \ + accessToken, refreshToken, tokenExpiresAt, scopes \ + FROM oauth_connection WHERE id = ?1 AND tenantId = ?2", + vec![connection_id.to_string(), tenant_id.to_string()], + ) + .await?; + + match results.into_iter().next() { + Some(row) => Ok(Some(serde_json::from_value(row)?)), + None => Ok(None), + } + } + + /// List connections for a tenant and provider. + pub async fn list_connections( + &self, + tenant_id: &str, + provider: &str, + ) -> anyhow::Result> { + let results = self + .execute_query( + "SELECT id, tenantId, provider, displayName, providerAccountId, \ + accessToken, refreshToken, tokenExpiresAt, scopes \ + FROM oauth_connection WHERE tenantId = ?1 AND provider = ?2", + vec![tenant_id.to_string(), provider.to_string()], + ) + .await?; + + let mut connections = Vec::new(); + for row in results { + match serde_json::from_value(row) { + Ok(conn) => connections.push(conn), + Err(e) => warn!("Failed to parse OAuth connection: {}", e), + } + } + + Ok(connections) + } + + /// Update tokens after a refresh. + pub async fn update_tokens( + &self, + connection_id: &str, + access_token: &str, + refresh_token: &str, + expires_at: &str, + ) -> anyhow::Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + self.execute_query( + "UPDATE oauth_connection \ + SET accessToken = ?1, refreshToken = ?2, tokenExpiresAt = ?3, updatedAt = ?4 \ + WHERE id = ?5", + vec![ + access_token.to_string(), + refresh_token.to_string(), + expires_at.to_string(), + now, + connection_id.to_string(), + ], + ) + .await?; + + Ok(()) + } +} diff --git a/crates/docx-storage-gdrive/src/gdrive.rs b/crates/docx-storage-gdrive/src/gdrive.rs new file mode 100644 index 0000000..5666757 --- /dev/null +++ b/crates/docx-storage-gdrive/src/gdrive.rs @@ -0,0 +1,276 @@ +//! Google Drive API v3 client wrapper. +//! +//! Token is passed per-call by the caller (TokenManager resolves it from D1). + +use reqwest::Client; +use serde::Deserialize; +use tracing::{debug, instrument}; + +/// Metadata returned by Google Drive API. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileMetadata { + #[allow(dead_code)] + pub id: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub modified_time: Option, + #[serde(default)] + pub md5_checksum: Option, + #[serde(default)] + pub head_revision_id: Option, +} + +/// A file entry from Drive API files.list. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DriveFileEntry { + pub id: String, + pub name: String, + pub mime_type: String, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub modified_time: Option, +} + +/// Response from Drive API files.list. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileListResponse { + #[serde(default)] + files: Vec, + #[serde(default)] + next_page_token: Option, +} + +/// Google Drive API client (stateless — token provided per-call). +pub struct GDriveClient { + http: Client, +} + +impl GDriveClient { + pub fn new() -> Self { + Self { + http: Client::new(), + } + } + + /// Get file metadata from Google Drive. + #[instrument(skip(self, token), level = "debug")] + pub async fn get_metadata( + &self, + token: &str, + file_id: &str, + ) -> anyhow::Result> { + let url = format!( + "https://www.googleapis.com/drive/v3/files/{}?fields=id,size,modifiedTime,md5Checksum,headRevisionId", + file_id + ); + + let resp = self.http.get(&url).bearer_auth(token).send().await?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Google Drive API error {}: {}", status, body); + } + + let metadata: FileMetadata = resp.json().await?; + debug!("Got metadata for file {}: {:?}", file_id, metadata); + Ok(Some(metadata)) + } + + /// Download file content from Google Drive. + #[instrument(skip(self, token), level = "debug")] + pub async fn download_file( + &self, + token: &str, + file_id: &str, + ) -> anyhow::Result>> { + let url = format!( + "https://www.googleapis.com/drive/v3/files/{}?alt=media", + file_id + ); + + let resp = self.http.get(&url).bearer_auth(token).send().await?; + + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Google Drive download error {}: {}", status, body); + } + + let bytes = resp.bytes().await?; + debug!("Downloaded {} bytes for file {}", bytes.len(), file_id); + Ok(Some(bytes.to_vec())) + } + + /// Upload (update) file content on Google Drive. + #[instrument(skip(self, token, data), level = "debug", fields(data_len = data.len()))] + pub async fn update_file( + &self, + token: &str, + file_id: &str, + data: &[u8], + ) -> anyhow::Result<()> { + let url = format!( + "https://www.googleapis.com/upload/drive/v3/files/{}?uploadType=media", + file_id + ); + + let resp = self + .http + .patch(&url) + .bearer_auth(token) + .header( + "Content-Type", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + .body(data.to_vec()) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Google Drive upload error {}: {}", status, body); + } + + debug!("Updated file {} ({} bytes)", file_id, data.len()); + Ok(()) + } + + /// Create a new file on Google Drive. + /// Returns the new file's ID. + #[instrument(skip(self, token, data), level = "debug", fields(data_len = data.len()))] + pub async fn create_file( + &self, + token: &str, + name: &str, + parent_id: Option<&str>, + data: &[u8], + ) -> anyhow::Result { + // Build multipart/related body manually: + // Google Drive v3 uploadType=multipart expects a multipart/related body + // with a JSON metadata part and a file content part. + let boundary = "docx_mcp_boundary"; + let mime_type = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + let parents_json = match parent_id { + Some(pid) => format!(r#","parents":["{}"]"#, pid), + None => String::new(), + }; + + let metadata = format!( + r#"{{"name":"{}","mimeType":"{}"{}}}"#, + name, mime_type, parents_json + ); + + let mut body = Vec::new(); + // Metadata part + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.extend_from_slice(metadata.as_bytes()); + body.extend_from_slice(b"\r\n"); + // File content part + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice(format!("Content-Type: {}\r\n\r\n", mime_type).as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(b"\r\n"); + // Closing boundary + body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + let url = + "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id"; + + let resp = self + .http + .post(url) + .bearer_auth(token) + .header( + "Content-Type", + format!("multipart/related; boundary={}", boundary), + ) + .body(body) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Google Drive create error {}: {}", status, body); + } + + #[derive(Deserialize)] + struct CreateResponse { + id: String, + } + let created: CreateResponse = resp.json().await?; + debug!( + "Created file '{}' with ID {} ({} bytes)", + name, + created.id, + data.len() + ); + Ok(created.id) + } + + /// List files in a folder on Google Drive. + /// Only returns .docx files and folders. + #[instrument(skip(self, token), level = "debug")] + pub async fn list_files( + &self, + token: &str, + parent_id: &str, + page_token: Option<&str>, + page_size: u32, + ) -> anyhow::Result<(Vec, Option)> { + let query = format!( + "'{}' in parents and trashed=false and (mimeType='application/vnd.google-apps.folder' or mimeType='application/vnd.openxmlformats-officedocument.wordprocessingml.document')", + parent_id + ); + + let mut request = self + .http + .get("https://www.googleapis.com/drive/v3/files") + .bearer_auth(token) + .query(&[ + ("q", query.as_str()), + ("fields", "nextPageToken,files(id,name,mimeType,size,modifiedTime)"), + ("pageSize", &page_size.to_string()), + ("orderBy", "folder,name"), + ]); + + if let Some(pt) = page_token { + request = request.query(&[("pageToken", pt)]); + } + + let resp = request.send().await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Google Drive list error {}: {}", status, body); + } + + let list_response: FileListResponse = resp.json().await?; + debug!( + "Listed {} files in folder {}", + list_response.files.len(), + parent_id + ); + + Ok((list_response.files, list_response.next_page_token)) + } +} diff --git a/crates/docx-storage-gdrive/src/main.rs b/crates/docx-storage-gdrive/src/main.rs new file mode 100644 index 0000000..51a09ca --- /dev/null +++ b/crates/docx-storage-gdrive/src/main.rs @@ -0,0 +1,156 @@ +mod browse; +mod config; +mod d1_client; +mod gdrive; +mod service_sync; +mod service_watch; +mod sync; +mod token_manager; +mod watch; + +use std::sync::Arc; + +use clap::Parser; +use tokio::signal; +use tokio::sync::watch as tokio_watch; +use tonic::transport::Server; +use tonic_reflection::server::Builder as ReflectionBuilder; +use tracing::info; +use tracing_subscriber::EnvFilter; + +use browse::GDriveBrowsableBackend; +use config::Config; +use d1_client::D1Client; +use gdrive::GDriveClient; +use service_sync::SourceSyncServiceImpl; +use service_watch::ExternalWatchServiceImpl; +use sync::GDriveSyncBackend; +use token_manager::TokenManager; +use watch::GDriveWatchBackend; + +/// Include generated protobuf code. +pub mod proto { + tonic::include_proto!("docx.storage"); +} + +/// File descriptor set for gRPC reflection. +pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("storage_descriptor"); + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let config = Config::parse(); + + info!("Starting docx-storage-gdrive server (multi-tenant)"); + info!(" Poll interval: {} secs", config.watch_poll_interval_secs); + + // Create D1 client for OAuth token storage + let d1_client = Arc::new(D1Client::new( + config.cloudflare_account_id.clone(), + config.cloudflare_api_token.clone(), + config.d1_database_id.clone(), + )); + info!(" D1 client initialized (database: {})", config.d1_database_id); + + // Create token manager (reads tokens from D1, refreshes via Google OAuth2) + let token_manager = Arc::new(TokenManager::new( + d1_client.clone(), + config.google_client_id.clone(), + config.google_client_secret.clone(), + )); + info!(" Token manager initialized"); + + // Create Google Drive API client (stateless — tokens provided per-call) + let gdrive_client = Arc::new(GDriveClient::new()); + + // Create sync backend + let sync_backend: Arc = Arc::new( + GDriveSyncBackend::new(gdrive_client.clone(), token_manager.clone()), + ); + + // Create browse backend + let browse_backend: Arc = Arc::new( + GDriveBrowsableBackend::new(d1_client, gdrive_client.clone(), token_manager.clone()), + ); + + // Create watch backend + let watch_backend = Arc::new(GDriveWatchBackend::new( + gdrive_client, + token_manager, + config.watch_poll_interval_secs, + )); + + // Create gRPC services (sync + watch only — no StorageService) + let sync_service = SourceSyncServiceImpl::new(sync_backend, browse_backend); + let sync_svc = proto::source_sync_service_server::SourceSyncServiceServer::new(sync_service); + + let watch_service = ExternalWatchServiceImpl::new(watch_backend); + let watch_svc = + proto::external_watch_service_server::ExternalWatchServiceServer::new(watch_service); + + // Create shutdown signal + let mut shutdown_rx = create_shutdown_signal(); + let shutdown_future = async move { + let _ = shutdown_rx.wait_for(|&v| v).await; + }; + + // Create reflection service + let reflection_svc = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1()?; + + // Start server + let addr = format!("{}:{}", config.host, config.port).parse()?; + info!("Listening on tcp://{}", addr); + + Server::builder() + .add_service(reflection_svc) + .add_service(sync_svc) + .add_service(watch_svc) + .serve_with_shutdown(addr, shutdown_future) + .await?; + + info!("Server shutdown complete"); + Ok(()) +} + +/// Create a shutdown signal that triggers on Ctrl+C or SIGTERM. +fn create_shutdown_signal() -> tokio_watch::Receiver { + let (tx, rx) = tokio_watch::channel(false); + + tokio::spawn(async move { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + info!("Received Ctrl+C, initiating shutdown"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM handler") + .recv() + .await; + info!("Received SIGTERM, initiating shutdown"); + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + let _ = tx.send(true); + }); + + rx +} diff --git a/crates/docx-storage-gdrive/src/service_sync.rs b/crates/docx-storage-gdrive/src/service_sync.rs new file mode 100644 index 0000000..1120e4e --- /dev/null +++ b/crates/docx-storage-gdrive/src/service_sync.rs @@ -0,0 +1,406 @@ +use std::pin::Pin; +use std::sync::Arc; + +use docx_storage_core::{BrowsableBackend, SourceDescriptor, SourceType, SyncBackend}; +use tokio_stream::{Stream, StreamExt}; +use tonic::{Request, Response, Status, Streaming}; +use tracing::{debug, instrument}; + +use crate::proto; +use proto::source_sync_service_server::SourceSyncService; +use proto::*; + +/// Implementation of the SourceSyncService gRPC service for Google Drive. +pub struct SourceSyncServiceImpl { + sync_backend: Arc, + browse_backend: Arc, +} + +impl SourceSyncServiceImpl { + pub fn new( + sync_backend: Arc, + browse_backend: Arc, + ) -> Self { + Self { + sync_backend, + browse_backend, + } + } + + fn get_tenant_id(context: Option<&TenantContext>) -> Result<&str, Status> { + context + .map(|c| c.tenant_id.as_str()) + .ok_or_else(|| Status::invalid_argument("tenant context is required")) + } + + fn convert_source_type(proto_type: i32) -> SourceType { + match proto_type { + 1 => SourceType::LocalFile, + 2 => SourceType::SharePoint, + 3 => SourceType::OneDrive, + 4 => SourceType::S3, + 5 => SourceType::R2, + 6 => SourceType::GoogleDrive, + _ => SourceType::LocalFile, + } + } + + fn convert_source_descriptor( + proto: Option<&proto::SourceDescriptor>, + ) -> Option { + proto.map(|s| SourceDescriptor { + source_type: Self::convert_source_type(s.r#type), + connection_id: if s.connection_id.is_empty() { + None + } else { + Some(s.connection_id.clone()) + }, + path: s.path.clone(), + file_id: if s.file_id.is_empty() { + None + } else { + Some(s.file_id.clone()) + }, + }) + } + + fn to_proto_source_type(source_type: SourceType) -> i32 { + match source_type { + SourceType::LocalFile => 1, + SourceType::SharePoint => 2, + SourceType::OneDrive => 3, + SourceType::S3 => 4, + SourceType::R2 => 5, + SourceType::GoogleDrive => 6, + } + } + + fn to_proto_source_descriptor(source: &SourceDescriptor) -> proto::SourceDescriptor { + proto::SourceDescriptor { + r#type: Self::to_proto_source_type(source.source_type), + connection_id: source.connection_id.clone().unwrap_or_default(), + path: source.path.clone(), + file_id: source.file_id.clone().unwrap_or_default(), + } + } + + fn to_proto_sync_status(status: &docx_storage_core::SyncStatus) -> proto::SyncStatus { + proto::SyncStatus { + session_id: status.session_id.clone(), + source: Some(Self::to_proto_source_descriptor(&status.source)), + auto_sync_enabled: status.auto_sync_enabled, + last_synced_at_unix: status.last_synced_at.unwrap_or(0), + has_pending_changes: status.has_pending_changes, + last_error: status.last_error.clone().unwrap_or_default(), + } + } +} + +type DownloadFromSourceStream = Pin> + Send>>; + +#[tonic::async_trait] +impl SourceSyncService for SourceSyncServiceImpl { + type DownloadFromSourceStream = DownloadFromSourceStream; + + #[instrument(skip(self, request), level = "debug")] + async fn register_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let source = Self::convert_source_descriptor(req.source.as_ref()) + .ok_or_else(|| Status::invalid_argument("source is required"))?; + + match self + .sync_backend + .register_source(tenant_id, &req.session_id, source, req.auto_sync) + .await + { + Ok(()) => { + debug!( + "Registered source for tenant {} session {}", + tenant_id, req.session_id + ); + Ok(Response::new(RegisterSourceResponse { + success: true, + error: String::new(), + })) + } + Err(e) => Ok(Response::new(RegisterSourceResponse { + success: false, + error: e.to_string(), + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn unregister_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + self.sync_backend + .unregister_source(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(UnregisterSourceResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn update_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let source = Self::convert_source_descriptor(req.source.as_ref()); + let auto_sync = if req.update_auto_sync { + Some(req.auto_sync) + } else { + None + }; + + match self + .sync_backend + .update_source(tenant_id, &req.session_id, source, auto_sync) + .await + { + Ok(()) => Ok(Response::new(UpdateSourceResponse { + success: true, + error: String::new(), + })), + Err(e) => Ok(Response::new(UpdateSourceResponse { + success: false, + error: e.to_string(), + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn sync_to_source( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + + let mut tenant_id: Option = None; + let mut session_id: Option = None; + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + + if tenant_id.is_none() { + tenant_id = chunk.context.map(|c| c.tenant_id); + session_id = Some(chunk.session_id); + } + + data.extend(chunk.data); + + if chunk.is_last { + break; + } + } + + let tenant_id = tenant_id + .ok_or_else(|| Status::invalid_argument("tenant context is required in first chunk"))?; + let session_id = session_id + .filter(|s| !s.is_empty()) + .ok_or_else(|| Status::invalid_argument("session_id is required in first chunk"))?; + + match self + .sync_backend + .sync_to_source(&tenant_id, &session_id, &data) + .await + { + Ok(synced_at) => Ok(Response::new(SyncToSourceResponse { + success: true, + error: String::new(), + synced_at_unix: synced_at, + })), + Err(e) => Ok(Response::new(SyncToSourceResponse { + success: false, + error: e.to_string(), + synced_at_unix: 0, + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn get_sync_status( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let status = self + .sync_backend + .get_sync_status(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(GetSyncStatusResponse { + registered: status.is_some(), + status: status.map(|s| Self::to_proto_sync_status(&s)), + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_sources( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let sources = self + .sync_backend + .list_sources(tenant_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_sources: Vec = + sources.iter().map(Self::to_proto_sync_status).collect(); + + Ok(Response::new(ListSourcesResponse { + sources: proto_sources, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_connections( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let connections = self + .browse_backend + .list_connections(tenant_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_connections = connections + .into_iter() + .map(|c| proto::ConnectionInfo { + connection_id: c.connection_id, + r#type: Self::to_proto_source_type(c.source_type), + display_name: c.display_name, + provider_account_id: c.provider_account_id.unwrap_or_default(), + }) + .collect(); + + Ok(Response::new(ListConnectionsResponse { + connections: proto_connections, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_connection_files( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let page_size = if req.page_size > 0 { + req.page_size as u32 + } else { + 50 + }; + + let page_token = if req.page_token.is_empty() { + None + } else { + Some(req.page_token.as_str()) + }; + + let result = self + .browse_backend + .list_files( + tenant_id, + &req.connection_id, + &req.path, + page_token, + page_size, + ) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_files = result + .files + .into_iter() + .map(|f| proto::FileEntry { + name: f.name, + path: f.path, + file_id: f.file_id.unwrap_or_default(), + is_folder: f.is_folder, + size_bytes: f.size_bytes as i64, + modified_at_unix: f.modified_at, + mime_type: f.mime_type.unwrap_or_default(), + }) + .collect(); + + Ok(Response::new(ListConnectionFilesResponse { + files: proto_files, + next_page_token: result.next_page_token.unwrap_or_default(), + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn download_from_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + + let file_id = if req.file_id.is_empty() { + None + } else { + Some(req.file_id.as_str()) + }; + + let data = self + .browse_backend + .download_file(&tenant_id, &req.connection_id, &req.path, file_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + // Stream in 256KB chunks + let stream = async_stream::stream! { + const CHUNK_SIZE: usize = 256 * 1024; + let mut offset = 0; + while offset < data.len() { + let end = (offset + CHUNK_SIZE).min(data.len()); + let is_last = end >= data.len(); + yield Ok(DataChunk { + data: data[offset..end].to_vec(), + is_last, + found: true, + total_size: data.len() as u64, + }); + offset = end; + } + // Empty data → single empty chunk + if data.is_empty() { + yield Ok(DataChunk { + data: vec![], + is_last: true, + found: true, + total_size: 0, + }); + } + }; + + Ok(Response::new(Box::pin(stream))) + } +} diff --git a/crates/docx-storage-gdrive/src/service_watch.rs b/crates/docx-storage-gdrive/src/service_watch.rs new file mode 100644 index 0000000..d7b01f8 --- /dev/null +++ b/crates/docx-storage-gdrive/src/service_watch.rs @@ -0,0 +1,278 @@ +use std::pin::Pin; +use std::sync::Arc; + +use docx_storage_core::{SourceDescriptor, SourceType, WatchBackend}; +use tokio::sync::mpsc; +use tokio_stream::{wrappers::ReceiverStream, Stream}; +use tonic::{Request, Response, Status}; +use tracing::{debug, instrument, warn}; + +use crate::proto; +use crate::watch::GDriveWatchBackend; +use proto::external_watch_service_server::ExternalWatchService; +use proto::*; + +/// Implementation of the ExternalWatchService gRPC service for Google Drive. +pub struct ExternalWatchServiceImpl { + watch_backend: Arc, +} + +impl ExternalWatchServiceImpl { + pub fn new(watch_backend: Arc) -> Self { + Self { watch_backend } + } + + fn get_tenant_id(context: Option<&TenantContext>) -> Result<&str, Status> { + context + .map(|c| c.tenant_id.as_str()) + .ok_or_else(|| Status::invalid_argument("tenant context is required")) + } + + fn convert_source_type(proto_type: i32) -> SourceType { + match proto_type { + 1 => SourceType::LocalFile, + 2 => SourceType::SharePoint, + 3 => SourceType::OneDrive, + 4 => SourceType::S3, + 5 => SourceType::R2, + 6 => SourceType::GoogleDrive, + _ => SourceType::LocalFile, + } + } + + fn convert_source_descriptor( + proto: Option<&proto::SourceDescriptor>, + ) -> Option { + proto.map(|s| SourceDescriptor { + source_type: Self::convert_source_type(s.r#type), + connection_id: if s.connection_id.is_empty() { + None + } else { + Some(s.connection_id.clone()) + }, + path: s.path.clone(), + file_id: if s.file_id.is_empty() { + None + } else { + Some(s.file_id.clone()) + }, + }) + } + + fn to_proto_source_metadata( + metadata: &docx_storage_core::SourceMetadata, + ) -> proto::SourceMetadata { + proto::SourceMetadata { + size_bytes: metadata.size_bytes as i64, + modified_at_unix: metadata.modified_at, + etag: metadata.etag.clone().unwrap_or_default(), + version_id: metadata.version_id.clone().unwrap_or_default(), + content_hash: metadata.content_hash.clone().unwrap_or_default(), + } + } + + fn to_proto_change_type(change_type: docx_storage_core::ExternalChangeType) -> i32 { + match change_type { + docx_storage_core::ExternalChangeType::Modified => 1, + docx_storage_core::ExternalChangeType::Deleted => 2, + docx_storage_core::ExternalChangeType::Renamed => 3, + docx_storage_core::ExternalChangeType::PermissionChanged => 4, + } + } +} + +type WatchChangesStream = Pin> + Send>>; + +#[tonic::async_trait] +impl ExternalWatchService for ExternalWatchServiceImpl { + type WatchChangesStream = WatchChangesStream; + + #[instrument(skip(self, request), level = "debug")] + async fn start_watch( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let source = Self::convert_source_descriptor(req.source.as_ref()) + .ok_or_else(|| Status::invalid_argument("source is required"))?; + + match self + .watch_backend + .start_watch( + tenant_id, + &req.session_id, + &source, + req.poll_interval_seconds as u32, + ) + .await + { + Ok(watch_id) => { + debug!( + "Started watching for tenant {} session {}: {}", + tenant_id, req.session_id, watch_id + ); + Ok(Response::new(StartWatchResponse { + success: true, + watch_id, + error: String::new(), + })) + } + Err(e) => Ok(Response::new(StartWatchResponse { + success: false, + watch_id: String::new(), + error: e.to_string(), + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn stop_watch( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + self.watch_backend + .stop_watch(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(StopWatchResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn check_for_changes( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let change = self + .watch_backend + .check_for_changes(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let (current_metadata, known_metadata) = if change.is_some() { + ( + self.watch_backend + .get_source_metadata(tenant_id, &req.session_id) + .await + .ok() + .flatten() + .map(|m| Self::to_proto_source_metadata(&m)), + self.watch_backend + .get_known_metadata(tenant_id, &req.session_id) + .await + .ok() + .flatten() + .map(|m| Self::to_proto_source_metadata(&m)), + ) + } else { + (None, None) + }; + + Ok(Response::new(CheckForChangesResponse { + has_changes: change.is_some(), + current_metadata, + known_metadata, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn watch_changes( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_ids = req.session_ids; + + let (tx, rx) = mpsc::channel(100); + let watch_backend = self.watch_backend.clone(); + + tokio::spawn(async move { + loop { + // Use configured poll interval from the first watched session + let poll_secs = session_ids + .first() + .map(|sid| watch_backend.get_poll_interval(&tenant_id, sid)) + .unwrap_or(60); + + for session_id in &session_ids { + match watch_backend + .check_for_changes(&tenant_id, session_id) + .await + { + Ok(Some(change)) => { + let proto_event = ExternalChangeEvent { + session_id: change.session_id.clone(), + change_type: Self::to_proto_change_type(change.change_type), + old_metadata: change + .old_metadata + .as_ref() + .map(Self::to_proto_source_metadata), + new_metadata: change + .new_metadata + .as_ref() + .map(Self::to_proto_source_metadata), + detected_at_unix: change.detected_at, + new_uri: change.new_uri.clone().unwrap_or_default(), + }; + + if tx.send(Ok(proto_event)).await.is_err() { + return; + } + } + Ok(None) => {} + Err(e) => { + warn!( + "Error checking for changes for session {}: {}", + session_id, e + ); + } + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(poll_secs as u64)).await; + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + #[instrument(skip(self, request), level = "debug")] + async fn get_source_metadata( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + match self + .watch_backend + .get_source_metadata(tenant_id, &req.session_id) + .await + { + Ok(Some(metadata)) => Ok(Response::new(GetSourceMetadataResponse { + success: true, + metadata: Some(Self::to_proto_source_metadata(&metadata)), + error: String::new(), + })), + Ok(None) => Ok(Response::new(GetSourceMetadataResponse { + success: false, + metadata: None, + error: "Source not found".to_string(), + })), + Err(e) => Ok(Response::new(GetSourceMetadataResponse { + success: false, + metadata: None, + error: e.to_string(), + })), + } + } +} diff --git a/crates/docx-storage-gdrive/src/sync.rs b/crates/docx-storage-gdrive/src/sync.rs new file mode 100644 index 0000000..f6088d0 --- /dev/null +++ b/crates/docx-storage-gdrive/src/sync.rs @@ -0,0 +1,342 @@ +//! Google Drive SyncBackend implementation (multi-tenant). +//! +//! Resolves OAuth tokens per-connection via TokenManager. +//! Source is identified by typed SourceDescriptor (connection_id + file_id). + +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use docx_storage_core::{SourceDescriptor, SourceType, StorageError, SyncBackend, SyncStatus}; +use tracing::{debug, instrument, warn}; + +use crate::gdrive::GDriveClient; +use crate::token_manager::TokenManager; + +/// Transient sync state (in-memory only). +#[derive(Debug, Clone, Default)] +struct TransientSyncState { + source: Option, + auto_sync: bool, + last_synced_at: Option, + has_pending_changes: bool, + last_error: Option, +} + +/// Google Drive sync backend (multi-tenant, token per-connection). +pub struct GDriveSyncBackend { + client: Arc, + token_manager: Arc, + /// Transient state: (tenant_id, session_id) -> TransientSyncState + state: DashMap<(String, String), TransientSyncState>, +} + +impl GDriveSyncBackend { + pub fn new(client: Arc, token_manager: Arc) -> Self { + Self { + client, + token_manager, + state: DashMap::new(), + } + } + + fn key(tenant_id: &str, session_id: &str) -> (String, String) { + (tenant_id.to_string(), session_id.to_string()) + } +} + +#[async_trait] +impl SyncBackend for GDriveSyncBackend { + #[instrument(skip(self), level = "debug")] + async fn register_source( + &self, + tenant_id: &str, + session_id: &str, + source: SourceDescriptor, + auto_sync: bool, + ) -> Result<(), StorageError> { + if source.source_type != SourceType::GoogleDrive { + return Err(StorageError::Sync(format!( + "GDriveSyncBackend only supports GoogleDrive sources, got {:?}", + source.source_type + ))); + } + + if source.connection_id.is_none() { + return Err(StorageError::Sync( + "Google Drive source requires a connection_id".to_string(), + )); + } + + let key = Self::key(tenant_id, session_id); + debug!( + "Registered Google Drive source for tenant {} session {} -> {} (auto_sync={})", + tenant_id, + session_id, + source.effective_id(), + auto_sync + ); + + self.state.insert( + key, + TransientSyncState { + source: Some(source), + auto_sync, + ..Default::default() + }, + ); + + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn unregister_source( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result<(), StorageError> { + let key = Self::key(tenant_id, session_id); + self.state.remove(&key); + + debug!( + "Unregistered source for tenant {} session {}", + tenant_id, session_id + ); + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn update_source( + &self, + tenant_id: &str, + session_id: &str, + source: Option, + auto_sync: Option, + ) -> Result<(), StorageError> { + let key = Self::key(tenant_id, session_id); + + let mut entry = self.state.get_mut(&key).ok_or_else(|| { + StorageError::Sync(format!( + "No source registered for tenant {} session {}", + tenant_id, session_id + )) + })?; + + if let Some(new_source) = source { + if new_source.source_type != SourceType::GoogleDrive { + return Err(StorageError::Sync(format!( + "GDriveSyncBackend only supports GoogleDrive sources, got {:?}", + new_source.source_type + ))); + } + entry.source = Some(new_source); + } + + if let Some(new_auto_sync) = auto_sync { + entry.auto_sync = new_auto_sync; + } + + Ok(()) + } + + #[instrument(skip(self, data), level = "debug", fields(data_len = data.len()))] + async fn sync_to_source( + &self, + tenant_id: &str, + session_id: &str, + data: &[u8], + ) -> Result { + let key = Self::key(tenant_id, session_id); + + let (connection_id, has_real_file_id, file_id_or_path, display_path) = { + let entry = self.state.get(&key).ok_or_else(|| { + StorageError::Sync(format!( + "No source registered for tenant {} session {}", + tenant_id, session_id + )) + })?; + + let source = entry.source.as_ref().ok_or_else(|| { + StorageError::Sync(format!( + "No source configured for tenant {} session {}", + tenant_id, session_id + )) + })?; + + let conn_id = source.connection_id.clone().ok_or_else(|| { + StorageError::Sync("Google Drive source requires a connection_id".to_string()) + })?; + + let has_fid = source + .file_id + .as_deref() + .filter(|id| !id.is_empty()) + .is_some(); + let fid_or_path = source.effective_id().to_string(); + let path = source.path.clone(); + + (conn_id, has_fid, fid_or_path, path) + }; + + // Get a valid token for this connection (tenant-scoped) + let token = self + .token_manager + .get_valid_token(tenant_id, &connection_id) + .await + .map_err(|e| StorageError::Sync(format!("Token error: {}", e)))?; + + let effective_file_id = if has_real_file_id { + // Existing file → update in place + debug!( + "Updating existing file {} on Google Drive", + file_id_or_path + ); + self.client + .update_file(&token, &file_id_or_path, data) + .await + .map_err(|e| { + StorageError::Sync(format!("Google Drive upload failed: {}", e)) + })?; + file_id_or_path + } else { + // New file → create on Google Drive, then remember the new file_id + let name = std::path::Path::new(&display_path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or(display_path.clone()); + + debug!("Creating new file '{}' on Google Drive", name); + let new_file_id = + self.client + .create_file(&token, &name, None, data) + .await + .map_err(|e| { + StorageError::Sync(format!("Google Drive create failed: {}", e)) + })?; + + // Update transient state with the newly assigned file_id + if let Some(mut entry) = self.state.get_mut(&key) { + if let Some(ref mut src) = entry.source { + src.file_id = Some(new_file_id.clone()); + } + } + + new_file_id + }; + + let synced_at = chrono::Utc::now().timestamp(); + + // Update transient state + if let Some(mut entry) = self.state.get_mut(&key) { + entry.last_synced_at = Some(synced_at); + entry.has_pending_changes = false; + entry.last_error = None; + } + + debug!( + "Synced {} bytes to {} for tenant {} session {}", + data.len(), + effective_file_id, + tenant_id, + session_id + ); + + Ok(synced_at) + } + + #[instrument(skip(self), level = "debug")] + async fn get_sync_status( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + let entry = match self.state.get(&key) { + Some(e) => e, + None => return Ok(None), + }; + + let source = match &entry.source { + Some(s) => s.clone(), + None => return Ok(None), + }; + + Ok(Some(SyncStatus { + session_id: session_id.to_string(), + source, + auto_sync_enabled: entry.auto_sync, + last_synced_at: entry.last_synced_at, + has_pending_changes: entry.has_pending_changes, + last_error: entry.last_error.clone(), + })) + } + + #[instrument(skip(self), level = "debug")] + async fn list_sources(&self, tenant_id: &str) -> Result, StorageError> { + let mut results = Vec::new(); + + for entry in self.state.iter() { + let (key_tenant, _) = entry.key(); + if key_tenant != tenant_id { + continue; + } + + let state = entry.value(); + if let Some(source) = &state.source { + let (_, session_id) = entry.key(); + results.push(SyncStatus { + session_id: session_id.clone(), + source: source.clone(), + auto_sync_enabled: state.auto_sync, + last_synced_at: state.last_synced_at, + has_pending_changes: state.has_pending_changes, + last_error: state.last_error.clone(), + }); + } + } + + debug!( + "Listed {} Google Drive sources for tenant {}", + results.len(), + tenant_id + ); + Ok(results) + } + + #[instrument(skip(self), level = "debug")] + async fn is_auto_sync_enabled( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result { + let key = Self::key(tenant_id, session_id); + Ok(self + .state + .get(&key) + .map(|e| e.auto_sync && e.source.is_some()) + .unwrap_or(false)) + } +} + +impl GDriveSyncBackend { + #[allow(dead_code)] + pub fn mark_pending_changes(&self, tenant_id: &str, session_id: &str) { + let key = Self::key(tenant_id, session_id); + if let Some(mut state) = self.state.get_mut(&key) { + state.has_pending_changes = true; + } + } + + #[allow(dead_code)] + pub fn record_sync_error(&self, tenant_id: &str, session_id: &str, error: &str) { + let key = Self::key(tenant_id, session_id); + if let Some(mut state) = self.state.get_mut(&key) { + state.last_error = Some(error.to_string()); + warn!( + "Sync error for tenant {} session {}: {}", + tenant_id, session_id, error + ); + } + } +} diff --git a/crates/docx-storage-gdrive/src/token_manager.rs b/crates/docx-storage-gdrive/src/token_manager.rs new file mode 100644 index 0000000..7206abc --- /dev/null +++ b/crates/docx-storage-gdrive/src/token_manager.rs @@ -0,0 +1,184 @@ +//! Per-connection OAuth token manager with automatic refresh. +//! +//! Reads tokens from D1 via `D1Client`, caches them in-memory, +//! and refreshes via Google OAuth2 when expired. + +use std::sync::Arc; + +use dashmap::DashMap; +use tracing::{debug, info, warn}; + +use crate::d1_client::D1Client; + +/// Cached token with expiration. +#[derive(Debug, Clone)] +struct CachedToken { + access_token: String, + expires_at: Option>, +} + +impl CachedToken { + fn is_expired(&self) -> bool { + match self.expires_at { + Some(exp) => chrono::Utc::now() >= exp - chrono::Duration::minutes(5), + None => true, // No expiration info → always refresh to be safe + } + } +} + +/// Manages OAuth tokens per-connection with caching and automatic refresh. +pub struct TokenManager { + d1: Arc, + http: reqwest::Client, + google_client_id: String, + google_client_secret: String, + cache: DashMap, +} + +impl TokenManager { + pub fn new( + d1: Arc, + google_client_id: String, + google_client_secret: String, + ) -> Self { + Self { + d1, + http: reqwest::Client::new(), + google_client_id, + google_client_secret, + cache: DashMap::new(), + } + } + + /// Get a valid access token for a connection, refreshing if necessary. + /// `tenant_id` is required for tenant isolation — only connections owned by + /// this tenant can be accessed. + pub async fn get_valid_token(&self, tenant_id: &str, connection_id: &str) -> anyhow::Result { + // 1. Check cache (keyed by connection_id — safe because D1 validates tenant) + if let Some(cached) = self.cache.get(connection_id) { + if !cached.is_expired() { + debug!("Token cache hit for connection {}", connection_id); + return Ok(cached.access_token.clone()); + } + debug!("Token expired for connection {}, refreshing", connection_id); + } + + // 2. Read from D1 (tenant-scoped query) + let conn = self + .d1 + .get_connection(tenant_id, connection_id) + .await? + .ok_or_else(|| anyhow::anyhow!("OAuth connection not found: {} (tenant: {})", connection_id, tenant_id))?; + + // 3. Check if token from D1 is still valid + let expires_at = conn + .token_expires_at + .as_ref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); + + let cached = CachedToken { + access_token: conn.access_token.clone(), + expires_at, + }; + + if !cached.is_expired() { + self.cache + .insert(connection_id.to_string(), cached.clone()); + return Ok(cached.access_token); + } + + // 4. Refresh the token + info!( + "Refreshing OAuth token for connection {} ({})", + connection_id, conn.display_name + ); + + let new_token = self + .refresh_token(&conn.refresh_token, connection_id) + .await?; + + Ok(new_token) + } + + /// Refresh an OAuth token using the refresh_token grant. + async fn refresh_token( + &self, + refresh_token: &str, + connection_id: &str, + ) -> anyhow::Result { + let resp = self + .http + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("client_id", self.google_client_id.as_str()), + ("client_secret", self.google_client_secret.as_str()), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "OAuth token refresh failed for connection {}: {} {}", + connection_id, + status, + body + ); + } + + #[derive(serde::Deserialize)] + struct RefreshResponse { + access_token: String, + expires_in: u64, + refresh_token: Option, + } + + let token_resp: RefreshResponse = resp.json().await?; + + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(token_resp.expires_in as i64); + let expires_at_str = expires_at.to_rfc3339(); + + // Google may rotate the refresh token + let new_refresh = token_resp + .refresh_token + .as_deref() + .unwrap_or(refresh_token); + + // Update D1 + if let Err(e) = self + .d1 + .update_tokens( + connection_id, + &token_resp.access_token, + new_refresh, + &expires_at_str, + ) + .await + { + warn!( + "Failed to update tokens in D1 for connection {}: {}", + connection_id, e + ); + } + + // Update cache + self.cache.insert( + connection_id.to_string(), + CachedToken { + access_token: token_resp.access_token.clone(), + expires_at: Some(expires_at), + }, + ); + + info!( + "Refreshed OAuth token for connection {}, expires at {}", + connection_id, expires_at_str + ); + + Ok(token_resp.access_token) + } +} diff --git a/crates/docx-storage-gdrive/src/watch.rs b/crates/docx-storage-gdrive/src/watch.rs new file mode 100644 index 0000000..11e5019 --- /dev/null +++ b/crates/docx-storage-gdrive/src/watch.rs @@ -0,0 +1,329 @@ +//! Google Drive WatchBackend implementation (multi-tenant). +//! +//! Polling-based change detection using `headRevisionId` from Drive API. +//! Resolves OAuth tokens per-connection via TokenManager. + +use async_trait::async_trait; +use dashmap::DashMap; +use docx_storage_core::{ + ExternalChangeEvent, ExternalChangeType, SourceDescriptor, SourceMetadata, SourceType, + StorageError, WatchBackend, +}; +use std::sync::Arc; +use tracing::{debug, instrument}; + +use crate::gdrive::GDriveClient; +use crate::token_manager::TokenManager; + +/// State for a watched Google Drive file. +#[derive(Debug, Clone)] +struct WatchedSource { + source: SourceDescriptor, + #[allow(dead_code)] + watch_id: String, + known_metadata: Option, + poll_interval_secs: u32, +} + +/// Polling-based watch backend for Google Drive (multi-tenant). +pub struct GDriveWatchBackend { + client: Arc, + token_manager: Arc, + /// Watched sources: (tenant_id, session_id) -> WatchedSource + sources: DashMap<(String, String), WatchedSource>, + /// Pending change events + pending_changes: DashMap<(String, String), ExternalChangeEvent>, + /// Default poll interval (seconds) + default_poll_interval: u32, +} + +impl GDriveWatchBackend { + pub fn new( + client: Arc, + token_manager: Arc, + default_poll_interval: u32, + ) -> Self { + Self { + client, + token_manager, + sources: DashMap::new(), + pending_changes: DashMap::new(), + default_poll_interval, + } + } + + fn key(tenant_id: &str, session_id: &str) -> (String, String) { + (tenant_id.to_string(), session_id.to_string()) + } + + /// Fetch metadata from Google Drive and convert to SourceMetadata. + async fn fetch_metadata( + &self, + token: &str, + file_id: &str, + ) -> Result, StorageError> { + let metadata = self + .client + .get_metadata(token, file_id) + .await + .map_err(|e| StorageError::Watch(format!("Google Drive API error: {}", e)))?; + + Ok(metadata.map(|m| { + let size_bytes = m + .size + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let modified_at = m + .modified_time + .as_ref() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + + let content_hash = m + .md5_checksum + .as_ref() + .and_then(|h| hex::decode(h).ok()); + + SourceMetadata { + size_bytes, + modified_at, + etag: None, + version_id: m.head_revision_id.clone(), + content_hash, + } + })) + } + + /// Get a valid token for a source, using its connection_id (tenant-scoped). + async fn get_token_for_source( + &self, + tenant_id: &str, + source: &SourceDescriptor, + ) -> Result<(String, String), StorageError> { + let connection_id = source.connection_id.as_deref().ok_or_else(|| { + StorageError::Watch("Google Drive source requires a connection_id".to_string()) + })?; + + let token = self + .token_manager + .get_valid_token(tenant_id, connection_id) + .await + .map_err(|e| StorageError::Watch(format!("Token error: {}", e)))?; + + let file_id = source.effective_id().to_string(); + Ok((token, file_id)) + } + + /// Compare metadata to detect changes. Prefers headRevisionId. + fn has_changed(old: &SourceMetadata, new: &SourceMetadata) -> bool { + // Prefer headRevisionId comparison (most reliable for Google Drive) + if let (Some(old_ver), Some(new_ver)) = (&old.version_id, &new.version_id) { + return old_ver != new_ver; + } + + // Fall back to content hash (md5Checksum) + if let (Some(old_hash), Some(new_hash)) = (&old.content_hash, &new.content_hash) { + return old_hash != new_hash; + } + + // Last resort: size and mtime + old.size_bytes != new.size_bytes || old.modified_at != new.modified_at + } + + /// Get the configured poll interval for a watched source. + pub fn get_poll_interval(&self, tenant_id: &str, session_id: &str) -> u32 { + let key = Self::key(tenant_id, session_id); + self.sources + .get(&key) + .map(|w| w.poll_interval_secs) + .unwrap_or(self.default_poll_interval) + } +} + +#[async_trait] +impl WatchBackend for GDriveWatchBackend { + #[instrument(skip(self), level = "debug")] + async fn start_watch( + &self, + tenant_id: &str, + session_id: &str, + source: &SourceDescriptor, + poll_interval_secs: u32, + ) -> Result { + if source.source_type != SourceType::GoogleDrive { + return Err(StorageError::Watch(format!( + "GDriveWatchBackend only supports GoogleDrive sources, got {:?}", + source.source_type + ))); + } + + let (token, file_id) = self.get_token_for_source(tenant_id, source).await?; + + let watch_id = uuid::Uuid::new_v4().to_string(); + let map_key = Self::key(tenant_id, session_id); + + // Get initial metadata + let known_metadata = self.fetch_metadata(&token, &file_id).await?; + + let poll_interval = if poll_interval_secs > 0 { + poll_interval_secs + } else { + self.default_poll_interval + }; + + self.sources.insert( + map_key, + WatchedSource { + source: source.clone(), + watch_id: watch_id.clone(), + known_metadata, + poll_interval_secs: poll_interval, + }, + ); + + debug!( + "Started watching Google Drive file {} (tenant {} session {}, interval {} secs)", + file_id, tenant_id, session_id, poll_interval + ); + + Ok(watch_id) + } + + #[instrument(skip(self), level = "debug")] + async fn stop_watch(&self, tenant_id: &str, session_id: &str) -> Result<(), StorageError> { + let key = Self::key(tenant_id, session_id); + + if let Some((_, watched)) = self.sources.remove(&key) { + debug!( + "Stopped watching {} for tenant {} session {}", + watched.source.effective_id(), + tenant_id, + session_id + ); + } + + self.pending_changes.remove(&key); + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn check_for_changes( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + // Check for pending changes first + if let Some((_, event)) = self.pending_changes.remove(&key) { + return Ok(Some(event)); + } + + // Get watched source + let watched = match self.sources.get(&key) { + Some(w) => w.clone(), + None => return Ok(None), + }; + + let (token, file_id) = self.get_token_for_source(tenant_id, &watched.source).await?; + + // Get current metadata + let current_metadata = match self.fetch_metadata(&token, &file_id).await? { + Some(m) => m, + None => { + // File was deleted + if watched.known_metadata.is_some() { + let event = ExternalChangeEvent { + session_id: session_id.to_string(), + change_type: ExternalChangeType::Deleted, + old_metadata: watched.known_metadata.clone(), + new_metadata: None, + detected_at: chrono::Utc::now().timestamp(), + new_uri: None, + }; + return Ok(Some(event)); + } + return Ok(None); + } + }; + + // Compare with known metadata + if let Some(known) = &watched.known_metadata { + if Self::has_changed(known, ¤t_metadata) { + debug!( + "Detected change in {} (revision: {:?} -> {:?})", + watched.source.effective_id(), + known.version_id, + current_metadata.version_id + ); + + let event = ExternalChangeEvent { + session_id: session_id.to_string(), + change_type: ExternalChangeType::Modified, + old_metadata: Some(known.clone()), + new_metadata: Some(current_metadata), + detected_at: chrono::Utc::now().timestamp(), + new_uri: None, + }; + + return Ok(Some(event)); + } + } + + Ok(None) + } + + #[instrument(skip(self), level = "debug")] + async fn get_source_metadata( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + let watched = match self.sources.get(&key) { + Some(w) => w.clone(), + None => return Ok(None), + }; + + let (token, file_id) = self.get_token_for_source(tenant_id, &watched.source).await?; + self.fetch_metadata(&token, &file_id).await + } + + #[instrument(skip(self), level = "debug")] + async fn get_known_metadata( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + Ok(self + .sources + .get(&key) + .and_then(|w| w.known_metadata.clone())) + } + + #[instrument(skip(self, metadata), level = "debug")] + async fn update_known_metadata( + &self, + tenant_id: &str, + session_id: &str, + metadata: SourceMetadata, + ) -> Result<(), StorageError> { + let key = Self::key(tenant_id, session_id); + + if let Some(mut watched) = self.sources.get_mut(&key) { + watched.known_metadata = Some(metadata); + debug!( + "Updated known metadata for tenant {} session {}", + tenant_id, session_id + ); + } + + Ok(()) + } +} diff --git a/crates/docx-storage-local/Cargo.lock b/crates/docx-storage-local/Cargo.lock new file mode 100644 index 0000000..f46a61f --- /dev/null +++ b/crates/docx-storage-local/Cargo.lock @@ -0,0 +1,3690 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0149602eeaf915158e14029ba0c78dedb8c08d554b024d54c8f239aab46511d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01c9521fa01558f750d183c8c68c81b0155b9d193a4ba7f84c36bd1b6d04a06" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce527fb7e53ba9626fc47824f25e256250556c40d8f81d27dd92aa38239d632" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e25d24de44b34dcdd5182ac4e4c6f07bcec2661c505acef94c0d293b65505fe" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.90.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f18e53542c522459e757f81e274783a78f8c81acdfc8d1522ee8a18b5fb1c66" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.92.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "532f4d866012ffa724a4385c82e8dd0e59f0ca0e600f3f22d4c03b6824b34e4a" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.94.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be6fbbfa1a57724788853a623378223fe828fc4c09b146c992f0c95b6256174" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "127fcfad33b7dfc531141fda7e1c402ac65f88aca5511a4d31e2e3d2cd01ce9c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.63.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95bd108f7b3563598e4dc7b62e1388c9982324a2abd622442167012690184591" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2db31f727935fc63c6eeae8b37b438847639ec330a9161ece694efba257e0c54" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1881b1ea6d313f9890710d65c158bdab6fb08c91ea825f74c1c8c357baf4cc" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d28a63441360c477465f80c7abac3b9c4d075ca638f982e605b7dc2a2c7156c9" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7204f9fd94749a7c53b26da1b961b4ac36bf070ef1e0b94bb09f79d4f6c193" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f535879a207fce0db74b679cfc3e91a3159c8144d717d55f5832aea9eef46e" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab77cdd036b11056d2a30a7af7b775789fb024bf216acc13884c6c97752ae56" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d79fb68e3d7fe5d4833ea34dc87d2e97d26d3086cb3da660bb6b1f76d98680b6" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" +dependencies = [ + "crc", + "digest", + "rand", + "regex", + "rustversion", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "docx-mcp-storage" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "aws-config", + "aws-sdk-s3", + "chrono", + "clap", + "dirs", + "futures", + "prost", + "prost-types", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tokio-stream", + "tokio-test", + "tonic", + "tonic-build", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/crates/docx-storage-local/Cargo.toml b/crates/docx-storage-local/Cargo.toml new file mode 100644 index 0000000..5cf22ff --- /dev/null +++ b/crates/docx-storage-local/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "docx-storage-local" +description = "Local filesystem storage backend for docx-mcp multi-tenant architecture" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +# Core traits +docx-storage-core = { path = "../docx-storage-core" } + +# gRPC +tonic.workspace = true +tonic-reflection = "0.13" +prost.workspace = true +prost-types.workspace = true +tokio.workspace = true +tokio-stream.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true +serde_bytes = "0.11" + +# Logging +tracing.workspace = true +tracing-subscriber.workspace = true + +# Error handling +thiserror.workspace = true +anyhow.workspace = true + +# Async utilities +async-trait.workspace = true +futures.workspace = true +async-stream = "0.3" + +# Time +chrono.workspace = true + +# UUID +uuid = { version = "1", features = ["v4"] } + +# CLI +clap.workspace = true + +# Paths +dirs = "6" + +# File locking +fs2 = "0.4" +dashmap = "6" + +# Filesystem watching +notify = "8" + +# Crypto (for SHA256 hash - matching C# ExternalChangeTracker) +sha2.workspace = true + +# Platform-specific for parent process watching +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_Security"] } + +[build-dependencies] +tonic-build = "0.13" + +[dev-dependencies] +tempfile.workspace = true +tokio-test = "0.4" + +[lib] +name = "docx_storage_local" +crate-type = ["staticlib", "lib"] +path = "src/lib.rs" + +[[bin]] +name = "docx-storage-local" +path = "src/main.rs" + +[lints] +workspace = true diff --git a/crates/docx-storage-local/Dockerfile b/crates/docx-storage-local/Dockerfile new file mode 100644 index 0000000..0356867 --- /dev/null +++ b/crates/docx-storage-local/Dockerfile @@ -0,0 +1,61 @@ +# ============================================================================= +# docx-storage-local Dockerfile +# Multi-stage build for the gRPC storage server +# ============================================================================= + +# Stage 1: Build +FROM rust:1.93-slim-bookworm AS builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY proto/ ./proto/ +COPY crates/ ./crates/ + +# Build the storage server +RUN cargo build --release --package docx-storage-local + +# Stage 2: Runtime +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies (minimal) +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 docx +USER docx + +WORKDIR /app + +# Copy the binary from builder +COPY --from=builder /build/target/release/docx-storage-local /app/docx-storage-local + +# Create data directory +RUN mkdir -p /app/data + +# Environment defaults +ENV RUST_LOG=info +ENV GRPC_HOST=0.0.0.0 +ENV GRPC_PORT=50051 +ENV STORAGE_BACKEND=local +ENV LOCAL_STORAGE_DIR=/app/data + +# Expose gRPC port +EXPOSE 50051 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD ["timeout", "5", "grpc_health_probe", "-addr=localhost:50051"] || exit 1 + +# Run the server +ENTRYPOINT ["/app/docx-storage-local"] +CMD ["--transport", "tcp"] diff --git a/crates/docx-storage-local/build.rs b/crates/docx-storage-local/build.rs new file mode 100644 index 0000000..abb0ca2 --- /dev/null +++ b/crates/docx-storage-local/build.rs @@ -0,0 +1,13 @@ +use std::env; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + + tonic_build::configure() + .build_server(true) + .build_client(true) + .file_descriptor_set_path(out_dir.join("storage_descriptor.bin")) + .compile_protos(&["../../proto/storage.proto"], &["../../proto"])?; + Ok(()) +} diff --git a/crates/docx-storage-local/src/browse.rs b/crates/docx-storage-local/src/browse.rs new file mode 100644 index 0000000..2c8b26d --- /dev/null +++ b/crates/docx-storage-local/src/browse.rs @@ -0,0 +1,176 @@ +use async_trait::async_trait; +use docx_storage_core::{ + BrowsableBackend, ConnectionInfo, FileEntry, FileListResult, SourceType, StorageError, +}; +use tracing::debug; + +/// Local filesystem browsable backend. +/// +/// Lists .docx files and folders on the local filesystem. +/// Returns a single "Local filesystem" connection. +pub struct LocalBrowsableBackend; + +impl LocalBrowsableBackend { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl BrowsableBackend for LocalBrowsableBackend { + async fn list_connections(&self, _tenant_id: &str) -> Result, StorageError> { + Ok(vec![ConnectionInfo { + connection_id: String::new(), + source_type: SourceType::LocalFile, + display_name: "Local filesystem".to_string(), + provider_account_id: None, + }]) + } + + async fn list_files( + &self, + _tenant_id: &str, + _connection_id: &str, + path: &str, + page_token: Option<&str>, + page_size: u32, + ) -> Result { + let dir_path = if path.is_empty() { + // Default to home directory + dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")) + } else { + std::path::PathBuf::from(path) + }; + + if !dir_path.is_dir() { + return Err(StorageError::Sync(format!( + "Path is not a directory: {}", + dir_path.display() + ))); + } + + let mut entries: Vec = Vec::new(); + + let read_dir = std::fs::read_dir(&dir_path).map_err(|e| { + StorageError::Sync(format!( + "Failed to read directory {}: {}", + dir_path.display(), + e + )) + })?; + + for entry in read_dir { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let metadata = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip hidden files + if name.starts_with('.') { + continue; + } + + let is_folder = metadata.is_dir(); + + // Only include folders and .docx files + if !is_folder && !name.to_lowercase().ends_with(".docx") { + continue; + } + + let full_path = entry.path().to_string_lossy().to_string(); + let modified_at = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let mime_type = if is_folder { + None + } else { + Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".to_string()) + }; + + entries.push(FileEntry { + name, + path: full_path, + file_id: None, + is_folder, + size_bytes: if is_folder { 0 } else { metadata.len() }, + modified_at, + mime_type, + }); + } + + // Sort: folders first, then by name + entries.sort_by(|a, b| { + match (a.is_folder, b.is_folder) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + } + }); + + // Pagination: offset-based (page_token = offset as string) + let offset: usize = page_token + .and_then(|t| t.parse().ok()) + .unwrap_or(0); + + let page_size = page_size as usize; + let total = entries.len(); + let end = std::cmp::min(offset + page_size, total); + let page = entries[offset..end].to_vec(); + + let next_page_token = if end < total { + Some(end.to_string()) + } else { + None + }; + + debug!( + "Listed {} files in {} (total {}, offset {}, page_size {})", + page.len(), + dir_path.display(), + total, + offset, + page_size + ); + + Ok(FileListResult { + files: page, + next_page_token, + }) + } + + async fn download_file( + &self, + _tenant_id: &str, + _connection_id: &str, + path: &str, + _file_id: Option<&str>, + ) -> Result, StorageError> { + let file_path = std::path::PathBuf::from(path); + + if !file_path.exists() { + return Err(StorageError::Sync(format!( + "File not found: {}", + file_path.display() + ))); + } + + std::fs::read(&file_path).map_err(|e| { + StorageError::Sync(format!( + "Failed to read file {}: {}", + file_path.display(), + e + )) + }) + } +} diff --git a/crates/docx-storage-local/src/config.rs b/crates/docx-storage-local/src/config.rs new file mode 100644 index 0000000..c98082b --- /dev/null +++ b/crates/docx-storage-local/src/config.rs @@ -0,0 +1,89 @@ +use std::path::PathBuf; + +use clap::Parser; + +/// Configuration for the docx-storage-local server. +#[derive(Parser, Debug, Clone)] +#[command(name = "docx-storage-local")] +#[command(about = "Local filesystem gRPC storage server for docx-mcp")] +pub struct Config { + /// Transport type: tcp or unix + #[arg(long, default_value = "tcp", env = "GRPC_TRANSPORT")] + pub transport: Transport, + + /// TCP host to bind to (only used with --transport tcp) + #[arg(long, default_value = "0.0.0.0", env = "GRPC_HOST")] + pub host: String, + + /// TCP port to bind to (only used with --transport tcp) + #[arg(long, default_value = "50051", env = "GRPC_PORT")] + pub port: u16, + + /// Unix socket path (only used with --transport unix) + #[arg(long, env = "GRPC_UNIX_SOCKET")] + pub unix_socket: Option, + + /// Storage backend (always local for this binary) + #[arg(long, default_value = "local", env = "STORAGE_BACKEND")] + pub storage_backend: StorageBackend, + + /// Base directory for local storage + #[arg(long, env = "LOCAL_STORAGE_DIR")] + pub local_storage_dir: Option, + + /// Parent process PID to watch. If set, server will exit when parent dies. + /// This enables fork/join semantics where the child server follows the parent lifecycle. + #[arg(long)] + pub parent_pid: Option, +} + +impl Config { + /// Get the effective local storage directory. + pub fn effective_local_storage_dir(&self) -> PathBuf { + self.local_storage_dir.clone().unwrap_or_else(|| { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("docx-mcp") + .join("sessions") + }) + } + + /// Get the effective Unix socket path. + #[cfg(unix)] + pub fn effective_unix_socket(&self) -> PathBuf { + self.unix_socket.clone().unwrap_or_else(|| { + std::env::var("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join("docx-storage-local.sock") + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Transport { + Tcp, + Unix, +} + +impl std::fmt::Display for Transport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Transport::Tcp => write!(f, "tcp"), + Transport::Unix => write!(f, "unix"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum StorageBackend { + Local, +} + +impl std::fmt::Display for StorageBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorageBackend::Local => write!(f, "local"), + } + } +} diff --git a/crates/docx-storage-local/src/embedded.rs b/crates/docx-storage-local/src/embedded.rs new file mode 100644 index 0000000..8633d2c --- /dev/null +++ b/crates/docx-storage-local/src/embedded.rs @@ -0,0 +1,238 @@ +use std::path::Path; +use std::pin::Pin; +use std::sync::{Mutex, OnceLock}; +use std::task::{Context, Poll}; + +use tokio::io::{AsyncRead, AsyncWrite, DuplexStream, ReadBuf, ReadHalf, WriteHalf}; +use tokio::runtime::Runtime; +use tokio::task::AbortHandle; +use tonic::transport::server::Connected; +use tonic::transport::Server; + +use crate::server; +use crate::service::proto::external_watch_service_server::ExternalWatchServiceServer; +use crate::service::proto::source_sync_service_server::SourceSyncServiceServer; +use crate::service::proto::storage_service_server::StorageServiceServer; +use crate::service::StorageServiceImpl; +use crate::service_sync::SourceSyncServiceImpl; +use crate::service_watch::ExternalWatchServiceImpl; + +/// Returns true if DEBUG environment variable is set. +fn is_debug() -> bool { + std::env::var("DEBUG").is_ok() +} + +/// Wrapper around DuplexStream that implements tonic's Connected trait. +struct InMemoryStream(DuplexStream); + +impl Connected for InMemoryStream { + type ConnectInfo = (); + fn connect_info(&self) -> Self::ConnectInfo {} +} + +impl AsyncRead for InMemoryStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, buf) + } +} + +impl AsyncWrite for InMemoryStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, buf) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(cx) + } +} + +/// Global state for the embedded gRPC server. +/// Read and write halves have separate mutexes so HTTP/2 full-duplex works +/// (one .NET thread reads, another writes, concurrently). +struct EmbeddedState { + runtime: Runtime, + read_half: Mutex>, + write_half: Mutex>, + server_abort: AbortHandle, +} + +static STATE: OnceLock = OnceLock::new(); + +/// Initialize the embedded gRPC server with in-memory DuplexStream transport. +/// +/// Creates storage backends, starts tonic server on a background tokio task, +/// and splits the client half of the DuplexStream for FFI read/write access. +pub fn init(storage_dir: &Path) -> Result<(), String> { + let debug = is_debug(); + if debug { + eprintln!("[embedded] init: creating runtime..."); + } + let runtime = Runtime::new().map_err(|e| e.to_string())?; + + // Enter the runtime context so create_backends() can call tokio::spawn() + // (needed by NotifyWatchBackend which spawns an event processing task) + let _guard = runtime.enter(); + + // Create backends (shared with main.rs via server module) + let (storage, lock, sync, watch, browse) = server::create_backends(storage_dir); + + // Create gRPC services + let storage_svc = StorageServiceServer::new(StorageServiceImpl::new(storage, lock)); + let sync_svc = SourceSyncServiceServer::new(SourceSyncServiceImpl::new(sync, browse)); + let watch_svc = ExternalWatchServiceServer::new(ExternalWatchServiceImpl::new(watch)); + + // Create in-memory transport (256KB buffer — matches StorageClient chunk size) + if debug { + eprintln!("[embedded] init: creating DuplexStream..."); + } + let (client, server_stream) = tokio::io::duplex(256 * 1024); + + // Start tonic server on the server half (runs on tokio worker threads) + if debug { + eprintln!("[embedded] init: spawning tonic server..."); + } + let server_handle = runtime.spawn(async move { + if is_debug() { + eprintln!("[embedded] server task: starting serve_with_incoming..."); + } + let result = Server::builder() + .add_service(storage_svc) + .add_service(sync_svc) + .add_service(watch_svc) + .serve_with_incoming(tokio_stream::once(Ok::<_, std::io::Error>( + InMemoryStream(server_stream), + ))) + .await; + if is_debug() { + eprintln!("[embedded] server task: serve_with_incoming ended: {result:?}"); + } + }); + + // Split client for concurrent read/write (HTTP/2 is full-duplex) + if debug { + eprintln!("[embedded] init: splitting client DuplexStream..."); + } + let (read_half, write_half) = tokio::io::split(client); + + STATE + .set(EmbeddedState { + runtime, + read_half: Mutex::new(read_half), + write_half: Mutex::new(write_half), + server_abort: server_handle.abort_handle(), + }) + .map_err(|_| "Already initialized".to_string()) +} + +/// Read from the client side of the in-memory gRPC transport. +/// Called by .NET via P/Invoke from a non-tokio thread. +/// Returns bytes read (>0), 0 = EOF, -1 = error. +pub fn pipe_read(buf: &mut [u8]) -> i64 { + let state = match STATE.get() { + Some(s) => s, + None => return -1, + }; + let debug = is_debug(); + if debug { + eprintln!("[embedded] pipe_read: waiting for lock (buf_len={})...", buf.len()); + } + let mut reader = state.read_half.lock().unwrap(); + if debug { + eprintln!("[embedded] pipe_read: got lock, calling block_on..."); + } + state.runtime.block_on(async { + use tokio::io::AsyncReadExt; + match reader.read(buf).await { + Ok(n) => { + if debug { + eprintln!("[embedded] pipe_read: read {n} bytes"); + } + n as i64 + } + Err(e) => { + eprintln!("[embedded] pipe_read: error: {e}"); + -1 + } + } + }) +} + +/// Write to the client side of the in-memory gRPC transport. +/// Called by .NET via P/Invoke from a non-tokio thread. +/// Returns bytes written, -1 = error. +pub fn pipe_write(data: &[u8]) -> i64 { + let state = match STATE.get() { + Some(s) => s, + None => return -1, + }; + let debug = is_debug(); + if debug { + eprintln!( + "[embedded] pipe_write: waiting for lock (data_len={})...", + data.len() + ); + } + let mut writer = state.write_half.lock().unwrap(); + if debug { + eprintln!("[embedded] pipe_write: got lock, calling block_on..."); + } + state.runtime.block_on(async { + use tokio::io::AsyncWriteExt; + match writer.write_all(data).await { + Ok(()) => { + if debug { + eprintln!("[embedded] pipe_write: wrote {} bytes", data.len()); + } + data.len() as i64 + } + Err(e) => { + eprintln!("[embedded] pipe_write: error: {e}"); + -1 + } + } + }) +} + +/// Flush the write side of the transport. +/// Returns 0 on success, -1 on error. +pub fn pipe_flush() -> i32 { + let state = match STATE.get() { + Some(s) => s, + None => return -1, + }; + let mut writer = state.write_half.lock().unwrap(); + state.runtime.block_on(async { + use tokio::io::AsyncWriteExt; + match writer.flush().await { + Ok(()) => 0, + Err(_) => -1, + } + }) +} + +/// Shutdown the embedded gRPC server. +/// Aborts the server task. The runtime and pipe state remain in memory +/// (leaked via OnceLock) but the process is expected to exit shortly after. +pub fn shutdown() { + if let Some(state) = STATE.get() { + state.server_abort.abort(); + } +} diff --git a/crates/docx-storage-local/src/error.rs b/crates/docx-storage-local/src/error.rs new file mode 100644 index 0000000..1549774 --- /dev/null +++ b/crates/docx-storage-local/src/error.rs @@ -0,0 +1,27 @@ +// Re-export from docx-storage-core +pub use docx_storage_core::StorageError; + +/// Convert StorageError to tonic::Status +pub fn storage_error_to_status(err: StorageError) -> tonic::Status { + match err { + StorageError::Io(msg) => tonic::Status::internal(msg), + StorageError::Serialization(msg) => tonic::Status::internal(msg), + StorageError::NotFound(msg) => tonic::Status::not_found(msg), + StorageError::Lock(msg) => tonic::Status::failed_precondition(msg), + StorageError::InvalidArgument(msg) => tonic::Status::invalid_argument(msg), + StorageError::Internal(msg) => tonic::Status::internal(msg), + StorageError::Sync(msg) => tonic::Status::internal(msg), + StorageError::Watch(msg) => tonic::Status::internal(msg), + } +} + +/// Extension trait for converting StorageError Result to tonic::Status Result +pub trait StorageResultExt { + fn map_storage_err(self) -> Result; +} + +impl StorageResultExt for Result { + fn map_storage_err(self) -> Result { + self.map_err(storage_error_to_status) + } +} diff --git a/crates/docx-storage-local/src/lib.rs b/crates/docx-storage-local/src/lib.rs new file mode 100644 index 0000000..296917c --- /dev/null +++ b/crates/docx-storage-local/src/lib.rs @@ -0,0 +1,96 @@ +// Shared modules (used by both the standalone binary and the embedded staticlib) +pub mod browse; +pub mod config; +pub mod error; +pub mod lock; +pub mod service; +pub mod service_sync; +pub mod service_watch; +pub mod storage; +pub mod sync; +pub mod watch; + +// Embedded server support +pub mod embedded; +pub mod server; + +/// File descriptor set for gRPC reflection +pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("storage_descriptor"); + +// ============================================================================= +// C FFI entry points for static linking into NativeAOT binaries +// ============================================================================= + +#[allow(unsafe_code)] +mod ffi { + use std::ffi::CStr; + use std::os::raw::c_char; + use std::path::Path; + + use crate::embedded; + + #[derive(serde::Deserialize)] + struct InitConfig { + local_storage_dir: String, + } + + /// Initialize storage backends and start in-memory gRPC server. + /// config_json: null-terminated UTF-8 JSON, e.g. {"local_storage_dir": "/path"} + /// Returns 0 on success, -1 on error. + #[no_mangle] + pub extern "C" fn docx_storage_init(config_json: *const c_char) -> i32 { + if config_json.is_null() { + return -1; + } + let c_str = unsafe { CStr::from_ptr(config_json) }; + let json_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => return -1, + }; + let config: InitConfig = match serde_json::from_str(json_str) { + Ok(c) => c, + Err(_) => return -1, + }; + match embedded::init(Path::new(&config.local_storage_dir)) { + Ok(()) => 0, + Err(_) => -1, + } + } + + /// Read from the client side of the in-memory gRPC transport. + /// Returns bytes read (>0), 0 = EOF, -1 = error. + #[no_mangle] + pub extern "C" fn docx_pipe_read(buf: *mut u8, max_len: usize) -> i64 { + if buf.is_null() || max_len == 0 { + return -1; + } + let slice = unsafe { std::slice::from_raw_parts_mut(buf, max_len) }; + embedded::pipe_read(slice) + } + + /// Write to the client side of the in-memory gRPC transport. + /// Returns bytes written, -1 = error. + #[no_mangle] + pub extern "C" fn docx_pipe_write(buf: *const u8, len: usize) -> i64 { + if buf.is_null() || len == 0 { + return 0; + } + let slice = unsafe { std::slice::from_raw_parts(buf, len) }; + embedded::pipe_write(slice) + } + + /// Flush the write side of the transport. + /// Returns 0 on success, -1 on error. + #[no_mangle] + pub extern "C" fn docx_pipe_flush() -> i32 { + embedded::pipe_flush() + } + + /// Shutdown the in-memory gRPC server and cleanup. + /// Returns 0 on success. + #[no_mangle] + pub extern "C" fn docx_storage_shutdown() -> i32 { + embedded::shutdown(); + 0 + } +} diff --git a/crates/docx-storage-local/src/lock/file.rs b/crates/docx-storage-local/src/lock/file.rs new file mode 100644 index 0000000..0983d71 --- /dev/null +++ b/crates/docx-storage-local/src/lock/file.rs @@ -0,0 +1,309 @@ +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Duration; + +use async_trait::async_trait; +use docx_storage_core::{LockAcquireResult, LockManager, StorageError}; +use fs2::FileExt; +use tracing::{debug, instrument}; + +/// File-based lock manager using OS-level exclusive file locking. +/// +/// This mimics the C# implementation that uses FileShare.None: +/// - Opens lock file with exclusive access (flock on Unix, LockFile on Windows) +/// - Holds the file handle while lock is held +/// - Closing the handle releases the lock +/// - Process crash automatically releases lock (OS closes file descriptors) +/// +/// Lock files are stored at: +/// `{base_dir}/{tenant_id}/locks/{resource_id}.lock` +#[derive(Debug)] +pub struct FileLock { + base_dir: PathBuf, + /// Active lock handles: (tenant_id, resource_id) -> (holder_id, File) + handles: Mutex>, +} + +impl FileLock { + /// Create a new FileLock with the given base directory. + pub fn new(base_dir: impl AsRef) -> Self { + Self { + base_dir: base_dir.as_ref().to_path_buf(), + handles: Mutex::new(HashMap::new()), + } + } + + /// Get the locks directory for a tenant. + fn locks_dir(&self, tenant_id: &str) -> PathBuf { + self.base_dir.join(tenant_id).join("locks") + } + + /// Get the path to a lock file. + fn lock_path(&self, tenant_id: &str, resource_id: &str) -> PathBuf { + self.locks_dir(tenant_id).join(format!("{}.lock", resource_id)) + } + + /// Ensure the locks directory exists. + fn ensure_locks_dir(&self, tenant_id: &str) -> Result<(), StorageError> { + let dir = self.locks_dir(tenant_id); + std::fs::create_dir_all(&dir).map_err(|e| { + StorageError::Io(format!("Failed to create locks dir {}: {}", dir.display(), e)) + })?; + Ok(()) + } +} + +#[async_trait] +impl LockManager for FileLock { + #[instrument(skip(self), level = "debug")] + async fn acquire( + &self, + tenant_id: &str, + resource_id: &str, + holder_id: &str, + _ttl: Duration, // TTL not needed - OS handles cleanup on process exit + ) -> Result { + self.ensure_locks_dir(tenant_id)?; + let path = self.lock_path(tenant_id, resource_id); + let key = (tenant_id.to_string(), resource_id.to_string()); + + // Check if we already hold this lock + { + let handles = self.handles.lock().unwrap(); + if let Some((existing_holder, _)) = handles.get(&key) { + if existing_holder == holder_id { + debug!( + "Lock on {}/{} already held by {}", + tenant_id, resource_id, holder_id + ); + return Ok(LockAcquireResult::acquired()); + } else { + // Different holder in same process - shouldn't happen but handle it + debug!( + "Lock on {}/{} held by {} (requested by {})", + tenant_id, resource_id, existing_holder, holder_id + ); + return Ok(LockAcquireResult::not_acquired()); + } + } + } + + // Try to open and lock the file + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(|e| StorageError::Io(format!("Failed to open lock file: {}", e)))?; + + // Try non-blocking exclusive lock + match file.try_lock_exclusive() { + Ok(()) => { + // Got the lock - store the handle + let mut handles = self.handles.lock().unwrap(); + handles.insert(key, (holder_id.to_string(), file)); + debug!( + "Acquired lock on {}/{} for {}", + tenant_id, resource_id, holder_id + ); + Ok(LockAcquireResult::acquired()) + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // Lock held by another process + debug!( + "Lock on {}/{} held by another process (requested by {})", + tenant_id, resource_id, holder_id + ); + Ok(LockAcquireResult::not_acquired()) + } + Err(e) => Err(StorageError::Io(format!("Failed to acquire lock: {}", e))), + } + } + + #[instrument(skip(self), level = "debug")] + async fn release( + &self, + tenant_id: &str, + resource_id: &str, + holder_id: &str, + ) -> Result<(), StorageError> { + let key = (tenant_id.to_string(), resource_id.to_string()); + + let mut handles = self.handles.lock().unwrap(); + match handles.entry(key) { + Entry::Occupied(entry) => { + let (existing_holder, _) = entry.get(); + if existing_holder == holder_id { + // Remove and drop the file handle - this releases the lock + let (_, file) = entry.remove(); + // Explicitly unlock before dropping (not strictly necessary but clean) + let _ = fs2::FileExt::unlock(&file); + debug!( + "Released lock on {}/{} by {}", + tenant_id, resource_id, holder_id + ); + } else { + debug!( + "Cannot release lock on {}/{}: held by {} not {}", + tenant_id, resource_id, existing_holder, holder_id + ); + } + } + Entry::Vacant(_) => { + debug!( + "Lock on {}/{} not found for release by {}", + tenant_id, resource_id, holder_id + ); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn setup() -> (FileLock, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let lock = FileLock::new(temp_dir.path()); + (lock, temp_dir) + } + + #[tokio::test] + async fn test_acquire_release() { + let (lock_mgr, _temp) = setup(); + let tenant = "test-tenant"; + let resource = "session-1"; + let holder = "holder-1"; + let ttl = Duration::from_secs(60); + + // Acquire lock + let result = lock_mgr.acquire(tenant, resource, holder, ttl).await.unwrap(); + assert!(result.acquired); + + // Same holder can re-acquire (idempotent) + let result2 = lock_mgr.acquire(tenant, resource, holder, ttl).await.unwrap(); + assert!(result2.acquired); + + // Different holder in same process cannot acquire + let result3 = lock_mgr.acquire(tenant, resource, "holder-2", ttl).await.unwrap(); + assert!(!result3.acquired); + + // Release lock + lock_mgr.release(tenant, resource, holder).await.unwrap(); + + // Now holder-2 can acquire + let result4 = lock_mgr.acquire(tenant, resource, "holder-2", ttl).await.unwrap(); + assert!(result4.acquired); + } + + #[tokio::test] + async fn test_release_not_owner() { + let (lock_mgr, _temp) = setup(); + let tenant = "test-tenant"; + let resource = "session-1"; + let ttl = Duration::from_secs(60); + + // holder-1 acquires + lock_mgr.acquire(tenant, resource, "holder-1", ttl).await.unwrap(); + + // holder-2 tries to release (should be no-op) + lock_mgr.release(tenant, resource, "holder-2").await.unwrap(); + + // Lock should still be held by holder-1 + let result = lock_mgr.acquire(tenant, resource, "holder-1", ttl).await.unwrap(); + assert!(result.acquired); // Can re-acquire (we still hold it) + + // holder-2 still cannot acquire + let result2 = lock_mgr.acquire(tenant, resource, "holder-2", ttl).await.unwrap(); + assert!(!result2.acquired); + } + + #[tokio::test] + async fn test_tenant_isolation() { + let (lock_mgr, _temp) = setup(); + let ttl = Duration::from_secs(60); + + // tenant-a acquires + let result1 = lock_mgr.acquire("tenant-a", "session-1", "holder", ttl).await.unwrap(); + assert!(result1.acquired); + + // tenant-b can acquire same resource name (different tenant) + let result2 = lock_mgr.acquire("tenant-b", "session-1", "holder", ttl).await.unwrap(); + assert!(result2.acquired); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_locking() { + use std::sync::Arc; + use tokio::sync::Barrier; + + let (lock_mgr, _temp) = setup(); + let lock_mgr = Arc::new(lock_mgr); + let tenant = "test-tenant"; + let resource = "shared-resource"; + let ttl = Duration::from_secs(30); + + const NUM_TASKS: usize = 10; + let barrier = Arc::new(Barrier::new(NUM_TASKS)); + let counter = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut handles = vec![]; + + for i in 0..NUM_TASKS { + let lock_mgr = Arc::clone(&lock_mgr); + let barrier = Arc::clone(&barrier); + let counter = Arc::clone(&counter); + let holder_id = format!("holder-{}", i); + + let handle = tokio::spawn(async move { + barrier.wait().await; + + // Try to acquire lock with retries + let mut acquired = false; + for attempt in 0..100 { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(10 + (attempt * 5) as u64)).await; + } + let result = lock_mgr + .acquire(tenant, resource, &holder_id, ttl) + .await + .expect("acquire failed"); + if result.acquired { + acquired = true; + break; + } + } + + assert!(acquired, "Task {} failed to acquire lock", i); + + // Critical section: increment counter + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + // Release lock + lock_mgr + .release(tenant, resource, &holder_id) + .await + .expect("release failed"); + + i + }); + + handles.push(handle); + } + + // Wait for all tasks + for handle in handles { + handle.await.expect("task panicked"); + } + + // All tasks should have completed + assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), NUM_TASKS); + } +} diff --git a/crates/docx-storage-local/src/lock/mod.rs b/crates/docx-storage-local/src/lock/mod.rs new file mode 100644 index 0000000..2bdb909 --- /dev/null +++ b/crates/docx-storage-local/src/lock/mod.rs @@ -0,0 +1,6 @@ +mod file; + +// Re-export from docx-storage-core +pub use docx_storage_core::LockManager; + +pub use file::FileLock; diff --git a/crates/docx-storage-local/src/main.rs b/crates/docx-storage-local/src/main.rs new file mode 100644 index 0000000..51ccaed --- /dev/null +++ b/crates/docx-storage-local/src/main.rs @@ -0,0 +1,237 @@ +use clap::Parser; +use tokio::signal; +use tokio::sync::watch as tokio_watch; +use tonic::transport::Server; +use tonic_reflection::server::Builder as ReflectionBuilder; +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[cfg(unix)] +use tokio::net::UnixListener; + +use docx_storage_local::config::{Config, Transport}; +use docx_storage_local::service::proto::storage_service_server::StorageServiceServer; +use docx_storage_local::service::proto::source_sync_service_server::SourceSyncServiceServer; +use docx_storage_local::service::proto::external_watch_service_server::ExternalWatchServiceServer; +use docx_storage_local::service::StorageServiceImpl; +use docx_storage_local::service_sync::SourceSyncServiceImpl; +use docx_storage_local::service_watch::ExternalWatchServiceImpl; +use docx_storage_local::FILE_DESCRIPTOR_SET; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + let config = Config::parse(); + + info!("Starting docx-storage-local server"); + info!(" Transport: {}", config.transport); + info!(" Backend: {}", config.storage_backend); + if let Some(ppid) = config.parent_pid { + info!(" Parent PID: {} (will exit when parent dies)", ppid); + } + + // Create storage backends via shared helper + let dir = config.effective_local_storage_dir(); + info!(" Local storage dir: {}", dir.display()); + let (storage, lock_manager, sync_backend, watch_backend, browse_backend) = + docx_storage_local::server::create_backends(&dir); + + // Create gRPC services + let storage_svc = StorageServiceServer::new(StorageServiceImpl::new(storage, lock_manager)); + let sync_svc = SourceSyncServiceServer::new(SourceSyncServiceImpl::new(sync_backend, browse_backend)); + let watch_svc = ExternalWatchServiceServer::new(ExternalWatchServiceImpl::new(watch_backend)); + + // Set up parent death signal using OS-native mechanisms + setup_parent_death_signal(config.parent_pid); + + // Create shutdown signal (watches for Ctrl+C and SIGTERM) + // Parent death is handled by OS-native signal delivery (prctl/kqueue) + let mut shutdown_rx = create_shutdown_signal(); + let shutdown_future = async move { + let _ = shutdown_rx.wait_for(|&v| v).await; + }; + + // Create reflection service + let reflection_svc = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1()?; + + // Start server based on transport + match config.transport { + Transport::Tcp => { + let addr = format!("{}:{}", config.host, config.port).parse()?; + info!("Listening on tcp://{}", addr); + + Server::builder() + .add_service(reflection_svc) + .add_service(storage_svc) + .add_service(sync_svc) + .add_service(watch_svc) + .serve_with_shutdown(addr, shutdown_future) + .await?; + } + #[cfg(unix)] + Transport::Unix => { + let socket_path = config.effective_unix_socket(); + + // Remove existing socket file if it exists + if socket_path.exists() { + std::fs::remove_file(&socket_path)?; + } + + // Ensure parent directory exists + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent)?; + } + + info!("Listening on unix://{}", socket_path.display()); + + let uds = UnixListener::bind(&socket_path)?; + let uds_stream = tokio_stream::wrappers::UnixListenerStream::new(uds); + + Server::builder() + .add_service(reflection_svc) + .add_service(storage_svc) + .add_service(sync_svc) + .add_service(watch_svc) + .serve_with_incoming_shutdown(uds_stream, shutdown_future) + .await?; + + // Clean up socket on shutdown + if socket_path.exists() { + let _ = std::fs::remove_file(&socket_path); + } + } + #[cfg(not(unix))] + Transport::Unix => { + anyhow::bail!("Unix socket transport is not supported on Windows. Use TCP instead."); + } + } + + info!("Server shutdown complete"); + Ok(()) +} + +/// Set up parent death monitoring. +/// The parent process (.NET) will kill us on exit via ProcessExit event. +/// This is a fallback safety net that polls for parent death. +fn setup_parent_death_signal(parent_pid: Option) { + let Some(ppid) = parent_pid else { return }; + + #[cfg(target_os = "linux")] + { + // Linux: use prctl for immediate notification + setup_parent_death_signal_linux(ppid); + } + + #[cfg(not(target_os = "linux"))] + { + // macOS/Windows: poll as fallback (parent will kill us on exit) + setup_parent_death_poll(ppid); + } +} + +/// Linux: Use prctl to receive SIGTERM when parent dies. +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +fn setup_parent_death_signal_linux(parent_pid: u32) { + // SAFETY: prctl and kill are well-defined syscalls + unsafe { + // Check if parent is already dead + if libc::kill(parent_pid as i32, 0) != 0 { + info!("Parent process {} already dead at startup, terminating", parent_pid); + std::process::exit(0); + } + + // Set up parent death signal + const PR_SET_PDEATHSIG: libc::c_int = 1; + libc::prctl(PR_SET_PDEATHSIG, libc::SIGTERM); + } + info!("Configured prctl(PR_SET_PDEATHSIG, SIGTERM) for parent {} death notification", parent_pid); +} + +/// Simple polling fallback for parent death detection. +/// The parent (.NET) will kill us via ProcessExit, this is just a safety net. +#[cfg(not(target_os = "linux"))] +#[allow(unsafe_code)] +fn setup_parent_death_poll(parent_pid: u32) { + use std::thread; + use std::time::Duration; + + thread::spawn(move || { + info!("Monitoring parent process {} (poll fallback)", parent_pid); + + loop { + thread::sleep(Duration::from_secs(2)); + + #[cfg(unix)] + let alive = unsafe { libc::kill(parent_pid as i32, 0) == 0 }; + + #[cfg(windows)] + let alive = { + // SYNCHRONIZE = 0x00100000 - we need this to open process for synchronization + const SYNCHRONIZE: u32 = 0x00100000; + let handle = unsafe { + windows_sys::Win32::System::Threading::OpenProcess( + SYNCHRONIZE, + 0, + parent_pid, + ) + }; + if handle != std::ptr::null_mut() { + unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) }; + true + } else { + false + } + }; + + if !alive { + info!("Parent process {} exited, terminating", parent_pid); + std::process::exit(0); + } + } + }); +} + +/// Create a shutdown signal that triggers on Ctrl+C or SIGTERM. +/// Parent death is handled separately via OS-native mechanisms. +fn create_shutdown_signal() -> tokio_watch::Receiver { + let (tx, rx) = tokio_watch::channel(false); + + tokio::spawn(async move { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + info!("Received Ctrl+C, initiating shutdown"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM handler") + .recv() + .await; + info!("Received SIGTERM, initiating shutdown"); + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + + let _ = tx.send(true); + }); + + rx +} diff --git a/crates/docx-storage-local/src/server.rs b/crates/docx-storage-local/src/server.rs new file mode 100644 index 0000000..9ea3a7b --- /dev/null +++ b/crates/docx-storage-local/src/server.rs @@ -0,0 +1,28 @@ +use std::path::Path; +use std::sync::Arc; + +use crate::browse::LocalBrowsableBackend; +use crate::lock::{FileLock, LockManager}; +use crate::storage::{LocalStorage, StorageBackend}; +use crate::sync::LocalFileSyncBackend; +use crate::watch::NotifyWatchBackend; +use docx_storage_core::{BrowsableBackend, SyncBackend, WatchBackend}; + +/// Create all storage backends from a base directory. +/// Shared between the standalone server binary and the embedded staticlib. +pub fn create_backends( + storage_dir: &Path, +) -> ( + Arc, + Arc, + Arc, + Arc, + Arc, +) { + let storage: Arc = Arc::new(LocalStorage::new(storage_dir)); + let lock: Arc = Arc::new(FileLock::new(storage_dir)); + let sync: Arc = Arc::new(LocalFileSyncBackend::new(storage.clone())); + let watch: Arc = Arc::new(NotifyWatchBackend::new()); + let browse: Arc = Arc::new(LocalBrowsableBackend::new()); + (storage, lock, sync, watch, browse) +} diff --git a/crates/docx-storage-local/src/service.rs b/crates/docx-storage-local/src/service.rs new file mode 100644 index 0000000..f3cddb9 --- /dev/null +++ b/crates/docx-storage-local/src/service.rs @@ -0,0 +1,729 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio_stream::{wrappers::ReceiverStream, Stream, StreamExt}; +use tonic::{Request, Response, Status, Streaming}; +use tracing::{debug, instrument}; + +use crate::error::StorageResultExt; +use crate::lock::LockManager; +use crate::storage::StorageBackend; + +// Include the generated protobuf code +pub mod proto { + tonic::include_proto!("docx.storage"); +} + +use proto::storage_service_server::StorageService; +use proto::*; + +/// Default chunk size for streaming: 256KB +const DEFAULT_CHUNK_SIZE: usize = 256 * 1024; + +/// Implementation of the StorageService gRPC service. +pub struct StorageServiceImpl { + storage: Arc, + lock_manager: Arc, + version: String, + chunk_size: usize, +} + +impl StorageServiceImpl { + pub fn new( + storage: Arc, + lock_manager: Arc, + ) -> Self { + Self { + storage, + lock_manager, + version: env!("CARGO_PKG_VERSION").to_string(), + chunk_size: DEFAULT_CHUNK_SIZE, + } + } + + /// Extract tenant_id from request context. + /// Empty string is allowed for backward compatibility with legacy paths. + fn get_tenant_id(context: Option<&TenantContext>) -> Result<&str, Status> { + context + .map(|c| c.tenant_id.as_str()) + .ok_or_else(|| Status::invalid_argument("tenant context is required")) + } +} + +type StreamResult = Pin> + Send>>; + +#[tonic::async_trait] +impl StorageService for StorageServiceImpl { + type LoadSessionStream = StreamResult; + type LoadCheckpointStream = StreamResult; + + // ========================================================================= + // Session Operations (Streaming) + // ========================================================================= + + #[instrument(skip(self, request), level = "debug")] + async fn load_session( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id.clone(); + + let result = self + .storage + .load_session(&tenant_id, &session_id) + .await + .map_storage_err()?; + + let (tx, rx) = mpsc::channel(4); + let chunk_size = self.chunk_size; + + tokio::spawn(async move { + match result { + Some(data) => { + let total_size = data.len() as u64; + let chunks: Vec> = data.chunks(chunk_size).map(|c| c.to_vec()).collect(); + let total_chunks = chunks.len(); + + for (i, chunk) in chunks.into_iter().enumerate() { + let is_first = i == 0; + let is_last = i == total_chunks - 1; + + let msg = DataChunk { + data: chunk, + is_last, + found: is_first, // Only meaningful in first chunk + total_size: if is_first { total_size } else { 0 }, + }; + + if tx.send(Ok(msg)).await.is_err() { + break; // Client disconnected + } + } + } + None => { + // Send a single chunk indicating not found + let _ = tx.send(Ok(DataChunk { + data: vec![], + is_last: true, + found: false, + total_size: 0, + })).await; + } + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + #[instrument(skip(self, request), level = "debug")] + async fn save_session( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + + let mut tenant_id: Option = None; + let mut session_id: Option = None; + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + + // Extract metadata from first chunk + if tenant_id.is_none() { + tenant_id = chunk.context.map(|c| c.tenant_id); + session_id = Some(chunk.session_id); + } + + data.extend(chunk.data); + + if chunk.is_last { + break; + } + } + + let tenant_id = tenant_id + .ok_or_else(|| Status::invalid_argument("tenant context is required in first chunk"))?; + let session_id = session_id + .filter(|s| !s.is_empty()) + .ok_or_else(|| Status::invalid_argument("session_id is required in first chunk"))?; + + debug!("Saving session {} for tenant {} ({} bytes)", session_id, tenant_id, data.len()); + + self.storage + .save_session(&tenant_id, &session_id, &data) + .await + .map_storage_err()?; + + Ok(Response::new(SaveSessionResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_sessions( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let sessions = self + .storage + .list_sessions(tenant_id) + .await + .map_storage_err()?; + + let sessions = sessions + .into_iter() + .map(|s| SessionInfo { + session_id: s.session_id, + source_path: s.source_path.unwrap_or_default(), + created_at_unix: s.created_at.timestamp(), + modified_at_unix: s.modified_at.timestamp(), + size_bytes: s.size_bytes as i64, + }) + .collect(); + + Ok(Response::new(ListSessionsResponse { sessions })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn delete_session( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let existed = self + .storage + .delete_session(tenant_id, &req.session_id) + .await + .map_storage_err()?; + + Ok(Response::new(DeleteSessionResponse { + success: true, + existed, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn session_exists( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let exists = self + .storage + .session_exists(tenant_id, &req.session_id) + .await + .map_storage_err()?; + + // Read pending_external_change from the index + let pending_external_change = if exists { + self.storage + .load_index(tenant_id) + .await + .map_storage_err()? + .and_then(|idx| idx.get(&req.session_id).map(|e| e.pending_external_change)) + .unwrap_or(false) + } else { + false + }; + + Ok(Response::new(SessionExistsResponse { exists, pending_external_change })) + } + + // ========================================================================= + // Index Operations (Atomic - with internal locking) + // ========================================================================= + + #[instrument(skip(self, request), level = "debug")] + async fn load_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let result = self + .storage + .load_index(tenant_id) + .await + .map_storage_err()?; + + let (index_json, found) = match result { + Some(index) => { + let json = serde_json::to_vec(&index) + .map_err(|e| Status::internal(format!("Failed to serialize index: {}", e)))?; + (json, true) + } + None => (vec![], false), + }; + + Ok(Response::new(LoadIndexResponse { index_json, found })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn add_session_to_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + let session_id = req.session_id; + let entry = req.entry.ok_or_else(|| Status::invalid_argument("entry is required"))?; + + // Generate a unique holder ID for this operation + let holder_id = uuid::Uuid::new_v4().to_string(); + let ttl = Duration::from_secs(30); + + // Acquire lock with retries + let mut acquired = false; + for i in 0..10 { + if i > 0 { + tokio::time::sleep(Duration::from_millis(50 * i as u64)).await; + } + let result = self.lock_manager.acquire(tenant_id, "index", &holder_id, ttl).await + .map_storage_err()?; + if result.acquired { + acquired = true; + break; + } + } + + if !acquired { + return Err(Status::unavailable("Could not acquire index lock")); + } + + // Perform atomic operation + let result = async { + let mut index = self.storage.load_index(tenant_id).await + .map_storage_err()? + .unwrap_or_default(); + + let already_exists = index.contains(&session_id); + if !already_exists { + index.upsert(crate::storage::SessionIndexEntry { + id: session_id.clone(), + source_path: if entry.source_path.is_empty() { None } else { Some(entry.source_path) }, + auto_sync: true, // Default to true for new sessions with source path + created_at: chrono::DateTime::from_timestamp(entry.created_at_unix, 0) + .unwrap_or_else(chrono::Utc::now), + last_modified_at: chrono::DateTime::from_timestamp(entry.modified_at_unix, 0) + .unwrap_or_else(chrono::Utc::now), + docx_file: Some(format!("{}.docx", session_id)), + wal_count: entry.wal_position, + cursor_position: entry.wal_position, + checkpoint_positions: entry.checkpoint_positions, + pending_external_change: entry.pending_external_change, + }); + self.storage.save_index(tenant_id, &index).await.map_storage_err()?; + } + + Ok::<_, Status>(already_exists) + }.await; + + // Release lock + let _ = self.lock_manager.release(tenant_id, "index", &holder_id).await; + + let already_exists = result?; + Ok(Response::new(AddSessionToIndexResponse { + success: true, + already_exists, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn update_session_in_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + let session_id = req.session_id; + + // Generate a unique holder ID for this operation + let holder_id = uuid::Uuid::new_v4().to_string(); + let ttl = Duration::from_secs(30); + + // Acquire lock with retries + let mut acquired = false; + for i in 0..10 { + if i > 0 { + tokio::time::sleep(Duration::from_millis(50 * i as u64)).await; + } + let result = self.lock_manager.acquire(tenant_id, "index", &holder_id, ttl).await + .map_storage_err()?; + if result.acquired { + acquired = true; + break; + } + } + + if !acquired { + return Err(Status::unavailable("Could not acquire index lock")); + } + + // Perform atomic operation + let result = async { + let mut index = self.storage.load_index(tenant_id).await + .map_storage_err()? + .unwrap_or_default(); + + let not_found = !index.contains(&session_id); + if !not_found { + let entry = index.get_mut(&session_id).unwrap(); + + // Update optional fields + if let Some(modified_at) = req.modified_at_unix { + entry.last_modified_at = chrono::DateTime::from_timestamp(modified_at, 0) + .unwrap_or_else(chrono::Utc::now); + } + if let Some(wal_position) = req.wal_position { + entry.wal_count = wal_position; + // Only update cursor if not explicitly set + if req.cursor_position.is_none() { + entry.cursor_position = wal_position; + } + } + if let Some(cursor_position) = req.cursor_position { + entry.cursor_position = cursor_position; + } + if let Some(pending) = req.pending_external_change { + entry.pending_external_change = pending; + } + if let Some(ref source_path) = req.source_path { + entry.source_path = if source_path.is_empty() { None } else { Some(source_path.clone()) }; + } + + // Add checkpoint positions + for pos in &req.add_checkpoint_positions { + if !entry.checkpoint_positions.contains(pos) { + entry.checkpoint_positions.push(*pos); + } + } + + // Remove checkpoint positions + entry.checkpoint_positions.retain(|p| !req.remove_checkpoint_positions.contains(p)); + + // Sort checkpoint positions + entry.checkpoint_positions.sort(); + + self.storage.save_index(tenant_id, &index).await.map_storage_err()?; + } + + Ok::<_, Status>(not_found) + }.await; + + // Release lock + let _ = self.lock_manager.release(tenant_id, "index", &holder_id).await; + + let not_found = result?; + Ok(Response::new(UpdateSessionInIndexResponse { + success: !not_found, + not_found, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn remove_session_from_index( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + let session_id = req.session_id; + + // Generate a unique holder ID for this operation + let holder_id = uuid::Uuid::new_v4().to_string(); + let ttl = Duration::from_secs(30); + + // Acquire lock with retries + let mut acquired = false; + for i in 0..10 { + if i > 0 { + tokio::time::sleep(Duration::from_millis(50 * i as u64)).await; + } + let result = self.lock_manager.acquire(tenant_id, "index", &holder_id, ttl).await + .map_storage_err()?; + if result.acquired { + acquired = true; + break; + } + } + + if !acquired { + return Err(Status::unavailable("Could not acquire index lock")); + } + + // Perform atomic operation + let result = async { + let mut index = self.storage.load_index(tenant_id).await + .map_storage_err()? + .unwrap_or_default(); + + let existed = index.remove(&session_id).is_some(); + if existed { + self.storage.save_index(tenant_id, &index).await.map_storage_err()?; + } + + Ok::<_, Status>(existed) + }.await; + + // Release lock + let _ = self.lock_manager.release(tenant_id, "index", &holder_id).await; + + let existed = result?; + Ok(Response::new(RemoveSessionFromIndexResponse { + success: true, + existed, + })) + } + + // ========================================================================= + // WAL Operations + // ========================================================================= + + #[instrument(skip(self, request), level = "debug", fields(entries_count = request.get_ref().entries.len()))] + async fn append_wal( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let entries: Vec = req + .entries + .into_iter() + .map(|e| crate::storage::WalEntry { + position: e.position, + operation: e.operation, + path: e.path, + patch_json: e.patch_json, + timestamp: chrono::DateTime::from_timestamp(e.timestamp_unix, 0) + .unwrap_or_else(chrono::Utc::now), + }) + .collect(); + + let new_position = self + .storage + .append_wal(tenant_id, &req.session_id, &entries) + .await + .map_storage_err()?; + + Ok(Response::new(AppendWalResponse { + success: true, + new_position, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn read_wal( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let limit = if req.limit > 0 { Some(req.limit) } else { None }; + + let (entries, has_more) = self + .storage + .read_wal(tenant_id, &req.session_id, req.from_position, limit) + .await + .map_storage_err()?; + + let entries = entries + .into_iter() + .map(|e| WalEntry { + position: e.position, + operation: e.operation, + path: e.path, + patch_json: e.patch_json, + timestamp_unix: e.timestamp.timestamp(), + }) + .collect(); + + Ok(Response::new(ReadWalResponse { entries, has_more })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn truncate_wal( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let entries_removed = self + .storage + .truncate_wal(tenant_id, &req.session_id, req.keep_from_position) + .await + .map_storage_err()?; + + Ok(Response::new(TruncateWalResponse { + success: true, + entries_removed, + })) + } + + // ========================================================================= + // Checkpoint Operations (Streaming) + // ========================================================================= + + #[instrument(skip(self, request), level = "debug")] + async fn save_checkpoint( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + + let mut tenant_id: Option = None; + let mut session_id: Option = None; + let mut position: u64 = 0; + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + + // Extract metadata from first chunk + if tenant_id.is_none() { + tenant_id = chunk.context.map(|c| c.tenant_id); + session_id = Some(chunk.session_id); + position = chunk.position; + } + + data.extend(chunk.data); + + if chunk.is_last { + break; + } + } + + let tenant_id = tenant_id + .ok_or_else(|| Status::invalid_argument("tenant context is required in first chunk"))?; + let session_id = session_id + .filter(|s| !s.is_empty()) + .ok_or_else(|| Status::invalid_argument("session_id is required in first chunk"))?; + + debug!( + "Saving checkpoint at position {} for session {} tenant {} ({} bytes)", + position, session_id, tenant_id, data.len() + ); + + self.storage + .save_checkpoint(&tenant_id, &session_id, position, &data) + .await + .map_storage_err()?; + + Ok(Response::new(SaveCheckpointResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn load_checkpoint( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_id = req.session_id.clone(); + let position = req.position; + + let result = self + .storage + .load_checkpoint(&tenant_id, &session_id, position) + .await + .map_storage_err()?; + + let (tx, rx) = mpsc::channel(4); + let chunk_size = self.chunk_size; + + tokio::spawn(async move { + match result { + Some((data, actual_position)) => { + let total_size = data.len() as u64; + let chunks: Vec> = data.chunks(chunk_size).map(|c| c.to_vec()).collect(); + let total_chunks = chunks.len(); + + for (i, chunk) in chunks.into_iter().enumerate() { + let is_first = i == 0; + let is_last = i == total_chunks - 1; + + let msg = LoadCheckpointChunk { + data: chunk, + is_last, + found: is_first, // Only meaningful in first chunk + position: if is_first { actual_position } else { 0 }, + total_size: if is_first { total_size } else { 0 }, + }; + + if tx.send(Ok(msg)).await.is_err() { + break; // Client disconnected + } + } + } + None => { + // Send a single chunk indicating not found + let _ = tx.send(Ok(LoadCheckpointChunk { + data: vec![], + is_last: true, + found: false, + position: 0, + total_size: 0, + })).await; + } + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_checkpoints( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let checkpoints = self + .storage + .list_checkpoints(tenant_id, &req.session_id) + .await + .map_storage_err()?; + + let checkpoints = checkpoints + .into_iter() + .map(|c| CheckpointInfo { + position: c.position, + created_at_unix: c.created_at.timestamp(), + size_bytes: c.size_bytes as i64, + }) + .collect(); + + Ok(Response::new(ListCheckpointsResponse { checkpoints })) + } + + // ========================================================================= + // Health Check + // ========================================================================= + + #[instrument(skip(self), level = "debug")] + async fn health_check( + &self, + _request: Request, + ) -> Result, Status> { + debug!("Health check requested"); + Ok(Response::new(HealthCheckResponse { + healthy: true, + backend: self.storage.backend_name().to_string(), + version: self.version.clone(), + })) + } +} diff --git a/crates/docx-storage-local/src/service_sync.rs b/crates/docx-storage-local/src/service_sync.rs new file mode 100644 index 0000000..c8b928a --- /dev/null +++ b/crates/docx-storage-local/src/service_sync.rs @@ -0,0 +1,406 @@ +use std::pin::Pin; +use std::sync::Arc; + +use docx_storage_core::{BrowsableBackend, SourceDescriptor, SourceType, SyncBackend}; +use tokio_stream::{Stream, StreamExt}; +use tonic::{Request, Response, Status, Streaming}; +use tracing::{debug, instrument}; + +use crate::service::proto; +use proto::source_sync_service_server::SourceSyncService; +use proto::*; + +type DownloadStream = Pin> + Send>>; + +/// Implementation of the SourceSyncService gRPC service. +pub struct SourceSyncServiceImpl { + sync_backend: Arc, + browse_backend: Arc, +} + +impl SourceSyncServiceImpl { + pub fn new(sync_backend: Arc, browse_backend: Arc) -> Self { + Self { sync_backend, browse_backend } + } + + /// Extract tenant_id from request context. + fn get_tenant_id(context: Option<&TenantContext>) -> Result<&str, Status> { + context + .map(|c| c.tenant_id.as_str()) + .ok_or_else(|| Status::invalid_argument("tenant context is required")) + } + + /// Convert proto SourceType to core SourceType. + fn convert_source_type(proto_type: i32) -> SourceType { + match proto_type { + 1 => SourceType::LocalFile, + 2 => SourceType::SharePoint, + 3 => SourceType::OneDrive, + 4 => SourceType::S3, + 5 => SourceType::R2, + 6 => SourceType::GoogleDrive, + _ => SourceType::LocalFile, // Default + } + } + + /// Convert proto SourceDescriptor to core SourceDescriptor. + fn convert_source_descriptor(proto: Option<&proto::SourceDescriptor>) -> Option { + proto.map(|s| SourceDescriptor { + source_type: Self::convert_source_type(s.r#type), + connection_id: if s.connection_id.is_empty() { None } else { Some(s.connection_id.clone()) }, + path: s.path.clone(), + file_id: if s.file_id.is_empty() { None } else { Some(s.file_id.clone()) }, + }) + } + + /// Convert core SourceType to proto SourceType. + fn to_proto_source_type(source_type: SourceType) -> i32 { + match source_type { + SourceType::LocalFile => 1, + SourceType::SharePoint => 2, + SourceType::OneDrive => 3, + SourceType::S3 => 4, + SourceType::R2 => 5, + SourceType::GoogleDrive => 6, + } + } + + /// Convert core SourceDescriptor to proto SourceDescriptor. + fn to_proto_source_descriptor(source: &SourceDescriptor) -> proto::SourceDescriptor { + proto::SourceDescriptor { + r#type: Self::to_proto_source_type(source.source_type), + connection_id: source.connection_id.clone().unwrap_or_default(), + path: source.path.clone(), + file_id: source.file_id.clone().unwrap_or_default(), + } + } + + /// Convert core SyncStatus to proto SyncStatus. + fn to_proto_sync_status(status: &docx_storage_core::SyncStatus) -> proto::SyncStatus { + proto::SyncStatus { + session_id: status.session_id.clone(), + source: Some(Self::to_proto_source_descriptor(&status.source)), + auto_sync_enabled: status.auto_sync_enabled, + last_synced_at_unix: status.last_synced_at.unwrap_or(0), + has_pending_changes: status.has_pending_changes, + last_error: status.last_error.clone().unwrap_or_default(), + } + } +} + +#[tonic::async_trait] +impl SourceSyncService for SourceSyncServiceImpl { + type DownloadFromSourceStream = DownloadStream; + + #[instrument(skip(self, request), level = "debug")] + async fn register_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let source = Self::convert_source_descriptor(req.source.as_ref()) + .ok_or_else(|| Status::invalid_argument("source is required"))?; + + match self + .sync_backend + .register_source(tenant_id, &req.session_id, source, req.auto_sync) + .await + { + Ok(()) => { + debug!( + "Registered source for tenant {} session {}", + tenant_id, req.session_id + ); + Ok(Response::new(RegisterSourceResponse { + success: true, + error: String::new(), + })) + } + Err(e) => Ok(Response::new(RegisterSourceResponse { + success: false, + error: e.to_string(), + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn unregister_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + self.sync_backend + .unregister_source(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + debug!( + "Unregistered source for tenant {} session {}", + tenant_id, req.session_id + ); + Ok(Response::new(UnregisterSourceResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn update_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + // Convert optional source + let source = Self::convert_source_descriptor(req.source.as_ref()); + + // Convert optional auto_sync (only if update_auto_sync is true) + let auto_sync = if req.update_auto_sync { + Some(req.auto_sync) + } else { + None + }; + + match self + .sync_backend + .update_source(tenant_id, &req.session_id, source, auto_sync) + .await + { + Ok(()) => { + debug!( + "Updated source for tenant {} session {}", + tenant_id, req.session_id + ); + Ok(Response::new(UpdateSourceResponse { + success: true, + error: String::new(), + })) + } + Err(e) => Ok(Response::new(UpdateSourceResponse { + success: false, + error: e.to_string(), + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn sync_to_source( + &self, + request: Request>, + ) -> Result, Status> { + let mut stream = request.into_inner(); + + let mut tenant_id: Option = None; + let mut session_id: Option = None; + let mut data = Vec::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + + // Extract metadata from first chunk + if tenant_id.is_none() { + tenant_id = chunk.context.map(|c| c.tenant_id); + session_id = Some(chunk.session_id); + } + + data.extend(chunk.data); + + if chunk.is_last { + break; + } + } + + let tenant_id = tenant_id + .ok_or_else(|| Status::invalid_argument("tenant context is required in first chunk"))?; + let session_id = session_id + .filter(|s| !s.is_empty()) + .ok_or_else(|| Status::invalid_argument("session_id is required in first chunk"))?; + + debug!( + "Syncing {} bytes to source for tenant {} session {}", + data.len(), + tenant_id, + session_id + ); + + match self + .sync_backend + .sync_to_source(&tenant_id, &session_id, &data) + .await + { + Ok(synced_at) => Ok(Response::new(SyncToSourceResponse { + success: true, + error: String::new(), + synced_at_unix: synced_at, + })), + Err(e) => Ok(Response::new(SyncToSourceResponse { + success: false, + error: e.to_string(), + synced_at_unix: 0, + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn get_sync_status( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let status = self + .sync_backend + .get_sync_status(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + Ok(Response::new(GetSyncStatusResponse { + registered: status.is_some(), + status: status.map(|s| Self::to_proto_sync_status(&s)), + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_sources( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let sources = self + .sync_backend + .list_sources(tenant_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_sources: Vec = sources + .iter() + .map(Self::to_proto_sync_status) + .collect(); + + Ok(Response::new(ListSourcesResponse { + sources: proto_sources, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_connections( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let connections = self + .browse_backend + .list_connections(tenant_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_connections: Vec = connections + .into_iter() + .map(|c| proto::ConnectionInfo { + connection_id: c.connection_id, + r#type: Self::to_proto_source_type(c.source_type), + display_name: c.display_name, + provider_account_id: c.provider_account_id.unwrap_or_default(), + }) + .collect(); + + Ok(Response::new(ListConnectionsResponse { + connections: proto_connections, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn list_connection_files( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let page_size = if req.page_size > 0 { req.page_size as u32 } else { 50 }; + let page_token = if req.page_token.is_empty() { None } else { Some(req.page_token.as_str()) }; + + let result = self + .browse_backend + .list_files( + tenant_id, + &req.connection_id, + &req.path, + page_token, + page_size, + ) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let proto_files: Vec = result + .files + .into_iter() + .map(|f| proto::FileEntry { + name: f.name, + path: f.path, + file_id: f.file_id.unwrap_or_default(), + is_folder: f.is_folder, + size_bytes: f.size_bytes as i64, + modified_at_unix: f.modified_at, + mime_type: f.mime_type.unwrap_or_default(), + }) + .collect(); + + Ok(Response::new(ListConnectionFilesResponse { + files: proto_files, + next_page_token: result.next_page_token.unwrap_or_default(), + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn download_from_source( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let file_id = if req.file_id.is_empty() { None } else { Some(req.file_id.as_str()) }; + + let data = self + .browse_backend + .download_file(tenant_id, &req.connection_id, &req.path, file_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + // Stream the data in chunks + let chunk_size = 256 * 1024; // 256KB + let total_size = data.len() as u64; + let stream = async_stream::stream! { + let mut offset = 0; + while offset < data.len() { + let end = std::cmp::min(offset + chunk_size, data.len()); + let is_last = end == data.len(); + yield Ok(DataChunk { + data: data[offset..end].to_vec(), + is_last, + found: true, + total_size: if offset == 0 { total_size } else { 0 }, + }); + offset = end; + } + if data.is_empty() { + yield Ok(DataChunk { + data: vec![], + is_last: true, + found: true, + total_size: 0, + }); + } + }; + + Ok(Response::new(Box::pin(stream))) + } +} diff --git a/crates/docx-storage-local/src/service_watch.rs b/crates/docx-storage-local/src/service_watch.rs new file mode 100644 index 0000000..2d581cc --- /dev/null +++ b/crates/docx-storage-local/src/service_watch.rs @@ -0,0 +1,269 @@ +use std::pin::Pin; +use std::sync::Arc; + +use docx_storage_core::{SourceDescriptor, SourceType, WatchBackend}; +use tokio::sync::mpsc; +use tokio_stream::{wrappers::ReceiverStream, Stream}; +use tonic::{Request, Response, Status}; +use tracing::{debug, instrument, warn}; + +use crate::service::proto; +use proto::external_watch_service_server::ExternalWatchService; +use proto::*; + +/// Implementation of the ExternalWatchService gRPC service. +pub struct ExternalWatchServiceImpl { + watch_backend: Arc, +} + +impl ExternalWatchServiceImpl { + pub fn new(watch_backend: Arc) -> Self { + Self { watch_backend } + } + + /// Extract tenant_id from request context. + fn get_tenant_id(context: Option<&TenantContext>) -> Result<&str, Status> { + context + .map(|c| c.tenant_id.as_str()) + .ok_or_else(|| Status::invalid_argument("tenant context is required")) + } + + /// Convert proto SourceType to core SourceType. + fn convert_source_type(proto_type: i32) -> SourceType { + match proto_type { + 1 => SourceType::LocalFile, + 2 => SourceType::SharePoint, + 3 => SourceType::OneDrive, + 4 => SourceType::S3, + 5 => SourceType::R2, + _ => SourceType::LocalFile, // Default + } + } + + /// Convert proto SourceDescriptor to core SourceDescriptor. + fn convert_source_descriptor( + proto: Option<&proto::SourceDescriptor>, + ) -> Option { + proto.map(|s| SourceDescriptor { + source_type: Self::convert_source_type(s.r#type), + connection_id: if s.connection_id.is_empty() { None } else { Some(s.connection_id.clone()) }, + path: s.path.clone(), + file_id: if s.file_id.is_empty() { None } else { Some(s.file_id.clone()) }, + }) + } + + /// Convert core SourceMetadata to proto SourceMetadata. + fn to_proto_source_metadata( + metadata: &docx_storage_core::SourceMetadata, + ) -> proto::SourceMetadata { + proto::SourceMetadata { + size_bytes: metadata.size_bytes as i64, + modified_at_unix: metadata.modified_at, + etag: metadata.etag.clone().unwrap_or_default(), + version_id: metadata.version_id.clone().unwrap_or_default(), + content_hash: metadata.content_hash.clone().unwrap_or_default(), + } + } + + /// Convert core ExternalChangeType to proto ExternalChangeType. + fn to_proto_change_type( + change_type: docx_storage_core::ExternalChangeType, + ) -> i32 { + match change_type { + docx_storage_core::ExternalChangeType::Modified => 1, + docx_storage_core::ExternalChangeType::Deleted => 2, + docx_storage_core::ExternalChangeType::Renamed => 3, + docx_storage_core::ExternalChangeType::PermissionChanged => 4, + } + } +} + +type WatchChangesStream = Pin> + Send>>; + +#[tonic::async_trait] +impl ExternalWatchService for ExternalWatchServiceImpl { + type WatchChangesStream = WatchChangesStream; + + #[instrument(skip(self, request), level = "debug")] + async fn start_watch( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let source = Self::convert_source_descriptor(req.source.as_ref()) + .ok_or_else(|| Status::invalid_argument("source is required"))?; + + match self + .watch_backend + .start_watch(tenant_id, &req.session_id, &source, req.poll_interval_seconds as u32) + .await + { + Ok(watch_id) => { + debug!( + "Started watching for tenant {} session {}: {}", + tenant_id, req.session_id, watch_id + ); + Ok(Response::new(StartWatchResponse { + success: true, + watch_id, + error: String::new(), + })) + } + Err(e) => Ok(Response::new(StartWatchResponse { + success: false, + watch_id: String::new(), + error: e.to_string(), + })), + } + } + + #[instrument(skip(self, request), level = "debug")] + async fn stop_watch( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + self.watch_backend + .stop_watch(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + debug!( + "Stopped watching for tenant {} session {}", + tenant_id, req.session_id + ); + Ok(Response::new(StopWatchResponse { success: true })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn check_for_changes( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + let change = self + .watch_backend + .check_for_changes(tenant_id, &req.session_id) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let (current_metadata, known_metadata) = if change.is_some() { + ( + self.watch_backend + .get_source_metadata(tenant_id, &req.session_id) + .await + .ok() + .flatten() + .map(|m| Self::to_proto_source_metadata(&m)), + self.watch_backend + .get_known_metadata(tenant_id, &req.session_id) + .await + .ok() + .flatten() + .map(|m| Self::to_proto_source_metadata(&m)), + ) + } else { + (None, None) + }; + + Ok(Response::new(CheckForChangesResponse { + has_changes: change.is_some(), + current_metadata, + known_metadata, + })) + } + + #[instrument(skip(self, request), level = "debug")] + async fn watch_changes( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?.to_string(); + let session_ids = req.session_ids; + + let (tx, rx) = mpsc::channel(100); + let watch_backend = self.watch_backend.clone(); + + // Spawn a task that polls for changes + tokio::spawn(async move { + loop { + // Check each session for changes + for session_id in &session_ids { + match watch_backend.check_for_changes(&tenant_id, session_id).await { + Ok(Some(change)) => { + let proto_event = ExternalChangeEvent { + session_id: change.session_id.clone(), + change_type: Self::to_proto_change_type(change.change_type), + old_metadata: change + .old_metadata + .as_ref() + .map(Self::to_proto_source_metadata), + new_metadata: change + .new_metadata + .as_ref() + .map(Self::to_proto_source_metadata), + detected_at_unix: change.detected_at, + new_uri: change.new_uri.clone().unwrap_or_default(), + }; + + if tx.send(Ok(proto_event)).await.is_err() { + // Client disconnected + return; + } + } + Ok(None) => {} + Err(e) => { + warn!( + "Error checking for changes for session {}: {}", + session_id, e + ); + } + } + } + + // Sleep before next poll cycle + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + } + }); + + Ok(Response::new(Box::pin(ReceiverStream::new(rx)))) + } + + #[instrument(skip(self, request), level = "debug")] + async fn get_source_metadata( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let tenant_id = Self::get_tenant_id(req.context.as_ref())?; + + match self + .watch_backend + .get_source_metadata(tenant_id, &req.session_id) + .await + { + Ok(Some(metadata)) => Ok(Response::new(GetSourceMetadataResponse { + success: true, + metadata: Some(Self::to_proto_source_metadata(&metadata)), + error: String::new(), + })), + Ok(None) => Ok(Response::new(GetSourceMetadataResponse { + success: false, + metadata: None, + error: "Source not found".to_string(), + })), + Err(e) => Ok(Response::new(GetSourceMetadataResponse { + success: false, + metadata: None, + error: e.to_string(), + })), + } + } +} diff --git a/crates/docx-storage-local/src/storage/local.rs b/crates/docx-storage-local/src/storage/local.rs new file mode 100644 index 0000000..85c5823 --- /dev/null +++ b/crates/docx-storage-local/src/storage/local.rs @@ -0,0 +1,1153 @@ +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use docx_storage_core::{ + CheckpointInfo, SessionIndex, SessionInfo, StorageBackend, StorageError, WalEntry, +}; +#[cfg(test)] +use docx_storage_core::SessionIndexEntry; +use tokio::fs; +use tracing::{debug, instrument, warn}; + +/// Local filesystem storage backend. +/// +/// Organizes data by tenant: +/// ```text +/// {base_dir}/ +/// {tenant_id}/ +/// sessions/ +/// index.json +/// {session_id}.docx +/// {session_id}.wal +/// {session_id}.ckpt.{position}.docx +/// ``` +#[derive(Debug, Clone)] +pub struct LocalStorage { + base_dir: PathBuf, +} + +/// ZIP file signature (PK\x03\x04) +const ZIP_SIGNATURE: [u8; 4] = [0x50, 0x4B, 0x03, 0x04]; + +/// Length of the header prefix used by .NET's memory-mapped file format. +/// The .NET code writes an 8-byte little-endian length prefix before DOCX data. +const DOTNET_HEADER_LEN: usize = 8; + +impl LocalStorage { + /// Create a new LocalStorage with the given base directory. + pub fn new(base_dir: impl AsRef) -> Self { + Self { + base_dir: base_dir.as_ref().to_path_buf(), + } + } + + /// Strip the .NET header prefix if present. + /// + /// The .NET code writes session/checkpoint files with an 8-byte length prefix + /// (little-endian u64) before the actual DOCX content. This function detects + /// and strips that prefix if present. + /// + /// Detection logic: + /// - If file starts with ZIP signature (PK\x03\x04), return as-is + /// - If bytes 8-11 are ZIP signature, strip first 8 bytes + fn strip_dotnet_header(data: Vec) -> Vec { + // Empty or too small for detection + if data.len() < DOTNET_HEADER_LEN + ZIP_SIGNATURE.len() { + return data; + } + + // Check if file already starts with ZIP signature (no header) + if data[..ZIP_SIGNATURE.len()] == ZIP_SIGNATURE { + return data; + } + + // Check if ZIP signature is at offset 8 (has .NET header prefix) + if data[DOTNET_HEADER_LEN..DOTNET_HEADER_LEN + ZIP_SIGNATURE.len()] == ZIP_SIGNATURE { + debug!("Detected .NET header prefix, stripping {} bytes", DOTNET_HEADER_LEN); + return data[DOTNET_HEADER_LEN..].to_vec(); + } + + // Unknown format, return as-is + data + } + + /// Get the sessions directory for a tenant. + fn sessions_dir(&self, tenant_id: &str) -> PathBuf { + self.base_dir.join(tenant_id).join("sessions") + } + + /// Get the path to a session file. + fn session_path(&self, tenant_id: &str, session_id: &str) -> PathBuf { + self.sessions_dir(tenant_id) + .join(format!("{}.docx", session_id)) + } + + /// Get the path to a session's WAL file. + fn wal_path(&self, tenant_id: &str, session_id: &str) -> PathBuf { + self.sessions_dir(tenant_id) + .join(format!("{}.wal", session_id)) + } + + /// Get the path to a checkpoint file. + fn checkpoint_path(&self, tenant_id: &str, session_id: &str, position: u64) -> PathBuf { + self.sessions_dir(tenant_id) + .join(format!("{}.ckpt.{}.docx", session_id, position)) + } + + /// Get the path to the index file. + fn index_path(&self, tenant_id: &str) -> PathBuf { + self.sessions_dir(tenant_id).join("index.json") + } + + /// Ensure the sessions directory exists. + async fn ensure_sessions_dir(&self, tenant_id: &str) -> Result<(), StorageError> { + let dir = self.sessions_dir(tenant_id); + fs::create_dir_all(&dir).await.map_err(|e| { + StorageError::Io(format!("Failed to create sessions dir {}: {}", dir.display(), e)) + })?; + Ok(()) + } +} + +#[async_trait] +impl StorageBackend for LocalStorage { + fn backend_name(&self) -> &'static str { + "local" + } + + // ========================================================================= + // Session Operations + // ========================================================================= + + #[instrument(skip(self), level = "debug")] + async fn load_session( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result>, StorageError> { + let path = self.session_path(tenant_id, session_id); + match fs::read(&path).await { + Ok(data) => { + let original_len = data.len(); + let data = Self::strip_dotnet_header(data); + debug!( + "Loaded session {} ({} bytes, stripped {} bytes)", + session_id, + data.len(), + original_len - data.len() + ); + Ok(Some(data)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StorageError::Io(format!( + "Failed to read {}: {}", + path.display(), + e + ))), + } + } + + #[instrument(skip(self, data), level = "debug", fields(data_len = data.len()))] + async fn save_session( + &self, + tenant_id: &str, + session_id: &str, + data: &[u8], + ) -> Result<(), StorageError> { + self.ensure_sessions_dir(tenant_id).await?; + let path = self.session_path(tenant_id, session_id); + + // Write atomically via temp file + let temp_path = path.with_extension("docx.tmp"); + fs::write(&temp_path, data).await.map_err(|e| { + StorageError::Io(format!("Failed to write {}: {}", temp_path.display(), e)) + })?; + fs::rename(&temp_path, &path).await.map_err(|e| { + StorageError::Io(format!("Failed to rename to {}: {}", path.display(), e)) + })?; + + debug!("Saved session {} ({} bytes)", session_id, data.len()); + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn delete_session( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result { + let session_path = self.session_path(tenant_id, session_id); + let wal_path = self.wal_path(tenant_id, session_id); + + let existed = session_path.exists(); + + // Delete session file + if let Err(e) = fs::remove_file(&session_path).await { + if e.kind() != std::io::ErrorKind::NotFound { + warn!("Failed to delete session file: {}", e); + } + } + + // Delete WAL + if let Err(e) = fs::remove_file(&wal_path).await { + if e.kind() != std::io::ErrorKind::NotFound { + warn!("Failed to delete WAL file: {}", e); + } + } + + // Delete all checkpoints + let checkpoints = self.list_checkpoints(tenant_id, session_id).await?; + for ckpt in checkpoints { + let ckpt_path = self.checkpoint_path(tenant_id, session_id, ckpt.position); + if let Err(e) = fs::remove_file(&ckpt_path).await { + if e.kind() != std::io::ErrorKind::NotFound { + warn!("Failed to delete checkpoint: {}", e); + } + } + } + + debug!("Deleted session {} (existed: {})", session_id, existed); + Ok(existed) + } + + #[instrument(skip(self), level = "debug")] + async fn list_sessions(&self, tenant_id: &str) -> Result, StorageError> { + let dir = self.sessions_dir(tenant_id); + if !dir.exists() { + return Ok(vec![]); + } + + let mut sessions = Vec::new(); + let mut entries = fs::read_dir(&dir).await.map_err(|e| { + StorageError::Io(format!("Failed to read dir {}: {}", dir.display(), e)) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + StorageError::Io(format!("Failed to read dir entry: {}", e)) + })? { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "docx") + && !path + .file_stem() + .is_some_and(|s| s.to_string_lossy().contains(".ckpt.")) + { + let session_id = path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + let metadata = entry.metadata().await.map_err(|e| { + StorageError::Io(format!("Failed to get metadata: {}", e)) + })?; + + let created_at = metadata + .created() + .map(chrono::DateTime::from) + .unwrap_or_else(|_| chrono::Utc::now()); + let modified_at = metadata + .modified() + .map(chrono::DateTime::from) + .unwrap_or_else(|_| chrono::Utc::now()); + + sessions.push(SessionInfo { + session_id, + source_path: None, // Would need to read from index + created_at, + modified_at, + size_bytes: metadata.len(), + }); + } + } + + debug!("Listed {} sessions for tenant {}", sessions.len(), tenant_id); + Ok(sessions) + } + + #[instrument(skip(self), level = "debug")] + async fn session_exists( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result { + let path = self.session_path(tenant_id, session_id); + Ok(path.exists()) + } + + // ========================================================================= + // Index Operations + // ========================================================================= + + #[instrument(skip(self), level = "debug")] + async fn load_index(&self, tenant_id: &str) -> Result, StorageError> { + let path = self.index_path(tenant_id); + match fs::read_to_string(&path).await { + Ok(json) => { + let index: SessionIndex = serde_json::from_str(&json).map_err(|e| { + StorageError::Serialization(format!("Failed to parse index: {}", e)) + })?; + debug!("Loaded index with {} sessions", index.sessions.len()); + Ok(Some(index)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StorageError::Io(format!( + "Failed to read index {}: {}", + path.display(), + e + ))), + } + } + + #[instrument(skip(self, index), level = "debug", fields(sessions = index.sessions.len()))] + async fn save_index( + &self, + tenant_id: &str, + index: &SessionIndex, + ) -> Result<(), StorageError> { + self.ensure_sessions_dir(tenant_id).await?; + let path = self.index_path(tenant_id); + + let json = serde_json::to_string_pretty(index).map_err(|e| { + StorageError::Serialization(format!("Failed to serialize index: {}", e)) + })?; + + // Write atomically + let temp_path = path.with_extension("json.tmp"); + fs::write(&temp_path, &json).await.map_err(|e| { + StorageError::Io(format!("Failed to write index: {}", e)) + })?; + fs::rename(&temp_path, &path).await.map_err(|e| { + StorageError::Io(format!("Failed to rename index: {}", e)) + })?; + + debug!("Saved index with {} sessions", index.sessions.len()); + Ok(()) + } + + // ========================================================================= + // WAL Operations + // ========================================================================= + + #[instrument(skip(self, entries), level = "debug", fields(entries_count = entries.len()))] + async fn append_wal( + &self, + tenant_id: &str, + session_id: &str, + entries: &[WalEntry], + ) -> Result { + if entries.is_empty() { + return Ok(0); + } + + self.ensure_sessions_dir(tenant_id).await?; + let path = self.wal_path(tenant_id, session_id); + + // .NET MappedWal format: + // - 8 bytes: little-endian i64 = data length (NOT including header) + // - JSONL data: each entry is a JSON line ending with \n + // - Remaining bytes: unused padding (memory-mapped file pre-allocated) + + // Read existing WAL or create new + let mut wal_data = match fs::read(&path).await { + Ok(data) if data.len() >= 8 => { + // Parse header to get data length (NOT including header) + let data_len = i64::from_le_bytes(data[..8].try_into().unwrap()) as usize; + // Total used = header (8) + data_len + let used_len = 8 + data_len; + // Truncate to actual used data + let mut truncated = data; + truncated.truncate(used_len.min(truncated.len())); + truncated + } + Ok(_) | Err(_) => { + // New file - start with 8-byte header (data_len = 0) + vec![0u8; 8] + } + }; + + // Append new entries as JSONL (each line ends with \n) + let mut last_position = 0u64; + for entry in entries { + // Write the raw .NET WalEntry JSON bytes + wal_data.extend_from_slice(&entry.patch_json); + // Ensure line ends with newline + if !entry.patch_json.ends_with(b"\n") { + wal_data.push(b'\n'); + } + last_position = entry.position; + } + + // Update header with data length (excluding header itself) + let data_len = (wal_data.len() - 8) as i64; + wal_data[..8].copy_from_slice(&data_len.to_le_bytes()); + + // Write atomically + let temp_path = path.with_extension("wal.tmp"); + fs::write(&temp_path, &wal_data).await.map_err(|e| { + StorageError::Io(format!("Failed to write WAL: {}", e)) + })?; + fs::rename(&temp_path, &path).await.map_err(|e| { + StorageError::Io(format!("Failed to rename WAL: {}", e)) + })?; + + debug!( + "Appended {} WAL entries, last position: {}, data_len: {}", + entries.len(), + last_position, + data_len + ); + Ok(last_position) + } + + #[instrument(skip(self), level = "debug")] + async fn read_wal( + &self, + tenant_id: &str, + session_id: &str, + from_position: u64, + limit: Option, + ) -> Result<(Vec, bool), StorageError> { + let path = self.wal_path(tenant_id, session_id); + + // Read raw bytes + let raw_data = match fs::read(&path).await { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok((vec![], false)); + } + Err(e) => { + return Err(StorageError::Io(format!( + "Failed to read WAL {}: {}", + path.display(), + e + ))); + } + }; + + // Need at least 8 bytes for header + if raw_data.len() < 8 { + return Ok((vec![], false)); + } + + // .NET MappedWal format: + // - 8 bytes: little-endian i64 = data length (NOT including header) + // - JSONL data: each entry is a JSON line ending with \n + let data_len = i64::from_le_bytes(raw_data[..8].try_into().unwrap()) as usize; + + // Sanity check + if data_len == 0 { + return Ok((vec![], false)); + } + if 8 + data_len > raw_data.len() { + debug!( + "WAL {} has invalid header (data_len={}, file_size={}), using file size", + path.display(), + data_len, + raw_data.len() + ); + } + + // Extract JSONL portion + let end = (8 + data_len).min(raw_data.len()); + let jsonl_data = &raw_data[8..end]; + + // Parse as UTF-8 + let content = std::str::from_utf8(jsonl_data).map_err(|e| { + StorageError::Io(format!("WAL {} is not valid UTF-8: {}", path.display(), e)) + })?; + + // Parse JSONL - each line is a .NET WalEntry JSON + // Position is 1-indexed (line 1 = position 1) + let mut entries = Vec::new(); + let limit = limit.unwrap_or(u64::MAX); + let mut position = 1u64; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if position >= from_position { + // Parse to extract timestamp + let value: serde_json::Value = serde_json::from_str(line).map_err(|e| { + StorageError::Serialization(format!( + "Failed to parse WAL entry at position {}: {}", + position, e + )) + })?; + + let timestamp = value.get("timestamp") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .unwrap_or_else(chrono::Utc::now); + + entries.push(WalEntry { + position, + operation: String::new(), + path: String::new(), + patch_json: line.as_bytes().to_vec(), + timestamp, + }); + + if entries.len() as u64 >= limit { + return Ok((entries, true)); // might have more + } + } + + position += 1; + } + + debug!( + "Read {} WAL entries from position {} (data_len={}, total_entries={})", + entries.len(), + from_position, + data_len, + position - 1 + ); + Ok((entries, false)) + } + + #[instrument(skip(self), level = "debug")] + async fn truncate_wal( + &self, + tenant_id: &str, + session_id: &str, + keep_count: u64, + ) -> Result { + let (entries, _) = self.read_wal(tenant_id, session_id, 0, None).await?; + + // keep_count = number of entries to keep from the beginning + // - keep_count = 0 means "delete all entries" + // - keep_count = 1 means "keep first entry" (position 1) + // - keep_count = N means "keep entries with position <= N" + let (to_keep, to_remove): (Vec<_>, Vec<_>) = + entries.into_iter().partition(|e| e.position <= keep_count); + + let removed_count = to_remove.len() as u64; + + if removed_count == 0 { + return Ok(0); + } + + // Rewrite WAL with only kept entries in .NET JSONL format + // Format: 8-byte header (data length NOT including header) + JSONL data + let path = self.wal_path(tenant_id, session_id); + + let mut wal_data = vec![0u8; 8]; // Header placeholder + + for entry in &to_keep { + // Write raw patch_json bytes (the .NET WalEntry JSON) + wal_data.extend_from_slice(&entry.patch_json); + // Ensure line ends with newline + if !entry.patch_json.ends_with(b"\n") { + wal_data.push(b'\n'); + } + } + + // Update header with data length (excluding header itself) + let data_len = (wal_data.len() - 8) as i64; + wal_data[..8].copy_from_slice(&data_len.to_le_bytes()); + + // Write atomically + let temp_path = path.with_extension("wal.tmp"); + fs::write(&temp_path, &wal_data).await.map_err(|e| { + StorageError::Io(format!("Failed to write WAL: {}", e)) + })?; + fs::rename(&temp_path, &path).await.map_err(|e| { + StorageError::Io(format!("Failed to rename WAL: {}", e)) + })?; + + debug!("Truncated WAL, removed {} entries, kept {}", removed_count, to_keep.len()); + Ok(removed_count) + } + + // ========================================================================= + // Checkpoint Operations + // ========================================================================= + + #[instrument(skip(self, data), level = "debug", fields(data_len = data.len()))] + async fn save_checkpoint( + &self, + tenant_id: &str, + session_id: &str, + position: u64, + data: &[u8], + ) -> Result<(), StorageError> { + self.ensure_sessions_dir(tenant_id).await?; + let path = self.checkpoint_path(tenant_id, session_id, position); + + // Write atomically + let temp_path = path.with_extension("docx.tmp"); + fs::write(&temp_path, data).await.map_err(|e| { + StorageError::Io(format!("Failed to write checkpoint: {}", e)) + })?; + fs::rename(&temp_path, &path).await.map_err(|e| { + StorageError::Io(format!("Failed to rename checkpoint: {}", e)) + })?; + + debug!( + "Saved checkpoint at position {} ({} bytes)", + position, + data.len() + ); + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn load_checkpoint( + &self, + tenant_id: &str, + session_id: &str, + position: u64, + ) -> Result, u64)>, StorageError> { + if position == 0 { + // Load latest checkpoint + let checkpoints = self.list_checkpoints(tenant_id, session_id).await?; + if let Some(latest) = checkpoints.last() { + let path = self.checkpoint_path(tenant_id, session_id, latest.position); + let data = fs::read(&path).await.map_err(|e| { + StorageError::Io(format!("Failed to read checkpoint: {}", e)) + })?; + let original_len = data.len(); + let data = Self::strip_dotnet_header(data); + debug!( + "Loaded latest checkpoint at position {} ({} bytes, stripped {} bytes)", + latest.position, + data.len(), + original_len - data.len() + ); + return Ok(Some((data, latest.position))); + } + return Ok(None); + } + + let path = self.checkpoint_path(tenant_id, session_id, position); + match fs::read(&path).await { + Ok(data) => { + let original_len = data.len(); + let data = Self::strip_dotnet_header(data); + debug!( + "Loaded checkpoint at position {} ({} bytes, stripped {} bytes)", + position, + data.len(), + original_len - data.len() + ); + Ok(Some((data, position))) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StorageError::Io(format!( + "Failed to read checkpoint: {}", + e + ))), + } + } + + #[instrument(skip(self), level = "debug")] + async fn list_checkpoints( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let dir = self.sessions_dir(tenant_id); + if !dir.exists() { + return Ok(vec![]); + } + + let prefix = format!("{}.ckpt.", session_id); + let mut checkpoints = Vec::new(); + + let mut entries = fs::read_dir(&dir).await.map_err(|e| { + StorageError::Io(format!("Failed to read dir: {}", e)) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + StorageError::Io(format!("Failed to read dir entry: {}", e)) + })? { + let path = entry.path(); + let file_name = path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + if file_name.starts_with(&prefix) && file_name.ends_with(".docx") { + // Extract position from filename: {session_id}.ckpt.{position}.docx + let position_str = file_name + .strip_prefix(&prefix) + .and_then(|s| s.strip_suffix(".docx")) + .unwrap_or("0"); + + if let Ok(position) = position_str.parse::() { + let metadata = entry.metadata().await.map_err(|e| { + StorageError::Io(format!("Failed to get metadata: {}", e)) + })?; + + checkpoints.push(CheckpointInfo { + position, + created_at: metadata + .created() + .map(chrono::DateTime::from) + .unwrap_or_else(|_| chrono::Utc::now()), + size_bytes: metadata.len(), + }); + } + } + } + + // Sort by position + checkpoints.sort_by_key(|c| c.position); + + debug!( + "Listed {} checkpoints for session {}", + checkpoints.len(), + session_id + ); + Ok(checkpoints) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + async fn setup() -> (LocalStorage, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let storage = LocalStorage::new(temp_dir.path()); + (storage, temp_dir) + } + + #[tokio::test] + async fn test_session_crud() { + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let data = b"PK\x03\x04fake docx content"; + + // Initially doesn't exist + assert!(!storage.session_exists(tenant, session).await.unwrap()); + assert!(storage.load_session(tenant, session).await.unwrap().is_none()); + + // Save + storage.save_session(tenant, session, data).await.unwrap(); + + // Now exists + assert!(storage.session_exists(tenant, session).await.unwrap()); + + // Load + let loaded = storage.load_session(tenant, session).await.unwrap().unwrap(); + assert_eq!(loaded, data); + + // List + let sessions = storage.list_sessions(tenant).await.unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, session); + + // Delete + let existed = storage.delete_session(tenant, session).await.unwrap(); + assert!(existed); + assert!(!storage.session_exists(tenant, session).await.unwrap()); + } + + #[tokio::test] + async fn test_wal_operations() { + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + + let entries = vec![ + WalEntry { + position: 1, + operation: "add".to_string(), + path: "/body/paragraph[0]".to_string(), + patch_json: b"{}".to_vec(), + timestamp: chrono::Utc::now(), + }, + WalEntry { + position: 2, + operation: "replace".to_string(), + path: "/body/paragraph[0]/run[0]".to_string(), + patch_json: b"{}".to_vec(), + timestamp: chrono::Utc::now(), + }, + ]; + + // Append + let last_pos = storage.append_wal(tenant, session, &entries).await.unwrap(); + assert_eq!(last_pos, 2); + + // Read all + let (read_entries, has_more) = storage.read_wal(tenant, session, 0, None).await.unwrap(); + assert_eq!(read_entries.len(), 2); + assert!(!has_more); + + // Read from position + let (read_entries, _) = storage.read_wal(tenant, session, 2, None).await.unwrap(); + assert_eq!(read_entries.len(), 1); + assert_eq!(read_entries[0].position, 2); + + // Truncate - keep first 1 entry (position <= 1), remove entry at position 2 + let removed = storage.truncate_wal(tenant, session, 1).await.unwrap(); + assert_eq!(removed, 1); + + let (read_entries, _) = storage.read_wal(tenant, session, 0, None).await.unwrap(); + assert_eq!(read_entries.len(), 1); + assert_eq!(read_entries[0].position, 1); + } + + #[tokio::test] + async fn test_checkpoint_operations() { + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let data = b"checkpoint data"; + + // Save checkpoints + storage.save_checkpoint(tenant, session, 10, data).await.unwrap(); + storage.save_checkpoint(tenant, session, 20, data).await.unwrap(); + + // List + let checkpoints = storage.list_checkpoints(tenant, session).await.unwrap(); + assert_eq!(checkpoints.len(), 2); + assert_eq!(checkpoints[0].position, 10); + assert_eq!(checkpoints[1].position, 20); + + // Load specific + let (loaded, pos) = storage.load_checkpoint(tenant, session, 10).await.unwrap().unwrap(); + assert_eq!(loaded, data); + assert_eq!(pos, 10); + + // Load latest (position = 0) + let (_, pos) = storage.load_checkpoint(tenant, session, 0).await.unwrap().unwrap(); + assert_eq!(pos, 20); + } + + #[tokio::test] + async fn test_tenant_isolation() { + let (storage, _temp) = setup().await; + let data = b"test data"; + + // Save to tenant A + storage.save_session("tenant-a", "session-1", data).await.unwrap(); + + // Tenant B shouldn't see it + assert!(!storage.session_exists("tenant-b", "session-1").await.unwrap()); + assert!(storage.list_sessions("tenant-b").await.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_index_save_load() { + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + + // Initially no index + let loaded = storage.load_index(tenant).await.unwrap(); + assert!(loaded.is_none()); + + // Create and save index with sessions + let mut index = SessionIndex::default(); + index.upsert(SessionIndexEntry { + id: "session-1".to_string(), + source_path: Some("/path/to/doc.docx".to_string()), + auto_sync: true, + created_at: chrono::Utc::now(), + last_modified_at: chrono::Utc::now(), + docx_file: Some("session-1.docx".to_string()), + wal_count: 5, + cursor_position: 5, + checkpoint_positions: vec![], + pending_external_change: false, + }); + + storage.save_index(tenant, &index).await.unwrap(); + + // Load and verify + let loaded = storage.load_index(tenant).await.unwrap().unwrap(); + assert_eq!(loaded.sessions.len(), 1); + assert!(loaded.contains("session-1")); + assert_eq!(loaded.get("session-1").unwrap().wal_count, 5); + } + + #[tokio::test] + async fn test_index_concurrent_updates_sequential() { + // Test that sequential index updates work correctly + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + + // Simulate 10 sequential session creations + for i in 0..10 { + // Load current index + let mut index = storage.load_index(tenant).await.unwrap().unwrap_or_default(); + + // Add a session + let session_id = format!("session-{}", i); + index.upsert(SessionIndexEntry { + id: session_id, + source_path: None, + auto_sync: false, + created_at: chrono::Utc::now(), + last_modified_at: chrono::Utc::now(), + docx_file: None, + wal_count: 0, + cursor_position: 0, + checkpoint_positions: vec![], + pending_external_change: false, + }); + + // Save + storage.save_index(tenant, &index).await.unwrap(); + } + + // Verify all 10 sessions are in the index + let final_index = storage.load_index(tenant).await.unwrap().unwrap(); + assert_eq!(final_index.sessions.len(), 10); + for i in 0..10 { + assert!( + final_index.contains(&format!("session-{}", i)), + "Missing session-{}", i + ); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_index_concurrent_updates_with_locking() { + use crate::lock::{FileLock, LockManager}; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::Barrier; + + // Test concurrent index updates WITH locking (production pattern) + let (storage, temp) = setup().await; + let storage = Arc::new(storage); + let lock_manager = Arc::new(FileLock::new(temp.path())); + let tenant = "test-tenant"; + + const NUM_TASKS: usize = 10; + let barrier = Arc::new(Barrier::new(NUM_TASKS)); + let mut handles = vec![]; + + // Spawn tasks, each adding a session WITH proper locking + for i in 0..NUM_TASKS { + let storage = Arc::clone(&storage); + let lock_manager = Arc::clone(&lock_manager); + let barrier = Arc::clone(&barrier); + let session_id = format!("session-{}", i); + let holder_id = format!("holder-{}", i); + + let handle = tokio::spawn(async move { + // Wait for all tasks to be ready + barrier.wait().await; + + // Acquire lock with retries (same pattern as service.rs) + let ttl = Duration::from_secs(30); + let mut acquired = false; + for attempt in 0..100 { + if attempt > 0 { + // Exponential backoff with jitter + let delay = Duration::from_millis(10 + (attempt * 10) as u64); + tokio::time::sleep(delay).await; + } + let result = lock_manager + .acquire(tenant, "index", &holder_id, ttl) + .await + .expect("Lock acquire should not fail"); + if result.acquired { + acquired = true; + break; + } + } + + if !acquired { + panic!("Task {} failed to acquire lock after 100 attempts", i); + } + + // Load current index + let mut index = storage + .load_index(tenant) + .await + .expect("Load index failed") + .unwrap_or_default(); + + // Add a session + index.upsert(SessionIndexEntry { + id: session_id.clone(), + source_path: None, + auto_sync: false, + created_at: chrono::Utc::now(), + last_modified_at: chrono::Utc::now(), + docx_file: None, + wal_count: 0, + cursor_position: 0, + checkpoint_positions: vec![], + pending_external_change: false, + }); + + // Save - ensure this completes before releasing lock + storage + .save_index(tenant, &index) + .await + .expect("Save index failed"); + + // Release lock + lock_manager + .release(tenant, "index", &holder_id) + .await + .expect("Release lock failed"); + + session_id + }); + + handles.push(handle); + } + + // Collect all session IDs + let mut created_ids = vec![]; + for handle in handles { + let id = handle.await.expect("Task panicked"); + created_ids.push(id); + } + + // With proper locking, ALL sessions should be present + let final_index = storage.load_index(tenant).await.unwrap().unwrap(); + let found_count = final_index.sessions.len(); + + assert_eq!( + found_count, NUM_TASKS, + "All {} sessions should be in index with proper locking. Found: {}. Missing: {:?}", + NUM_TASKS, + found_count, + created_ids + .iter() + .filter(|id| !final_index.contains(id)) + .collect::>() + ); + } + + #[tokio::test] + async fn test_load_dotnet_index_format() { + // Test loading the actual .NET index format + let index_json = r#"{ + "version": 1, + "sessions": [ + { + "id": "a5fea612f066", + "source_path": "/Users/laurentvaldes/Documents/lettre de motivation.docx", + "created_at": "2026-02-03T21:16:37.29544Z", + "last_modified_at": "2026-02-04T17:37:38.4257Z", + "docx_file": "a5fea612f066.docx", + "wal_count": 26, + "cursor_position": 26, + "checkpoint_positions": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + } + ] +}"#; + + let index: SessionIndex = serde_json::from_str(index_json).expect("Failed to parse index"); + + assert_eq!(index.version, 1); + assert_eq!(index.sessions.len(), 1); + + let session = index.get("a5fea612f066").expect("Session not found"); + assert_eq!(session.id, "a5fea612f066"); + assert_eq!(session.source_path, Some("/Users/laurentvaldes/Documents/lettre de motivation.docx".to_string())); + assert_eq!(session.docx_file, Some("a5fea612f066.docx".to_string())); + assert_eq!(session.wal_count, 26); + assert_eq!(session.cursor_position, 26); + assert_eq!(session.checkpoint_positions.len(), 10); + } + + #[test] + fn test_strip_dotnet_header_with_prefix() { + // Simulate .NET format: 8-byte length prefix + DOCX data + // The first 8 bytes are a little-endian u64 length + let mut data = vec![0x87, 0x95, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 8-byte header + data.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // PK signature + data.extend_from_slice(b"rest of docx content"); + + let result = LocalStorage::strip_dotnet_header(data); + + // Should strip the 8-byte header + assert_eq!(result[0..4], [0x50, 0x4B, 0x03, 0x04]); + assert_eq!(result.len(), 4 + 20); // PK + "rest of docx content" + } + + #[test] + fn test_strip_dotnet_header_without_prefix() { + // Raw DOCX file (no header) - starts directly with PK + let mut data = vec![0x50, 0x4B, 0x03, 0x04]; // PK signature + data.extend_from_slice(b"rest of docx content"); + + let result = LocalStorage::strip_dotnet_header(data.clone()); + + // Should return unchanged + assert_eq!(result, data); + } + + #[test] + fn test_strip_dotnet_header_empty() { + let data = vec![]; + let result = LocalStorage::strip_dotnet_header(data); + assert!(result.is_empty()); + } + + #[test] + fn test_strip_dotnet_header_too_small() { + // Too small to have header + valid DOCX + let data = vec![0x01, 0x02, 0x03]; + let result = LocalStorage::strip_dotnet_header(data.clone()); + assert_eq!(result, data); + } + + #[test] + fn test_strip_dotnet_header_unknown_format() { + // Unknown format - doesn't start with PK and no PK at offset 8 + let data = vec![0x00; 20]; + let result = LocalStorage::strip_dotnet_header(data.clone()); + assert_eq!(result, data); + } + + #[tokio::test] + async fn test_load_session_with_dotnet_header() { + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + + // Write a file with .NET header prefix + storage.ensure_sessions_dir(tenant).await.unwrap(); + let path = storage.session_path(tenant, session); + + let mut data_with_header = vec![0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 8-byte header + data_with_header.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // PK signature + data_with_header.extend_from_slice(b"docx content"); + + fs::write(&path, &data_with_header).await.unwrap(); + + // Load should strip the header + let loaded = storage.load_session(tenant, session).await.unwrap().unwrap(); + assert_eq!(&loaded[0..4], &[0x50, 0x4B, 0x03, 0x04]); + assert_eq!(loaded.len(), 4 + 12); // PK + "docx content" + } + + #[tokio::test] + async fn test_load_checkpoint_with_dotnet_header() { + let (storage, _temp) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + + // Write a checkpoint with .NET header prefix + storage.ensure_sessions_dir(tenant).await.unwrap(); + let path = storage.checkpoint_path(tenant, session, 10); + + let mut data_with_header = vec![0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 8-byte header + data_with_header.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); // PK signature + data_with_header.extend_from_slice(b"checkpoint data"); + + fs::write(&path, &data_with_header).await.unwrap(); + + // Load should strip the header + let (loaded, pos) = storage.load_checkpoint(tenant, session, 10).await.unwrap().unwrap(); + assert_eq!(pos, 10); + assert_eq!(&loaded[0..4], &[0x50, 0x4B, 0x03, 0x04]); + assert_eq!(loaded.len(), 4 + 15); // PK + "checkpoint data" + } +} diff --git a/crates/docx-storage-local/src/storage/mod.rs b/crates/docx-storage-local/src/storage/mod.rs new file mode 100644 index 0000000..e125d3d --- /dev/null +++ b/crates/docx-storage-local/src/storage/mod.rs @@ -0,0 +1,6 @@ +mod local; + +// Re-export from docx-storage-core +pub use docx_storage_core::{SessionIndexEntry, StorageBackend, WalEntry}; + +pub use local::LocalStorage; diff --git a/crates/docx-storage-local/src/sync/local_file.rs b/crates/docx-storage-local/src/sync/local_file.rs new file mode 100644 index 0000000..9600000 --- /dev/null +++ b/crates/docx-storage-local/src/sync/local_file.rs @@ -0,0 +1,702 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use docx_storage_core::{ + SourceDescriptor, SourceType, StorageBackend, StorageError, SyncBackend, SyncStatus, +}; +use tokio::fs; +use tracing::{debug, instrument, warn}; + +/// Transient sync state (not persisted - only in memory during server lifetime) +#[derive(Debug, Clone, Default)] +struct TransientSyncState { + last_synced_at: Option, + has_pending_changes: bool, + last_error: Option, +} + +/// Local file sync backend. +/// +/// Handles syncing session data to local files (the original auto-save behavior). +/// Source path and auto_sync are persisted in the session index (index.json). +/// Transient state (last_synced_at, pending_changes, errors) is kept in memory. +pub struct LocalFileSyncBackend { + /// Storage backend for reading/writing session index + storage: Arc, + /// Transient state: (tenant_id, session_id) -> TransientSyncState + transient: DashMap<(String, String), TransientSyncState>, +} + +impl std::fmt::Debug for LocalFileSyncBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalFileSyncBackend") + .field("transient", &self.transient) + .finish_non_exhaustive() + } +} + +impl LocalFileSyncBackend { + /// Create a new LocalFileSyncBackend with a storage backend. + pub fn new(storage: Arc) -> Self { + Self { + storage, + transient: DashMap::new(), + } + } + + /// Get the key for the transient state map. + fn key(tenant_id: &str, session_id: &str) -> (String, String) { + (tenant_id.to_string(), session_id.to_string()) + } + + /// Get the file path from a source descriptor. + #[allow(dead_code)] + fn get_file_path(source: &SourceDescriptor) -> Result { + if source.source_type != SourceType::LocalFile { + return Err(StorageError::Sync(format!( + "LocalFileSyncBackend only supports LocalFile sources, got {:?}", + source.source_type + ))); + } + Ok(PathBuf::from(&source.path)) + } +} + +#[async_trait] +impl SyncBackend for LocalFileSyncBackend { + #[instrument(skip(self), level = "debug")] + async fn register_source( + &self, + tenant_id: &str, + session_id: &str, + source: SourceDescriptor, + auto_sync: bool, + ) -> Result<(), StorageError> { + // Validate source type + if source.source_type != SourceType::LocalFile { + return Err(StorageError::Sync(format!( + "LocalFileSyncBackend only supports LocalFile sources, got {:?}", + source.source_type + ))); + } + + // Load index, update entry, save index + let mut index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + + let now = chrono::Utc::now(); + if let Some(entry) = index.get_mut(session_id) { + entry.source_path = Some(source.path.clone()); + entry.auto_sync = auto_sync; + entry.last_modified_at = now; + } else { + // Create a minimal index entry if absent (dual-server mode: + // AddSessionToIndex may have gone to the remote history server) + use docx_storage_core::SessionIndexEntry; + let entry = SessionIndexEntry { + id: session_id.to_string(), + source_path: Some(source.path.clone()), + auto_sync, + created_at: now, + last_modified_at: now, + docx_file: None, + wal_count: 0, + cursor_position: 0, + checkpoint_positions: vec![], + pending_external_change: false, + }; + index.sessions.push(entry); + } + + self.storage.save_index(tenant_id, &index).await?; + + // Initialize transient state + let key = Self::key(tenant_id, session_id); + self.transient.insert(key, TransientSyncState::default()); + + debug!( + "Registered source for tenant {} session {} -> {} (auto_sync={})", + tenant_id, session_id, source.path, auto_sync + ); + + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn unregister_source( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result<(), StorageError> { + // Load index, clear source_path, save index + let mut index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + + if let Some(entry) = index.get_mut(session_id) { + entry.source_path = None; + entry.auto_sync = false; + entry.last_modified_at = chrono::Utc::now(); + self.storage.save_index(tenant_id, &index).await?; + + debug!( + "Unregistered source for tenant {} session {}", + tenant_id, session_id + ); + } + + // Clear transient state + let key = Self::key(tenant_id, session_id); + self.transient.remove(&key); + + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn update_source( + &self, + tenant_id: &str, + session_id: &str, + source: Option, + auto_sync: Option, + ) -> Result<(), StorageError> { + // Load index + let mut index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + + let entry = index.get_mut(session_id).ok_or_else(|| { + StorageError::Sync(format!( + "Session {} not found in index for tenant {}", + session_id, tenant_id + )) + })?; + + // Check if source is registered + if entry.source_path.is_none() { + return Err(StorageError::Sync(format!( + "No source registered for tenant {} session {}", + tenant_id, session_id + ))); + } + + // Update source if provided + if let Some(new_source) = source { + if new_source.source_type != SourceType::LocalFile { + return Err(StorageError::Sync(format!( + "LocalFileSyncBackend only supports LocalFile sources, got {:?}", + new_source.source_type + ))); + } + debug!( + "Updating source path for tenant {} session {}: {:?} -> {}", + tenant_id, session_id, entry.source_path, new_source.path + ); + entry.source_path = Some(new_source.path); + } + + // Update auto_sync if provided + if let Some(new_auto_sync) = auto_sync { + debug!( + "Updating auto_sync for tenant {} session {}: {} -> {}", + tenant_id, session_id, entry.auto_sync, new_auto_sync + ); + entry.auto_sync = new_auto_sync; + } + + entry.last_modified_at = chrono::Utc::now(); + self.storage.save_index(tenant_id, &index).await?; + + Ok(()) + } + + #[instrument(skip(self, data), level = "debug", fields(data_len = data.len()))] + async fn sync_to_source( + &self, + tenant_id: &str, + session_id: &str, + data: &[u8], + ) -> Result { + // Get source path from index + let index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + + let entry = index.get(session_id).ok_or_else(|| { + StorageError::Sync(format!( + "Session {} not found in index for tenant {}", + session_id, tenant_id + )) + })?; + + let source_path = entry.source_path.as_ref().ok_or_else(|| { + StorageError::Sync(format!( + "No source registered for tenant {} session {}", + tenant_id, session_id + )) + })?; + + let file_path = PathBuf::from(source_path); + + // Ensure parent directory exists + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).await.map_err(|e| { + StorageError::Sync(format!( + "Failed to create parent directory for {}: {}", + file_path.display(), + e + )) + })?; + } + + // Write atomically via temp file + let temp_path = file_path.with_extension("docx.sync.tmp"); + fs::write(&temp_path, data).await.map_err(|e| { + StorageError::Sync(format!( + "Failed to write temp file {}: {}", + temp_path.display(), + e + )) + })?; + + fs::rename(&temp_path, &file_path).await.map_err(|e| { + StorageError::Sync(format!( + "Failed to rename temp file to {}: {}", + file_path.display(), + e + )) + })?; + + let synced_at = chrono::Utc::now().timestamp(); + + // Update transient state + let key = Self::key(tenant_id, session_id); + self.transient + .entry(key) + .or_default() + .last_synced_at = Some(synced_at); + if let Some(mut state) = self.transient.get_mut(&Self::key(tenant_id, session_id)) { + state.has_pending_changes = false; + state.last_error = None; + } + + debug!( + "Synced {} bytes to {} for tenant {} session {}", + data.len(), + file_path.display(), + tenant_id, + session_id + ); + + Ok(synced_at) + } + + #[instrument(skip(self), level = "debug")] + async fn get_sync_status( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + // Get source info from index + let index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + + let entry = match index.get(session_id) { + Some(e) => e, + None => return Ok(None), + }; + + let source_path = match &entry.source_path { + Some(p) => p, + None => return Ok(None), + }; + + // Get transient state + let key = Self::key(tenant_id, session_id); + let transient = self.transient.get(&key); + + Ok(Some(SyncStatus { + session_id: session_id.to_string(), + source: SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: source_path.clone(), + file_id: None, + }, + auto_sync_enabled: entry.auto_sync, + last_synced_at: transient.as_ref().and_then(|t| t.last_synced_at), + has_pending_changes: transient.as_ref().map(|t| t.has_pending_changes).unwrap_or(false), + last_error: transient.as_ref().and_then(|t| t.last_error.clone()), + })) + } + + #[instrument(skip(self), level = "debug")] + async fn list_sources(&self, tenant_id: &str) -> Result, StorageError> { + let index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + let mut results = Vec::new(); + + for entry in &index.sessions { + if let Some(source_path) = &entry.source_path { + let key = Self::key(tenant_id, &entry.id); + let transient = self.transient.get(&key); + + results.push(SyncStatus { + session_id: entry.id.clone(), + source: SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: source_path.clone(), + file_id: None, + }, + auto_sync_enabled: entry.auto_sync, + last_synced_at: transient.as_ref().and_then(|t| t.last_synced_at), + has_pending_changes: transient.as_ref().map(|t| t.has_pending_changes).unwrap_or(false), + last_error: transient.as_ref().and_then(|t| t.last_error.clone()), + }); + } + } + + debug!( + "Listed {} sources for tenant {}", + results.len(), + tenant_id + ); + Ok(results) + } + + #[instrument(skip(self), level = "debug")] + async fn is_auto_sync_enabled( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result { + let index = self.storage.load_index(tenant_id).await?.unwrap_or_default(); + + Ok(index + .get(session_id) + .map(|e| e.source_path.is_some() && e.auto_sync) + .unwrap_or(false)) + } +} + +/// Mark a session as having pending changes (for auto-sync tracking). +impl LocalFileSyncBackend { + #[allow(dead_code)] + pub fn mark_pending_changes(&self, tenant_id: &str, session_id: &str) { + let key = Self::key(tenant_id, session_id); + self.transient + .entry(key) + .or_default() + .has_pending_changes = true; + } + + #[allow(dead_code)] + pub fn record_sync_error(&self, tenant_id: &str, session_id: &str, error: &str) { + let key = Self::key(tenant_id, session_id); + if let Some(mut state) = self.transient.get_mut(&key) { + state.last_error = Some(error.to_string()); + warn!( + "Sync error for tenant {} session {}: {}", + tenant_id, session_id, error + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::LocalStorage; + use tempfile::TempDir; + + async fn setup() -> (LocalFileSyncBackend, TempDir, TempDir) { + let storage_dir = TempDir::new().unwrap(); + let output_dir = TempDir::new().unwrap(); + let storage = Arc::new(LocalStorage::new(storage_dir.path())); + let backend = LocalFileSyncBackend::new(storage); + (backend, storage_dir, output_dir) + } + + async fn create_session(backend: &LocalFileSyncBackend, tenant: &str, session: &str) { + // Create a session in the index + let mut index = backend.storage.load_index(tenant).await.unwrap().unwrap_or_default(); + index.upsert(docx_storage_core::SessionIndexEntry { + id: session.to_string(), + source_path: None, + auto_sync: false, + created_at: chrono::Utc::now(), + last_modified_at: chrono::Utc::now(), + docx_file: None, + wal_count: 0, + cursor_position: 0, + checkpoint_positions: vec![], + pending_external_change: false, + }); + backend.storage.save_index(tenant, &index).await.unwrap(); + } + + #[tokio::test] + async fn test_register_unregister() { + let (backend, _storage_dir, output_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = output_dir.path().join("output.docx"); + + // Create session first + create_session(&backend, tenant, session).await; + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + // Register + backend + .register_source(tenant, session, source, true) + .await + .unwrap(); + + // Check status + let status = backend.get_sync_status(tenant, session).await.unwrap(); + assert!(status.is_some()); + let status = status.unwrap(); + assert!(status.auto_sync_enabled); + assert!(status.last_synced_at.is_none()); + + // Unregister + backend.unregister_source(tenant, session).await.unwrap(); + + // Check status again + let status = backend.get_sync_status(tenant, session).await.unwrap(); + assert!(status.is_none()); + } + + #[tokio::test] + async fn test_sync_to_source() { + let (backend, _storage_dir, output_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = output_dir.path().join("output.docx"); + + // Create session first + create_session(&backend, tenant, session).await; + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + backend + .register_source(tenant, session, source, true) + .await + .unwrap(); + + // Sync data + let data = b"PK\x03\x04fake docx content"; + let synced_at = backend.sync_to_source(tenant, session, data).await.unwrap(); + assert!(synced_at > 0); + + // Verify file was written + let content = tokio::fs::read(&file_path).await.unwrap(); + assert_eq!(content, data); + + // Check status + let status = backend + .get_sync_status(tenant, session) + .await + .unwrap() + .unwrap(); + assert_eq!(status.last_synced_at, Some(synced_at)); + assert!(!status.has_pending_changes); + } + + #[tokio::test] + async fn test_list_sources() { + let (backend, _storage_dir, output_dir) = setup().await; + let tenant = "test-tenant"; + + // Register multiple sources + for i in 0..3 { + let session = format!("session-{}", i); + create_session(&backend, tenant, &session).await; + + let file_path = output_dir.path().join(format!("output-{}.docx", i)); + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + backend + .register_source(tenant, &session, source, i % 2 == 0) + .await + .unwrap(); + } + + // List sources + let sources = backend.list_sources(tenant).await.unwrap(); + assert_eq!(sources.len(), 3); + + // Different tenant should have empty list + let other_sources = backend.list_sources("other-tenant").await.unwrap(); + assert!(other_sources.is_empty()); + } + + #[tokio::test] + async fn test_pending_changes() { + let (backend, _storage_dir, output_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = output_dir.path().join("output.docx"); + + // Create session first + create_session(&backend, tenant, session).await; + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + backend + .register_source(tenant, session, source, true) + .await + .unwrap(); + + // Initially no pending changes + let status = backend + .get_sync_status(tenant, session) + .await + .unwrap() + .unwrap(); + assert!(!status.has_pending_changes); + + // Mark pending + backend.mark_pending_changes(tenant, session); + + // Now has pending changes + let status = backend + .get_sync_status(tenant, session) + .await + .unwrap() + .unwrap(); + assert!(status.has_pending_changes); + + // Sync clears pending + let data = b"test"; + backend.sync_to_source(tenant, session, data).await.unwrap(); + + let status = backend + .get_sync_status(tenant, session) + .await + .unwrap() + .unwrap(); + assert!(!status.has_pending_changes); + } + + #[tokio::test] + async fn test_invalid_source_type() { + let (backend, _storage_dir, _output_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + + // Create session first + create_session(&backend, tenant, session).await; + + let source = SourceDescriptor { + source_type: SourceType::S3, + connection_id: None, + path: "s3://bucket/key".to_string(), + file_id: None, + }; + + let result = backend.register_source(tenant, session, source, true).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("LocalFile")); + } + + #[tokio::test] + async fn test_update_source() { + let (backend, _storage_dir, output_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = output_dir.path().join("output.docx"); + let new_file_path = output_dir.path().join("new-output.docx"); + + // Create session first + create_session(&backend, tenant, session).await; + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + // Register source + backend + .register_source(tenant, session, source, true) + .await + .unwrap(); + + // Verify initial state + let status = backend.get_sync_status(tenant, session).await.unwrap().unwrap(); + assert_eq!(status.source.path, file_path.to_string_lossy()); + assert!(status.auto_sync_enabled); + + // Update only auto_sync + backend + .update_source(tenant, session, None, Some(false)) + .await + .unwrap(); + + let status = backend.get_sync_status(tenant, session).await.unwrap().unwrap(); + assert_eq!(status.source.path, file_path.to_string_lossy()); + assert!(!status.auto_sync_enabled); + + // Update source path + let new_source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: new_file_path.to_string_lossy().to_string(), + file_id: None, + }; + backend + .update_source(tenant, session, Some(new_source), None) + .await + .unwrap(); + + let status = backend.get_sync_status(tenant, session).await.unwrap().unwrap(); + assert_eq!(status.source.path, new_file_path.to_string_lossy()); + assert!(!status.auto_sync_enabled); // Should remain unchanged + + // Update both + let final_source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + backend + .update_source(tenant, session, Some(final_source), Some(true)) + .await + .unwrap(); + + let status = backend.get_sync_status(tenant, session).await.unwrap().unwrap(); + assert_eq!(status.source.path, file_path.to_string_lossy()); + assert!(status.auto_sync_enabled); + } + + #[tokio::test] + async fn test_update_source_not_registered() { + let (backend, _storage_dir, _output_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + + // Create session but don't register source + create_session(&backend, tenant, session).await; + + let result = backend.update_source(tenant, session, None, Some(true)).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No source registered")); + } +} diff --git a/crates/docx-storage-local/src/sync/mod.rs b/crates/docx-storage-local/src/sync/mod.rs new file mode 100644 index 0000000..ba32b16 --- /dev/null +++ b/crates/docx-storage-local/src/sync/mod.rs @@ -0,0 +1,3 @@ +mod local_file; + +pub use local_file::LocalFileSyncBackend; diff --git a/crates/docx-storage-local/src/watch/mod.rs b/crates/docx-storage-local/src/watch/mod.rs new file mode 100644 index 0000000..7a839c1 --- /dev/null +++ b/crates/docx-storage-local/src/watch/mod.rs @@ -0,0 +1,3 @@ +mod notify_watcher; + +pub use notify_watcher::NotifyWatchBackend; diff --git a/crates/docx-storage-local/src/watch/notify_watcher.rs b/crates/docx-storage-local/src/watch/notify_watcher.rs new file mode 100644 index 0000000..c36cec8 --- /dev/null +++ b/crates/docx-storage-local/src/watch/notify_watcher.rs @@ -0,0 +1,566 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use docx_storage_core::{ + ExternalChangeEvent, ExternalChangeType, SourceDescriptor, SourceMetadata, SourceType, + StorageError, WatchBackend, +}; +use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use sha2::{Digest, Sha256}; +use tokio::sync::mpsc; +use tracing::{debug, info, instrument, warn}; + +/// State for a watched source +#[derive(Debug, Clone)] +struct WatchedSource { + source: SourceDescriptor, + #[allow(dead_code)] + watch_id: String, + known_metadata: Option, +} + +/// Local file watch backend using the `notify` crate. +/// +/// Uses filesystem events (inotify on Linux, FSEvents on macOS, etc.) +/// to detect when external sources are modified. +pub struct NotifyWatchBackend { + /// Watched sources: (tenant_id, session_id) -> WatchedSource + sources: DashMap<(String, String), WatchedSource>, + /// Pending change events: (tenant_id, session_id) -> ExternalChangeEvent + pending_changes: DashMap<(String, String), ExternalChangeEvent>, + /// Sender for change events (used by the watcher thread) + event_sender: mpsc::Sender<(String, String, Event)>, + /// Keep watcher alive (it stops when dropped) + _watcher: Arc>>, +} + +impl NotifyWatchBackend { + /// Create a new NotifyWatchBackend. + pub fn new() -> Self { + let (tx, mut rx) = mpsc::channel::<(String, String, Event)>(1000); + let pending_changes: DashMap<(String, String), ExternalChangeEvent> = DashMap::new(); + let sources: DashMap<(String, String), WatchedSource> = DashMap::new(); + + let pending_changes_clone = pending_changes.clone(); + let sources_clone = sources.clone(); + + // Spawn a task to process events from the watcher + tokio::spawn(async move { + while let Some((tenant_id, session_id, event)) = rx.recv().await { + let key = (tenant_id.clone(), session_id.clone()); + + // Determine change type from event kind + let change_type = match event.kind { + EventKind::Modify(_) => ExternalChangeType::Modified, + EventKind::Remove(_) => ExternalChangeType::Deleted, + EventKind::Create(_) => ExternalChangeType::Modified, // Treat create as modify for simplicity + _ => continue, // Ignore other events + }; + + // Get known metadata if we have it + let old_metadata = sources_clone + .get(&key) + .and_then(|w| w.known_metadata.clone()); + + // Try to get new metadata + let new_metadata = if let Some(source) = sources_clone.get(&key) { + Self::get_metadata_sync(&source.source).ok() + } else { + None + }; + + let change_event = ExternalChangeEvent { + session_id: session_id.clone(), + change_type, + old_metadata, + new_metadata, + detected_at: chrono::Utc::now().timestamp(), + new_uri: None, + }; + + pending_changes_clone.insert(key, change_event); + debug!( + "Detected {} change for tenant {} session {}", + match change_type { + ExternalChangeType::Modified => "modified", + ExternalChangeType::Deleted => "deleted", + ExternalChangeType::Renamed => "renamed", + ExternalChangeType::PermissionChanged => "permission", + }, + tenant_id, + session_id + ); + } + }); + + Self { + sources, + pending_changes, + event_sender: tx, + _watcher: Arc::new(std::sync::Mutex::new(None)), + } + } + + /// Get the key for the sources map. + fn key(tenant_id: &str, session_id: &str) -> (String, String) { + (tenant_id.to_string(), session_id.to_string()) + } + + /// Get the file path from a source descriptor. + fn get_file_path(source: &SourceDescriptor) -> Result { + if source.source_type != SourceType::LocalFile { + return Err(StorageError::Watch(format!( + "NotifyWatchBackend only supports LocalFile sources, got {:?}", + source.source_type + ))); + } + Ok(PathBuf::from(&source.path)) + } + + /// Get file metadata synchronously (for use in sync context). + /// Computes SHA256 hash of file content for accurate change detection, + /// matching the C# ExternalChangeTracker behavior. + fn get_metadata_sync(source: &SourceDescriptor) -> Result { + let path = Self::get_file_path(source)?; + + // Read file to compute hash (like C# ExternalChangeTracker) + let content = std::fs::read(&path).map_err(|e| { + StorageError::Watch(format!( + "Failed to read file {}: {}", + path.display(), + e + )) + })?; + + let metadata = std::fs::metadata(&path).map_err(|e| { + StorageError::Watch(format!( + "Failed to get metadata for {}: {}", + path.display(), + e + )) + })?; + + // Compute SHA256 hash (same as C# ComputeFileHash) + let content_hash = { + let mut hasher = Sha256::new(); + hasher.update(&content); + hasher.finalize().to_vec() + }; + + Ok(SourceMetadata { + size_bytes: metadata.len(), + modified_at: metadata + .modified() + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }) + .unwrap_or(0), + etag: None, + version_id: None, + content_hash: Some(content_hash), + }) + } +} + +impl Default for NotifyWatchBackend { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl WatchBackend for NotifyWatchBackend { + #[instrument(skip(self), level = "debug")] + async fn start_watch( + &self, + tenant_id: &str, + session_id: &str, + source: &SourceDescriptor, + _poll_interval_secs: u32, + ) -> Result { + // Validate source type + if source.source_type != SourceType::LocalFile { + return Err(StorageError::Watch(format!( + "NotifyWatchBackend only supports LocalFile sources, got {:?}", + source.source_type + ))); + } + + let path = Self::get_file_path(source)?; + let watch_id = uuid::Uuid::new_v4().to_string(); + let key = Self::key(tenant_id, session_id); + + // Get initial metadata + let known_metadata = Self::get_metadata_sync(source).ok(); + + // Set up notify watcher for this file + let tenant_id_clone = tenant_id.to_string(); + let session_id_clone = session_id.to_string(); + let tx = self.event_sender.clone(); + let path_clone = path.clone(); + + let watcher_result = RecommendedWatcher::new( + move |res: Result| { + match res { + Ok(event) => { + // Only process events for our file + if event.paths.iter().any(|p| p == &path_clone) { + let _ = tx.blocking_send(( + tenant_id_clone.clone(), + session_id_clone.clone(), + event, + )); + } + } + Err(e) => { + warn!("Watch error: {}", e); + } + } + }, + Config::default(), + ); + + let mut watcher = match watcher_result { + Ok(w) => w, + Err(e) => { + return Err(StorageError::Watch(format!( + "Failed to create watcher: {}", + e + ))); + } + }; + + // Watch the file's parent directory (file watchers need the dir) + let watch_path = path.parent().unwrap_or(&path); + watcher + .watch(watch_path, RecursiveMode::NonRecursive) + .map_err(|e| { + StorageError::Watch(format!( + "Failed to watch {}: {}", + watch_path.display(), + e + )) + })?; + + // Store the watcher (need to keep it alive) + { + let mut guard = self._watcher.lock().unwrap(); + *guard = Some(watcher); + } + + // Store the watch info + self.sources.insert( + key, + WatchedSource { + source: source.clone(), + watch_id: watch_id.clone(), + known_metadata, + }, + ); + + info!( + "Started watching {} for tenant {} session {}", + path.display(), + tenant_id, + session_id + ); + + Ok(watch_id) + } + + #[instrument(skip(self), level = "debug")] + async fn stop_watch(&self, tenant_id: &str, session_id: &str) -> Result<(), StorageError> { + let key = Self::key(tenant_id, session_id); + + if let Some((_, watched)) = self.sources.remove(&key) { + info!( + "Stopped watching {} for tenant {} session {}", + watched.source.path, tenant_id, session_id + ); + } + + // Also remove any pending changes + self.pending_changes.remove(&key); + + Ok(()) + } + + #[instrument(skip(self), level = "debug")] + async fn check_for_changes( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + // Check for pending changes detected by the watcher + if let Some((_, event)) = self.pending_changes.remove(&key) { + return Ok(Some(event)); + } + + // If no pending changes, do a manual check by comparing content hash + // (like C# ExternalChangeTracker which uses SHA256 hash comparison) + if let Some(watched) = self.sources.get(&key) { + if let (Some(known), Ok(current)) = ( + &watched.known_metadata, + Self::get_metadata_sync(&watched.source), + ) { + // Check if file content hash changed (matching C# behavior) + let hash_changed = match (&known.content_hash, ¤t.content_hash) { + (Some(old_hash), Some(new_hash)) => old_hash != new_hash, + // If we don't have hashes, fall back to size/mtime comparison + _ => current.modified_at != known.modified_at || current.size_bytes != known.size_bytes, + }; + + if hash_changed { + debug!( + "Content hash changed for tenant {} session {} (hash-based detection)", + tenant_id, session_id + ); + return Ok(Some(ExternalChangeEvent { + session_id: session_id.to_string(), + change_type: ExternalChangeType::Modified, + old_metadata: Some(known.clone()), + new_metadata: Some(current), + detected_at: chrono::Utc::now().timestamp(), + new_uri: None, + })); + } + } + } + + Ok(None) + } + + #[instrument(skip(self), level = "debug")] + async fn get_source_metadata( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + let source = match self.sources.get(&key) { + Some(watched) => watched.source.clone(), + None => return Ok(None), + }; + + let path = Self::get_file_path(&source)?; + + // Check if file exists + if !path.exists() { + return Ok(None); + } + + let metadata = Self::get_metadata_sync(&source)?; + Ok(Some(metadata)) + } + + #[instrument(skip(self), level = "debug")] + async fn get_known_metadata( + &self, + tenant_id: &str, + session_id: &str, + ) -> Result, StorageError> { + let key = Self::key(tenant_id, session_id); + + Ok(self + .sources + .get(&key) + .and_then(|w| w.known_metadata.clone())) + } + + #[instrument(skip(self, metadata), level = "debug")] + async fn update_known_metadata( + &self, + tenant_id: &str, + session_id: &str, + metadata: SourceMetadata, + ) -> Result<(), StorageError> { + let key = Self::key(tenant_id, session_id); + + if let Some(mut watched) = self.sources.get_mut(&key) { + watched.known_metadata = Some(metadata); + debug!( + "Updated known metadata for tenant {} session {}", + tenant_id, session_id + ); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + use tokio::time::{sleep, Duration}; + + async fn setup() -> (NotifyWatchBackend, TempDir) { + let temp_dir = TempDir::new().unwrap(); + let backend = NotifyWatchBackend::new(); + (backend, temp_dir) + } + + #[tokio::test] + async fn test_start_stop_watch() { + let (backend, temp_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = temp_dir.path().join("watched.docx"); + + // Create the file first + std::fs::write(&file_path, b"initial content").unwrap(); + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + // Start watch + let watch_id = backend.start_watch(tenant, session, &source, 0).await.unwrap(); + assert!(!watch_id.is_empty()); + + // Get known metadata + let known = backend.get_known_metadata(tenant, session).await.unwrap(); + assert!(known.is_some()); + + // Stop watch + backend.stop_watch(tenant, session).await.unwrap(); + + // Known metadata should be gone + let known = backend.get_known_metadata(tenant, session).await.unwrap(); + assert!(known.is_none()); + } + + #[tokio::test] + async fn test_detect_modification() { + let (backend, temp_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = temp_dir.path().join("watched.docx"); + + // Create the file first + std::fs::write(&file_path, b"initial content").unwrap(); + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + backend.start_watch(tenant, session, &source, 0).await.unwrap(); + + // Wait a bit for the watcher to settle + sleep(Duration::from_millis(100)).await; + + // Modify the file + std::fs::write(&file_path, b"modified content").unwrap(); + + // Wait for the event to be processed + sleep(Duration::from_millis(500)).await; + + // Check for changes (may detect via manual check if event wasn't captured) + let change = backend.check_for_changes(tenant, session).await.unwrap(); + + // Note: notify events are async and may not always be captured in tests + // The manual check should still detect the modification + if change.is_some() { + let change = change.unwrap(); + assert_eq!(change.change_type, ExternalChangeType::Modified); + } + } + + #[tokio::test] + async fn test_get_source_metadata() { + let (backend, temp_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = temp_dir.path().join("watched.docx"); + + // Create a file with known content + let content = b"test content for metadata"; + std::fs::write(&file_path, content).unwrap(); + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + backend.start_watch(tenant, session, &source, 0).await.unwrap(); + + // Get metadata + let metadata = backend.get_source_metadata(tenant, session).await.unwrap(); + assert!(metadata.is_some()); + let metadata = metadata.unwrap(); + assert_eq!(metadata.size_bytes, content.len() as u64); + } + + #[tokio::test] + async fn test_update_known_metadata() { + let (backend, temp_dir) = setup().await; + let tenant = "test-tenant"; + let session = "test-session"; + let file_path = temp_dir.path().join("watched.docx"); + + std::fs::write(&file_path, b"content").unwrap(); + + let source = SourceDescriptor { + source_type: SourceType::LocalFile, + connection_id: None, + path: file_path.to_string_lossy().to_string(), + file_id: None, + }; + + backend.start_watch(tenant, session, &source, 0).await.unwrap(); + + // Update known metadata + let new_metadata = SourceMetadata { + size_bytes: 12345, + modified_at: 99999, + etag: Some("test-etag".to_string()), + version_id: None, + content_hash: None, + }; + + backend + .update_known_metadata(tenant, session, new_metadata.clone()) + .await + .unwrap(); + + // Verify it was updated + let known = backend.get_known_metadata(tenant, session).await.unwrap(); + assert!(known.is_some()); + let known = known.unwrap(); + assert_eq!(known.size_bytes, 12345); + assert_eq!(known.etag, Some("test-etag".to_string())); + } + + #[tokio::test] + async fn test_invalid_source_type() { + let backend = NotifyWatchBackend::new(); + let tenant = "test-tenant"; + let session = "test-session"; + + let source = SourceDescriptor { + source_type: SourceType::S3, + connection_id: None, + path: "s3://bucket/key".to_string(), + file_id: None, + }; + + let result = backend.start_watch(tenant, session, &source, 0).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("LocalFile")); + } +} diff --git a/crates/docx-storage-local/tests/fixtures/index.json b/crates/docx-storage-local/tests/fixtures/index.json new file mode 100644 index 0000000..3637b04 --- /dev/null +++ b/crates/docx-storage-local/tests/fixtures/index.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "sessions": [ + { + "id": "a5fea612f066", + "source_path": "/Users/laurentvaldes/Documents/lettre de motivation.docx", + "created_at": "2026-02-03T21:16:37.29544Z", + "last_modified_at": "2026-02-04T17:37:38.4257Z", + "docx_file": "a5fea612f066.docx", + "wal_count": 26, + "cursor_position": 26, + "checkpoint_positions": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 24, + 26 + ] + } + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..94888a9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,99 @@ +# ============================================================================= +# docx-mcp — Full Stack Docker Compose +# +# All services start together: R2 storage + Google Drive sync + MCP server + proxy +# +# Usage: +# source infra/env-setup.sh && docker compose up -d # Start all +# source infra/env-setup.sh && docker compose up -d --build # Rebuild + start +# +# Environment (source infra/env-setup.sh before docker compose): +# # Cloudflare (R2 storage + proxy auth) +# CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, D1_DATABASE_ID +# R2_BUCKET_NAME, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY +# +# # Google Drive OAuth (tokens stored per-tenant in D1) +# OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET +# WATCH_POLL_INTERVAL (default: 60) +# ============================================================================= + +services: + # ─── R2 Storage (StorageService gRPC) ────────────────────────────────────── + storage: + build: { context: ., dockerfile: Dockerfile.storage-cloudflare } + environment: + RUST_LOG: "info,docx_storage_cloudflare=debug" + GRPC_HOST: "0.0.0.0" + GRPC_PORT: "50051" + CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID} + R2_BUCKET_NAME: ${R2_BUCKET_NAME} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY} + ports: + - "50051:50051" + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "50051"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ─── Google Drive (SourceSyncService + ExternalWatchService gRPC) ────────── + gdrive: + build: { context: ., dockerfile: Dockerfile.gdrive } + environment: + RUST_LOG: info + GRPC_HOST: "0.0.0.0" + GRPC_PORT: "50052" + CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID} + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} + D1_DATABASE_ID: ${D1_DATABASE_ID} + GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET} + WATCH_POLL_INTERVAL: ${WATCH_POLL_INTERVAL:-60} + ports: + - "50052:50052" + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "50052"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ─── MCP Server (.NET NativeAOT, dual-server: R2 history + gdrive sync) ─── + mcp-http: + build: { context: ., dockerfile: Dockerfile } + depends_on: + storage: { condition: service_healthy } + gdrive: { condition: service_healthy } + environment: + MCP_TRANSPORT: http + ASPNETCORE_URLS: http://+:3000 + STORAGE_GRPC_URL: http://storage:50051 + SYNC_GRPC_URL: http://gdrive:50052 + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:3000/health"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ─── Auth Proxy (PAT auth via D1 + HTTP reverse proxy) ──────────────────── + proxy: + build: { context: ., dockerfile: Dockerfile.proxy } + depends_on: + mcp-http: { condition: service_healthy } + environment: + RUST_LOG: "info,docx_mcp_sse_proxy=debug" + MCP_BACKEND_URL: http://mcp-http:3000 + CLOUDFLARE_ACCOUNT_ID: ${CLOUDFLARE_ACCOUNT_ID} + CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} + D1_DATABASE_ID: ${D1_DATABASE_ID} + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + restart: unless-stopped diff --git a/infra/Pulumi.prod.yaml b/infra/Pulumi.prod.yaml new file mode 100644 index 0000000..160b4cd --- /dev/null +++ b/infra/Pulumi.prod.yaml @@ -0,0 +1,12 @@ +config: + cloudflare:apiToken: + secure: v1:SANxxQaABmwBQKiI:YW/qI+07tKg9ct3XatdCx+Hsv/WWuUkwD99IQ2ddmMYqq/v+Ab0xbzz23bFYe3KzpBHOOcAi1G0= + docx-mcp-infra:accountId: 13314480b397e4e53f0569e01e636e14 + gcp:project: lv-project-313715 + docx-mcp-infra:oauthGoogleClientId: + secure: v1:raISJAbWen+CDHu2:HBGYHiNELaQlHdbb5K/6aoF6SFmn3RMMYD8Be8uFJOoxYwbzE44SEBh5F9EnNHwvZPTLieePHmALZuo2RZdZKxSZT2MPDVjswb0yLvyTtAZQtrSum/Tl9g== + docx-mcp-infra:oauthGoogleClientSecret: + secure: v1:AdxEHoLR7izIRfgL:A2+4U4BpwqBOIU8CjKiC4a6kohiYG94paviCDVv8FC1m6GN9sm8VNsm86jVfE/OHysD1 + docx-mcp-infra:koyebToken: + secure: v1:ca3dUjXmIPmfDe31:KXWDX4kWyxHxa97IiFsHoEdl0k+NZHUikd5w3diP9TLwj7BbSsN80SssDWgh29kM3nBzTNtyN5aUFbhK5lIjBNmZeyflAlo5CBaz6xcW978= +encryptionsalt: v1:4RqIT+yhimY=:v1:T/0KFvwDzDKZZjPT:RQTr9KQMSSjUI08lQHZV4W98CA93mw== diff --git a/infra/Pulumi.yaml b/infra/Pulumi.yaml new file mode 100644 index 0000000..e1e3df5 --- /dev/null +++ b/infra/Pulumi.yaml @@ -0,0 +1,6 @@ +name: docx-mcp-infra +runtime: + name: python + options: + virtualenv: venv +description: Cloudflare infrastructure for docx-mcp (R2, KV, D1) diff --git a/infra/__main__.py b/infra/__main__.py new file mode 100644 index 0000000..561e838 --- /dev/null +++ b/infra/__main__.py @@ -0,0 +1,392 @@ +"""Cloudflare + GCP infrastructure for docx-mcp.""" + +import hashlib +import json + +import pulumi +import pulumi_cloudflare as cloudflare +import pulumi_gcp as gcp + +config = pulumi.Config() +account_id = config.require("accountId") +gcp_project = config.get("gcpProject") or "lv-project-313715" + +# ============================================================================= +# R2 — Document storage (DOCX baselines, WAL, checkpoints) +# ============================================================================= + +storage_bucket = cloudflare.R2Bucket( + "docx-storage", + account_id=account_id, + name="docx-mcp-storage", + location="WEUR", +) + +# ============================================================================= +# R2 API Token — S3-compatible access for docx-storage-cloudflare +# Access Key ID = token.id, Secret Access Key = SHA-256(token.value) +# ============================================================================= + +r2_write_perms = cloudflare.get_api_token_permission_groups_list( + name="Workers R2 Storage Write", + scope="com.cloudflare.api.account", +) + +r2_token = cloudflare.ApiToken( + "docx-r2-token", + name="docx-mcp-storage-r2", + policies=[ + { + "effect": "allow", + "permission_groups": [{"id": r2_write_perms.results[0].id}], + "resources": json.dumps({f"com.cloudflare.api.account.{account_id}": "*"}), + } + ], +) + +r2_access_key_id = r2_token.id +r2_secret_access_key = r2_token.value.apply( + lambda v: hashlib.sha256(v.encode()).hexdigest() +) + +# ============================================================================= +# KV — Storage index & locks (used by docx-storage-cloudflare) +# ============================================================================= + +storage_kv = cloudflare.WorkersKvNamespace( + "docx-storage-kv", + account_id=account_id, + title="docx-mcp-storage-index", +) + +# ============================================================================= +# D1 — Auth database (used by SSE proxy + website) +# Import existing: 609c7a5e-34d2-4ca3-974c-8ea81bd7897b +# ============================================================================= + +auth_db = cloudflare.D1Database( + "docx-auth-db", + account_id=account_id, + name="docx-mcp-auth", + read_replication={"mode": "disabled"}, + opts=pulumi.ResourceOptions(protect=True), +) + +# ============================================================================= +# KV — Website sessions (used by Better Auth) +# Import existing: ab2f243e258b4eb2b3be9dfaf7665b38 +# ============================================================================= + +session_kv = cloudflare.WorkersKvNamespace( + "docx-session-kv", + account_id=account_id, + title="SESSION", + opts=pulumi.ResourceOptions(protect=True), +) + +# ============================================================================= +# GCP — Google Drive API (for OAuth file sync) +# ============================================================================= + +drive_api = gcp.projects.Service( + "drive-api", + project=gcp_project, + service="drive.googleapis.com", + disable_on_destroy=False, +) + +# OAuth Client ID — must be created manually in GCP Console (no API available since +# the IAP OAuth Admin API was deprecated in July 2025 with no replacement). +# 1. Go to: https://console.cloud.google.com/apis/credentials?project=lv-project-313715 +# 2. Create OAuth 2.0 Client ID (type: Web application) +# 3. Add redirect URI: https://docx.lapoule.dev/api/oauth/callback/google-drive +# 4. Store credentials: +# pulumi config set --secret docx-mcp-infra:oauthGoogleClientId "" +# pulumi config set --secret docx-mcp-infra:oauthGoogleClientSecret "" +oauth_google_client_id = config.get_secret("oauthGoogleClientId") or "" +oauth_google_client_secret = config.get_secret("oauthGoogleClientSecret") or "" + +# ============================================================================= +# Cloudflare Pages — Website (secrets injection) +# Import existing: pulumi import cloudflare:index/pagesProject:PagesProject docx-website /docx-mcp-website +# ============================================================================= + +better_auth_secret = config.get_secret("betterAuthSecret") or "" +oauth_github_client_id = config.get_secret("oauthGithubClientId") or "" +oauth_github_client_secret = config.get_secret("oauthGithubClientSecret") or "" + +_pages_shared_config = { + "compatibility_date": "2026-01-16", + "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"], + "d1_databases": {"DB": {"id": auth_db.id}}, + "kv_namespaces": {"SESSION": {"namespace_id": session_kv.id}}, + "env_vars": { + "BETTER_AUTH_URL": {"type": "plain_text", "value": "https://docx.lapoule.dev"}, + "GCS_BUCKET_NAME": {"type": "plain_text", "value": "docx-mcp-sessions"}, + }, + "fail_open": True, + "usage_model": "standard", +} + +pages_project = cloudflare.PagesProject( + "docx-website", + account_id=account_id, + name="docx-mcp-website", + production_branch="main", + deployment_configs={ + "production": { + **_pages_shared_config, + "env_vars": { + **_pages_shared_config["env_vars"], + "BETTER_AUTH_SECRET": {"type": "secret_text", "value": better_auth_secret}, + "OAUTH_GITHUB_CLIENT_ID": {"type": "secret_text", "value": oauth_github_client_id}, + "OAUTH_GITHUB_CLIENT_SECRET": {"type": "secret_text", "value": oauth_github_client_secret}, + "OAUTH_GOOGLE_CLIENT_ID": {"type": "secret_text", "value": oauth_google_client_id}, + "OAUTH_GOOGLE_CLIENT_SECRET": {"type": "secret_text", "value": oauth_google_client_secret}, + }, + }, + "preview": { + **_pages_shared_config, + "env_vars": { + **_pages_shared_config["env_vars"], + "OAUTH_GOOGLE_CLIENT_ID": {"type": "secret_text", "value": oauth_google_client_id}, + "OAUTH_GOOGLE_CLIENT_SECRET": {"type": "secret_text", "value": oauth_google_client_secret}, + }, + }, + }, + opts=pulumi.ResourceOptions(protect=True), +) + +# ============================================================================= +# Koyeb — MCP backend services (storage + gdrive + mcp-http + proxy) +# Plugin hosted on GitHub (not Pulumi CDN). Install via: +# pulumi plugin install resource koyeb v0.1.11 \ +# --server https://github.com/koyeb/pulumi-koyeb/releases/download/v0.1.11/ +# Or: source infra/env-setup.sh (auto-installs if missing) +# Auth: KOYEB_TOKEN env var only (no Pulumi config key — provider has no schema). +# pulumi config set --secret koyebToken "" # stored under app namespace +# source infra/env-setup.sh # exports as KOYEB_TOKEN +# ============================================================================= + +import pulumi_koyeb as koyeb + +KOYEB_REGION = "fra" +GIT_REPO = "github.com/valdo404/docx-system" +GIT_BRANCH = "feat/sse-grpc-multi-tenant-20" + + +def _koyeb_service( + name: str, + dockerfile: str, + port: int, + envs: list, + *, + public: bool = False, + http_health_path: str | None = None, + instance_type: str = "nano", + scale_to_zero: bool = False, +) -> koyeb.ServiceDefinitionArgs: + """Build a ServiceDefinitionArgs for a Koyeb service. + + Public services: protocol=http, route "/", scale via requests_per_second. + Internal (mesh-only) services: protocol=tcp, no routes, min=1 (always on). + Koyeb requires at least one route for scale-to-zero, which is incompatible + with tcp — so internal services cannot scale to zero. + """ + if public: + port_protocol = "http" + routes = [koyeb.ServiceDefinitionRouteArgs(path="/", port=port)] + min_instances = 0 if scale_to_zero else 1 + scaling_targets = [koyeb.ServiceDefinitionScalingTargetArgs( + requests_per_seconds=[ + koyeb.ServiceDefinitionScalingTargetRequestsPerSecondArgs(value=100), + ], + )] + else: + port_protocol = "tcp" + routes = [] + min_instances = 1 # tcp services can't scale to zero (no route to intercept) + scaling_targets = [koyeb.ServiceDefinitionScalingTargetArgs( + concurrent_requests=[ + koyeb.ServiceDefinitionScalingTargetConcurrentRequestArgs(value=10), + ], + )] + # Health checks: HTTP for services with http_health_path, TCP otherwise + if http_health_path: + health_checks = [ + koyeb.ServiceDefinitionHealthCheckArgs( + grace_period=10, + interval=30, + timeout=5, + restart_limit=3, + http=koyeb.ServiceDefinitionHealthCheckHttpArgs( + port=port, path=http_health_path, + ), + ) + ] + else: + health_checks = [ + koyeb.ServiceDefinitionHealthCheckArgs( + grace_period=10, + interval=30, + timeout=5, + restart_limit=3, + tcp=koyeb.ServiceDefinitionHealthCheckTcpArgs(port=port), + ) + ] + return koyeb.ServiceDefinitionArgs( + name=name, + type="WEB", + regions=[KOYEB_REGION], + instance_types=[koyeb.ServiceDefinitionInstanceTypeArgs(type=instance_type)], + scalings=[koyeb.ServiceDefinitionScalingArgs( + min=min_instances, + max=2, + targets=scaling_targets, + )], + git=koyeb.ServiceDefinitionGitArgs( + repository=GIT_REPO, + branch=GIT_BRANCH, + dockerfile=koyeb.ServiceDefinitionGitDockerfileArgs( + dockerfile=dockerfile, + ), + ), + ports=[koyeb.ServiceDefinitionPortArgs(port=port, protocol=port_protocol)], + routes=routes, + envs=envs, + health_checks=health_checks, + ) + + +# --- App --- +koyeb_app = koyeb.App("docx-mcp", name="docx-mcp") + +# --- Service 1: storage (gRPC, mesh-only) --- +cloudflare_api_token = pulumi.Config("cloudflare").require_secret("apiToken") + +koyeb_storage = koyeb.Service( + "koyeb-storage", + app_name=koyeb_app.name, + definition=_koyeb_service( + name="storage", + dockerfile="Dockerfile.storage-cloudflare", + port=50051, + instance_type="nano", + scale_to_zero=True, # Applied via CLI (mesh services need no route for CLI/API) + envs=[ + koyeb.ServiceDefinitionEnvArgs(key="RUST_LOG", value="info,docx_storage_cloudflare=debug"), + koyeb.ServiceDefinitionEnvArgs(key="GRPC_HOST", value="0.0.0.0"), + koyeb.ServiceDefinitionEnvArgs(key="GRPC_PORT", value="50051"), + koyeb.ServiceDefinitionEnvArgs(key="CLOUDFLARE_ACCOUNT_ID", value=account_id), + koyeb.ServiceDefinitionEnvArgs(key="R2_BUCKET_NAME", value=storage_bucket.name), + koyeb.ServiceDefinitionEnvArgs(key="R2_ACCESS_KEY_ID", value=r2_access_key_id), + koyeb.ServiceDefinitionEnvArgs(key="R2_SECRET_ACCESS_KEY", value=r2_secret_access_key), + ], + ), +) + +# --- Service 2: gdrive (gRPC, mesh-only) --- +koyeb_gdrive = koyeb.Service( + "koyeb-gdrive", + app_name=koyeb_app.name, + definition=_koyeb_service( + name="gdrive", + dockerfile="Dockerfile.gdrive", + port=50052, + instance_type="nano", + scale_to_zero=True, # Applied via CLI (mesh services need no route for CLI/API) + envs=[ + koyeb.ServiceDefinitionEnvArgs(key="RUST_LOG", value="info"), + koyeb.ServiceDefinitionEnvArgs(key="GRPC_HOST", value="0.0.0.0"), + koyeb.ServiceDefinitionEnvArgs(key="GRPC_PORT", value="50052"), + koyeb.ServiceDefinitionEnvArgs(key="CLOUDFLARE_ACCOUNT_ID", value=account_id), + koyeb.ServiceDefinitionEnvArgs(key="CLOUDFLARE_API_TOKEN", value=cloudflare_api_token), + koyeb.ServiceDefinitionEnvArgs(key="D1_DATABASE_ID", value=auth_db.id), + koyeb.ServiceDefinitionEnvArgs(key="GOOGLE_CLIENT_ID", value=oauth_google_client_id), + koyeb.ServiceDefinitionEnvArgs(key="GOOGLE_CLIENT_SECRET", value=oauth_google_client_secret), + koyeb.ServiceDefinitionEnvArgs(key="WATCH_POLL_INTERVAL", value="60"), + ], + ), +) + +# --- Service 3: mcp-http (HTTP, mesh-only) --- +koyeb_mcp = koyeb.Service( + "koyeb-mcp-http", + app_name=koyeb_app.name, + definition=_koyeb_service( + name="mcp-http", + dockerfile="Dockerfile", + port=3000, + http_health_path="/health", + instance_type="small", + envs=[ + koyeb.ServiceDefinitionEnvArgs(key="MCP_TRANSPORT", value="http"), + koyeb.ServiceDefinitionEnvArgs(key="ASPNETCORE_URLS", value="http://+:3000"), + koyeb.ServiceDefinitionEnvArgs(key="STORAGE_GRPC_URL", value="http://storage:50051"), + koyeb.ServiceDefinitionEnvArgs(key="SYNC_GRPC_URL", value="http://gdrive:50052"), + ], + ), +) + +# --- Service 4: proxy (HTTP, PUBLIC) --- +koyeb_proxy = koyeb.Service( + "koyeb-proxy", + app_name=koyeb_app.name, + definition=_koyeb_service( + name="proxy", + dockerfile="Dockerfile.proxy", + port=8080, + public=True, + http_health_path="/health", + instance_type="nano", + scale_to_zero=False, # Always on — front door for all MCP clients + envs=[ + koyeb.ServiceDefinitionEnvArgs(key="RUST_LOG", value="info"), + koyeb.ServiceDefinitionEnvArgs(key="MCP_BACKEND_URL", value="http://mcp-http:3000"), + koyeb.ServiceDefinitionEnvArgs(key="CLOUDFLARE_ACCOUNT_ID", value=account_id), + koyeb.ServiceDefinitionEnvArgs(key="CLOUDFLARE_API_TOKEN", value=cloudflare_api_token), + koyeb.ServiceDefinitionEnvArgs(key="D1_DATABASE_ID", value=auth_db.id), + koyeb.ServiceDefinitionEnvArgs(key="RESOURCE_URL", value="https://mcp.docx.lapoule.dev"), + koyeb.ServiceDefinitionEnvArgs(key="AUTH_SERVER_URL", value="https://docx.lapoule.dev"), + ], + ), +) + +# --- Custom Domain: mcp.docx.lapoule.dev --- +koyeb_domain = koyeb.Domain("docx-mcp-domain", + name="mcp.docx.lapoule.dev", + app_name=koyeb_app.name, +) + +lapoule_zone = cloudflare.get_zone(filter=cloudflare.GetZoneFilterArgs( + name="lapoule.dev", + match="all", +)) + +cloudflare.DnsRecord("mcp-cname", + zone_id=lapoule_zone.zone_id, + name="mcp.docx", + type="CNAME", + content=koyeb_domain.intended_cname, + ttl=1, # 1 = automatic + proxied=False, # DNS-only — Koyeb needs direct access for TLS provisioning +) + +# ============================================================================= +# Outputs +# ============================================================================= + +pulumi.export("cloudflare_account_id", account_id) +pulumi.export("r2_bucket_name", storage_bucket.name) +pulumi.export("r2_endpoint", pulumi.Output.concat( + "https://", account_id, ".r2.cloudflarestorage.com", +)) +pulumi.export("r2_access_key_id", r2_access_key_id) +pulumi.export("r2_secret_access_key", pulumi.Output.secret(r2_secret_access_key)) +pulumi.export("storage_kv_namespace_id", storage_kv.id) +pulumi.export("auth_d1_database_id", auth_db.id) +pulumi.export("session_kv_namespace_id", session_kv.id) +pulumi.export("oauth_google_client_id", pulumi.Output.secret(oauth_google_client_id)) +pulumi.export("oauth_google_client_secret", pulumi.Output.secret(oauth_google_client_secret)) +pulumi.export("koyeb_app_id", koyeb_app.id) +pulumi.export("koyeb_mcp_domain", koyeb_domain.name) diff --git a/infra/env-setup.sh b/infra/env-setup.sh new file mode 100755 index 0000000..791323b --- /dev/null +++ b/infra/env-setup.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Source this file to export Cloudflare env vars from Pulumi outputs. +# source infra/env-setup.sh +# +# Also requires CLOUDFLARE_API_TOKEN in env (not stored in Pulumi outputs). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" && pwd)" +STACK="${PULUMI_STACK:-prod}" + +# Ensure Koyeb plugin is installed (hosted on GitHub, not Pulumi CDN) +KOYEB_VERSION="0.1.11" +if ! pulumi plugin ls 2>/dev/null | grep -q "koyeb.*${KOYEB_VERSION}"; then + echo "Installing Koyeb Pulumi plugin v${KOYEB_VERSION}..." + pulumi plugin install resource koyeb "v${KOYEB_VERSION}" \ + --server "https://github.com/koyeb/pulumi-koyeb/releases/download/v${KOYEB_VERSION}/" +fi + +_out() { + pulumi stack output "$1" --stack "$STACK" --cwd "$SCRIPT_DIR" --show-secrets 2>/dev/null +} + +export CLOUDFLARE_ACCOUNT_ID="$(_out cloudflare_account_id)" +export R2_BUCKET_NAME="$(_out r2_bucket_name)" +export KV_NAMESPACE_ID="$(_out storage_kv_namespace_id)" +export D1_DATABASE_ID="$(_out auth_d1_database_id)" +export R2_ACCESS_KEY_ID="$(_out r2_access_key_id)" +export R2_SECRET_ACCESS_KEY="$(_out r2_secret_access_key)" +export CLOUDFLARE_API_TOKEN="$(pulumi config get cloudflare:apiToken --stack "$STACK" --cwd "$SCRIPT_DIR" 2>/dev/null)" +export OAUTH_GOOGLE_CLIENT_ID="$(_out oauth_google_client_id)" +export OAUTH_GOOGLE_CLIENT_SECRET="$(_out oauth_google_client_secret)" + +echo "Env loaded from Pulumi stack '$STACK':" +echo " CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID" +echo " R2_BUCKET_NAME=$R2_BUCKET_NAME" +echo " R2_ACCESS_KEY_ID=$R2_ACCESS_KEY_ID" +echo " R2_SECRET_ACCESS_KEY=(set)" +echo " KV_NAMESPACE_ID=$KV_NAMESPACE_ID" +echo " D1_DATABASE_ID=$D1_DATABASE_ID" +echo " CLOUDFLARE_API_TOKEN=(set)" +echo " OAUTH_GOOGLE_CLIENT_ID=${OAUTH_GOOGLE_CLIENT_ID:-(not set)}" +echo " OAUTH_GOOGLE_CLIENT_SECRET=${OAUTH_GOOGLE_CLIENT_SECRET:+****(set)}" + +# Koyeb +export KOYEB_TOKEN="$(pulumi config get koyebToken --stack "$STACK" --cwd "$SCRIPT_DIR" 2>/dev/null)" +export KOYEB_APP_ID="$(_out koyeb_app_id 2>/dev/null)" +echo " KOYEB_TOKEN=${KOYEB_TOKEN:+(set)}" +echo " KOYEB_APP_ID=${KOYEB_APP_ID:-(not set)}" diff --git a/infra/koyeb-fix-routes.sh b/infra/koyeb-fix-routes.sh new file mode 100755 index 0000000..ec2a801 --- /dev/null +++ b/infra/koyeb-fix-routes.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Remove public routes from internal Koyeb services. +# +# Problem: Koyeb auto-adds route "/" to all WEB services with protocol=http. +# When multiple services in the same app share the same route, Koyeb's edge +# routes traffic to the wrong service (e.g., gRPC storage instead of proxy) → 502. +# +# Solution: Internal services must use protocol=tcp (mesh-only, no public route). +# Only the proxy keeps protocol=http with route "/". +# +# Usage: +# source infra/env-setup.sh # loads KOYEB_TOKEN +# bash infra/koyeb-fix-routes.sh +# +# Requires: curl, python3, KOYEB_TOKEN or ~/.koyeb.yaml + +set -euo pipefail + +# --- Resolve API token --- +if [[ -z "${KOYEB_TOKEN:-}" ]]; then + if [[ -f "$HOME/.koyeb.yaml" ]]; then + KOYEB_TOKEN=$(grep 'token:' "$HOME/.koyeb.yaml" | awk '{print $2}') + fi +fi +if [[ -z "${KOYEB_TOKEN:-}" ]]; then + echo "Error: KOYEB_TOKEN not set. Run: source infra/env-setup.sh" >&2 + exit 1 +fi + +API="https://app.koyeb.com/v1" +APP_NAME="${KOYEB_APP_NAME:-docx-mcp}" + +# --- Resolve service IDs --- +echo "Fetching services for app '$APP_NAME'..." +SERVICES_JSON=$(curl -sf -H "Authorization: Bearer $KOYEB_TOKEN" "$API/services?limit=20") + +get_service_id() { + local name="$1" + echo "$SERVICES_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for svc in data.get('services', []): + if svc.get('name') == '$name': + print(svc['id']); sys.exit(0) +sys.exit(1) +" 2>/dev/null +} + +get_deployment_def() { + local svc_id="$1" + local dep_id + dep_id=$(curl -sf -H "Authorization: Bearer $KOYEB_TOKEN" "$API/services/$svc_id" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['service']['active_deployment_id'])") + curl -sf -H "Authorization: Bearer $KOYEB_TOKEN" "$API/deployments/$dep_id" \ + | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin)['deployment']['definition']))" +} + +redeploy_with_def() { + local svc_id="$1" + local new_def="$2" + # Koyeb PATCH /services/:id with full definition triggers a new deployment + curl -sf -X PATCH -H "Authorization: Bearer $KOYEB_TOKEN" \ + -H "Content-Type: application/json" \ + "$API/services/$svc_id" \ + -d "{\"definition\": $new_def}" +} + +# Internal services: switch ports to tcp + remove routes +INTERNAL_SERVICES=("mcp-http" "storage" "gdrive") + +for svc_name in "${INTERNAL_SERVICES[@]}"; do + svc_id=$(get_service_id "$svc_name") || true + if [[ -z "$svc_id" ]]; then + echo " SKIP $svc_name (not found)" + continue + fi + + current_def=$(get_deployment_def "$svc_id") + current_protocol=$(echo "$current_def" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('ports',[{}])[0].get('protocol',''))") + current_routes=$(echo "$current_def" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('routes',[])))") + + if [[ "$current_protocol" == "tcp" && "$current_routes" == "0" ]]; then + echo " OK $svc_name — already tcp, no routes" + continue + fi + + echo " FIX $svc_name — protocol=$current_protocol, routes=$current_routes → tcp, no routes" + + # Build new definition: change ports.protocol to tcp, remove routes + new_def=$(echo "$current_def" | python3 -c " +import sys, json +d = json.load(sys.stdin) +d['routes'] = [] +for p in d.get('ports', []): + p['protocol'] = 'tcp' +print(json.dumps(d)) +") + + result=$(redeploy_with_def "$svc_id" "$new_def") + new_version=$(echo "$result" | python3 -c "import sys,json; print(json.load(sys.stdin)['service']['version'])") + echo " → deployed (version $new_version)" +done + +echo "" +echo "Verifying proxy keeps http + route /..." +proxy_id=$(get_service_id "proxy") || true +if [[ -n "$proxy_id" ]]; then + proxy_def=$(get_deployment_def "$proxy_id") + echo "$proxy_def" | python3 -c " +import sys, json +d = json.load(sys.stdin) +proto = d.get('ports',[{}])[0].get('protocol','') +routes = d.get('routes', []) +print(f' proxy: protocol={proto}, routes={json.dumps(routes)}') +" +fi + +echo "" +echo "Done. Wait for deployments to become HEALTHY, then test:" +echo " curl -s https://mcp.docx.lapoule.dev/health" diff --git a/infra/requirements.txt b/infra/requirements.txt new file mode 100644 index 0000000..e1cae97 --- /dev/null +++ b/infra/requirements.txt @@ -0,0 +1,4 @@ +pulumi>=3.0.0,<4.0.0 +pulumi-cloudflare>=6.0.0,<7.0.0 +pulumi-gcp>=8.0.0,<9.0.0 +pulumi-koyeb>=0.1.11,<1.0.0 diff --git a/installers/macos/build-dmg.sh b/installers/macos/build-dmg.sh index 73dd008..a2dab04 100755 --- a/installers/macos/build-dmg.sh +++ b/installers/macos/build-dmg.sh @@ -106,8 +106,8 @@ Installation: Double-click "${PKG_NAME}" to install. After installation, binaries will be available at: - /usr/local/bin/docx-mcp (MCP server) - /usr/local/bin/docx-cli (CLI tool) + /usr/local/bin/docx-mcp (MCP server with built-in storage) + /usr/local/bin/docx-cli (CLI tool with built-in storage) Quick Start: docx-mcp --help diff --git a/installers/macos/build-pkg.sh b/installers/macos/build-pkg.sh index 73baf45..26c2286 100755 --- a/installers/macos/build-pkg.sh +++ b/installers/macos/build-pkg.sh @@ -275,8 +275,8 @@ cat > "${RESOURCES_DIR}/welcome.html" <Welcome to the DocX MCP Server installer.

This package will install:

    -
  • docx-mcp - MCP server for AI-powered Word document manipulation
  • -
  • docx-cli - Command-line interface for direct operations
  • +
  • docx-mcp - MCP server for AI-powered Word document manipulation (storage engine built-in)
  • +
  • docx-cli - Command-line interface for direct operations (storage engine built-in)

The tools will be installed to /usr/local/bin and will be available from the terminal immediately after installation.

diff --git a/proto/storage.proto b/proto/storage.proto new file mode 100644 index 0000000..e9e74c6 --- /dev/null +++ b/proto/storage.proto @@ -0,0 +1,576 @@ +syntax = "proto3"; + +option csharp_namespace = "DocxMcp.Grpc"; + +package docx.storage; + +// StorageService provides tenant-aware storage operations for docx-mcp. +// All operations are scoped by tenant_id for multi-tenant isolation. +// Large file operations use streaming to handle documents > 4MB. +service StorageService { + // Session lifecycle (streaming for large files) + rpc LoadSession(LoadSessionRequest) returns (stream DataChunk); + rpc SaveSession(stream SaveSessionChunk) returns (SaveSessionResponse); + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); + rpc DeleteSession(DeleteSessionRequest) returns (DeleteSessionResponse); + rpc SessionExists(SessionExistsRequest) returns (SessionExistsResponse); + + // Index operations (atomic, server handles locking internally) + rpc LoadIndex(LoadIndexRequest) returns (LoadIndexResponse); + rpc AddSessionToIndex(AddSessionToIndexRequest) returns (AddSessionToIndexResponse); + rpc UpdateSessionInIndex(UpdateSessionInIndexRequest) returns (UpdateSessionInIndexResponse); + rpc RemoveSessionFromIndex(RemoveSessionFromIndexRequest) returns (RemoveSessionFromIndexResponse); + + // WAL operations + rpc AppendWal(AppendWalRequest) returns (AppendWalResponse); + rpc ReadWal(ReadWalRequest) returns (ReadWalResponse); + rpc TruncateWal(TruncateWalRequest) returns (TruncateWalResponse); + + // Checkpoint operations (streaming for large files) + rpc SaveCheckpoint(stream SaveCheckpointChunk) returns (SaveCheckpointResponse); + rpc LoadCheckpoint(LoadCheckpointRequest) returns (stream LoadCheckpointChunk); + rpc ListCheckpoints(ListCheckpointsRequest) returns (ListCheckpointsResponse); + + // Health check + rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse); +} + +// Common context for all tenant-scoped operations +message TenantContext { + string tenant_id = 1; +} + +// ============================================================================= +// Streaming Chunk Messages +// ============================================================================= + +// Generic data chunk for streaming large binary payloads. +// Recommended chunk size: 64KB - 1MB +message DataChunk { + bytes data = 1; + bool is_last = 2; // True for the final chunk + // Metadata only in first chunk + bool found = 3; // For load operations: whether the resource exists + uint64 total_size = 4; // Total size in bytes (optional, for progress) +} + +// Chunk for SaveSession streaming upload +message SaveSessionChunk { + // First chunk must include metadata + TenantContext context = 1; + string session_id = 2; + // All chunks include data + bytes data = 3; + bool is_last = 4; +} + +// Chunk for SaveCheckpoint streaming upload +message SaveCheckpointChunk { + // First chunk must include metadata + TenantContext context = 1; + string session_id = 2; + uint64 position = 3; // WAL position this checkpoint represents + // All chunks include data + bytes data = 4; + bool is_last = 5; +} + +// Chunk for LoadCheckpoint streaming download (includes position metadata) +message LoadCheckpointChunk { + bytes data = 1; + bool is_last = 2; + bool found = 3; // Only meaningful in first chunk + uint64 position = 4; // Actual checkpoint position (only in first chunk) + uint64 total_size = 5; // Total size in bytes (only in first chunk) +} + +// ============================================================================= +// Session Messages +// ============================================================================= + +message LoadSessionRequest { + TenantContext context = 1; + string session_id = 2; +} + +// Response is stream of DataChunk + +message SaveSessionResponse { + bool success = 1; +} + +message ListSessionsRequest { + TenantContext context = 1; +} + +message SessionInfo { + string session_id = 1; + string source_path = 2; + int64 created_at_unix = 3; + int64 modified_at_unix = 4; + int64 size_bytes = 5; +} + +message ListSessionsResponse { + repeated SessionInfo sessions = 1; +} + +message DeleteSessionRequest { + TenantContext context = 1; + string session_id = 2; +} + +message DeleteSessionResponse { + bool success = 1; + bool existed = 2; +} + +message SessionExistsRequest { + TenantContext context = 1; + string session_id = 2; +} + +message SessionExistsResponse { + bool exists = 1; + bool pending_external_change = 2; +} + +// ============================================================================= +// Index Messages (Atomic operations - server handles locking internally) +// ============================================================================= + +message LoadIndexRequest { + TenantContext context = 1; +} + +message LoadIndexResponse { + bytes index_json = 1; + bool found = 2; +} + +// Atomic operation to add a session to the index +message SessionIndexEntry { + string source_path = 1; + int64 created_at_unix = 2; + int64 modified_at_unix = 3; + uint64 wal_position = 4; + repeated uint64 checkpoint_positions = 5; + bool pending_external_change = 6; +} + +message AddSessionToIndexRequest { + TenantContext context = 1; + string session_id = 2; + SessionIndexEntry entry = 3; +} + +message AddSessionToIndexResponse { + bool success = 1; + bool already_exists = 2; +} + +// Atomic operation to update a session in the index +message UpdateSessionInIndexRequest { + TenantContext context = 1; + string session_id = 2; + // Optional fields - only non-null values are updated + optional int64 modified_at_unix = 3; + optional uint64 wal_position = 4; + repeated uint64 add_checkpoint_positions = 5; // Positions to add + repeated uint64 remove_checkpoint_positions = 6; // Positions to remove + optional uint64 cursor_position = 7; // Current undo/redo cursor + optional bool pending_external_change = 8; // External change pending flag + optional string source_path = 9; // Update source path +} + +message UpdateSessionInIndexResponse { + bool success = 1; + bool not_found = 2; +} + +// Atomic operation to remove a session from the index +message RemoveSessionFromIndexRequest { + TenantContext context = 1; + string session_id = 2; +} + +message RemoveSessionFromIndexResponse { + bool success = 1; + bool existed = 2; +} + +// ============================================================================= +// WAL Messages +// ============================================================================= + +message WalEntry { + uint64 position = 1; + string operation = 2; // "add", "replace", "remove", etc. + string path = 3; // Document path affected + bytes patch_json = 4; // The patch data as JSON + int64 timestamp_unix = 5; +} + +message AppendWalRequest { + TenantContext context = 1; + string session_id = 2; + repeated WalEntry entries = 3; +} + +message AppendWalResponse { + bool success = 1; + uint64 new_position = 2; // Position after append +} + +message ReadWalRequest { + TenantContext context = 1; + string session_id = 2; + uint64 from_position = 3; // 0 = from beginning + uint64 limit = 4; // 0 = no limit +} + +message ReadWalResponse { + repeated WalEntry entries = 1; + bool has_more = 2; +} + +message TruncateWalRequest { + TenantContext context = 1; + string session_id = 2; + uint64 keep_from_position = 3; // Keep entries >= this position +} + +message TruncateWalResponse { + bool success = 1; + uint64 entries_removed = 2; +} + +// ============================================================================= +// Checkpoint Messages +// ============================================================================= + +message SaveCheckpointResponse { + bool success = 1; +} + +message LoadCheckpointRequest { + TenantContext context = 1; + string session_id = 2; + uint64 position = 3; // 0 = latest checkpoint +} + +// Response is stream of LoadCheckpointChunk + +message ListCheckpointsRequest { + TenantContext context = 1; + string session_id = 2; +} + +message CheckpointInfo { + uint64 position = 1; + int64 created_at_unix = 2; + int64 size_bytes = 3; +} + +message ListCheckpointsResponse { + repeated CheckpointInfo checkpoints = 1; +} + +// ============================================================================= +// Health Check +// ============================================================================= + +message HealthCheckRequest {} + +message HealthCheckResponse { + bool healthy = 1; + string backend = 2; // "local" or "r2" + string version = 3; +} + +// ============================================================================= +// SourceSyncService - Sync changes back to external sources +// ============================================================================= +// Handles auto-save functionality for various source types: +// - Local files (current behavior) +// - SharePoint documents +// - OneDrive files +// - Google Drive files + +service SourceSyncService { + // Register a session's source for sync tracking + rpc RegisterSource(RegisterSourceRequest) returns (RegisterSourceResponse); + + // Unregister a source (on session close) + rpc UnregisterSource(UnregisterSourceRequest) returns (UnregisterSourceResponse); + + // Update source configuration (change target file, toggle auto-sync) + rpc UpdateSource(UpdateSourceRequest) returns (UpdateSourceResponse); + + // Sync current session state to external source (streaming for large files) + rpc SyncToSource(stream SyncToSourceChunk) returns (SyncToSourceResponse); + + // Get sync status for a session + rpc GetSyncStatus(GetSyncStatusRequest) returns (GetSyncStatusResponse); + + // List all registered sources for a tenant + rpc ListSources(ListSourcesRequest) returns (ListSourcesResponse); + + // List available connections for a tenant + rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse); + + // List files in a folder of a connection + rpc ListConnectionFiles(ListConnectionFilesRequest) returns (ListConnectionFilesResponse); + + // Download a file from a source (streaming) + rpc DownloadFromSource(DownloadFromSourceRequest) returns (stream DataChunk); +} + +// Source types supported by the sync service +enum SourceType { + SOURCE_TYPE_UNSPECIFIED = 0; + SOURCE_TYPE_LOCAL_FILE = 1; + SOURCE_TYPE_SHAREPOINT = 2; + SOURCE_TYPE_ONEDRIVE = 3; + reserved 4, 5; // Formerly S3, R2 (never activated) + SOURCE_TYPE_GOOGLE_DRIVE = 6; +} + +message SourceDescriptor { + SourceType type = 1; + string connection_id = 2; // OAuth connection ID (empty for local) + string path = 3; // Human-readable path (local: absolute path, cloud: display path) + string file_id = 4; // Provider-specific file ID (GDrive file ID, OneDrive item ID) + // Empty for local (path is the identifier) +} + +message RegisterSourceRequest { + TenantContext context = 1; + string session_id = 2; + SourceDescriptor source = 3; + bool auto_sync = 4; // Enable auto-sync on WAL append +} + +message RegisterSourceResponse { + bool success = 1; + string error = 2; +} + +message UnregisterSourceRequest { + TenantContext context = 1; + string session_id = 2; +} + +message UnregisterSourceResponse { + bool success = 1; +} + +message UpdateSourceRequest { + TenantContext context = 1; + string session_id = 2; + // New source descriptor (optional - if not set, keeps existing source) + SourceDescriptor source = 3; + // New auto-sync setting (optional - use update_auto_sync to indicate if set) + bool auto_sync = 4; + bool update_auto_sync = 5; // True if auto_sync field should be applied +} + +message UpdateSourceResponse { + bool success = 1; + string error = 2; +} + +// Chunk for SyncToSource streaming upload (supports large files > 4MB) +message SyncToSourceChunk { + // First chunk must include metadata + TenantContext context = 1; + string session_id = 2; + // All chunks include data + bytes data = 3; + bool is_last = 4; +} + +message SyncToSourceResponse { + bool success = 1; + string error = 2; + int64 synced_at_unix = 3; +} + +message GetSyncStatusRequest { + TenantContext context = 1; + string session_id = 2; +} + +message SyncStatus { + string session_id = 1; + SourceDescriptor source = 2; + bool auto_sync_enabled = 3; + int64 last_synced_at_unix = 4; + bool has_pending_changes = 5; + string last_error = 6; +} + +message GetSyncStatusResponse { + bool registered = 1; + SyncStatus status = 2; +} + +message ListSourcesRequest { + TenantContext context = 1; +} + +message ListSourcesResponse { + repeated SyncStatus sources = 1; +} + +// ============================================================================= +// Connection Browsing Messages +// ============================================================================= + +message ConnectionInfo { + string connection_id = 1; // "" for local + SourceType type = 2; + string display_name = 3; // "My personal Drive" or "Local filesystem" + string provider_account_id = 4; // email for GDrive, empty for local +} + +message ListConnectionsRequest { + TenantContext context = 1; + SourceType filter_type = 2; // 0 = all types +} + +message ListConnectionsResponse { + repeated ConnectionInfo connections = 1; +} + +message FileEntry { + string name = 1; // file/folder name + string path = 2; // human-readable path (local: absolute, cloud: display path) + string file_id = 3; // provider-specific ID (GDrive file ID, empty for local) + bool is_folder = 4; + int64 size_bytes = 5; + int64 modified_at_unix = 6; + string mime_type = 7; +} + +message ListConnectionFilesRequest { + TenantContext context = 1; + SourceType type = 2; + string connection_id = 3; // empty for local + string path = 4; // folder path (empty = root) + string page_token = 5; // pagination + int32 page_size = 6; // 0 = default (50) +} + +message ListConnectionFilesResponse { + repeated FileEntry files = 1; + string next_page_token = 2; // empty if no more pages +} + +message DownloadFromSourceRequest { + TenantContext context = 1; + SourceType type = 2; + string connection_id = 3; + string path = 4; // human-readable path + string file_id = 5; // provider-specific ID (takes priority if non-empty) +} + +// Response = stream DataChunk (already defined) + +// ============================================================================= +// ExternalWatchService - Monitor external sources for changes +// ============================================================================= +// Detects when external sources are modified outside of docx-mcp. +// Used to notify clients of conflicts or trigger re-sync. + +service ExternalWatchService { + // Start watching a source for external changes + rpc StartWatch(StartWatchRequest) returns (StartWatchResponse); + + // Stop watching a source + rpc StopWatch(StopWatchRequest) returns (StopWatchResponse); + + // Poll for changes (for backends that don't support push notifications) + rpc CheckForChanges(CheckForChangesRequest) returns (CheckForChangesResponse); + + // Stream of external change events (long-poll / server-push) + rpc WatchChanges(WatchChangesRequest) returns (stream ExternalChangeEvent); + + // Get current file metadata (for comparison) + rpc GetSourceMetadata(GetSourceMetadataRequest) returns (GetSourceMetadataResponse); +} + +message StartWatchRequest { + TenantContext context = 1; + string session_id = 2; + SourceDescriptor source = 3; + int32 poll_interval_seconds = 4; // For polling-based backends (0 = default) +} + +message StartWatchResponse { + bool success = 1; + string watch_id = 2; // Unique identifier for this watch + string error = 3; +} + +message StopWatchRequest { + TenantContext context = 1; + string session_id = 2; +} + +message StopWatchResponse { + bool success = 1; +} + +message CheckForChangesRequest { + TenantContext context = 1; + string session_id = 2; +} + +message CheckForChangesResponse { + bool has_changes = 1; + SourceMetadata current_metadata = 2; + SourceMetadata known_metadata = 3; +} + +message WatchChangesRequest { + TenantContext context = 1; + repeated string session_ids = 2; // Sessions to watch (empty = all for tenant) +} + +// Event types for external changes +enum ExternalChangeType { + EXTERNAL_CHANGE_TYPE_UNSPECIFIED = 0; + EXTERNAL_CHANGE_TYPE_MODIFIED = 1; + EXTERNAL_CHANGE_TYPE_DELETED = 2; + EXTERNAL_CHANGE_TYPE_RENAMED = 3; + EXTERNAL_CHANGE_TYPE_PERMISSION_CHANGED = 4; +} + +message ExternalChangeEvent { + string session_id = 1; + ExternalChangeType change_type = 2; + SourceMetadata old_metadata = 3; + SourceMetadata new_metadata = 4; + int64 detected_at_unix = 5; + string new_uri = 6; // For rename events +} + +message SourceMetadata { + int64 size_bytes = 1; + int64 modified_at_unix = 2; + string etag = 3; // For HTTP-based sources + string version_id = 4; // For versioned sources (S3, SharePoint) + bytes content_hash = 5; // SHA-256 of content (if available) +} + +message GetSourceMetadataRequest { + TenantContext context = 1; + string session_id = 2; +} + +message GetSourceMetadataResponse { + bool success = 1; + SourceMetadata metadata = 2; + string error = 3; +} diff --git a/publish.sh b/publish.sh index 3db24f3..60e289a 100755 --- a/publish.sh +++ b/publish.sh @@ -1,16 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# Build NativeAOT binaries for all supported platforms. -# Requires .NET 10 SDK. +# Build NativeAOT binaries with embedded Rust storage for all supported platforms. +# Requires .NET 10 SDK and Rust toolchain. # # Usage: # ./publish.sh # Build for current platform # ./publish.sh all # Build for all platforms (cross-compile) # ./publish.sh macos-arm64 # Build for specific target +# ./publish.sh rust # Build only Rust staticlib for current platform SERVER_PROJECT="src/DocxMcp/DocxMcp.csproj" CLI_PROJECT="src/DocxMcp.Cli/DocxMcp.Cli.csproj" +STORAGE_CRATE="crates/docx-storage-local" OUTPUT_DIR="dist" CONFIG="Release" @@ -23,11 +25,37 @@ declare -A TARGETS=( ["windows-arm64"]="win-arm64" ) +# Rust target triples for cross-compilation +declare -A RUST_TARGETS=( + ["macos-arm64"]="aarch64-apple-darwin" + ["macos-x64"]="x86_64-apple-darwin" + ["linux-x64"]="x86_64-unknown-linux-gnu" + ["linux-arm64"]="aarch64-unknown-linux-gnu" + ["windows-x64"]="x86_64-pc-windows-msvc" + ["windows-arm64"]="aarch64-pc-windows-msvc" +) + +# Staticlib names per platform +rust_staticlib_name() { + local name="$1" + if [[ "$name" == windows-* ]]; then + echo "docx_storage_local.lib" + else + echo "libdocx_storage_local.a" + fi +} + publish_project() { local project="$1" local binary_name="$2" local rid="$3" local out="$4" + local rust_lib_path="$5" + + local extra_args=() + if [[ -n "$rust_lib_path" ]]; then + extra_args+=("-p:RustStaticLibPath=$rust_lib_path") + fi dotnet publish "$project" \ --configuration "$CONFIG" \ @@ -35,7 +63,8 @@ publish_project() { --self-contained true \ --output "$out" \ -p:PublishAot=true \ - -p:OptimizationPreference=Size + -p:OptimizationPreference=Size \ + "${extra_args[@]}" local binary if [[ "$out" == *windows* ]]; then @@ -53,6 +82,89 @@ publish_project() { fi } +# Build Rust staticlib (for embedding into .NET binaries) +build_rust_staticlib() { + local name="$1" + local rust_target="${RUST_TARGETS[$name]}" + local current_target + + # Detect current Rust target + local arch + arch="$(uname -m)" + case "$(uname -s)-$arch" in + Darwin-arm64) current_target="aarch64-apple-darwin" ;; + Darwin-x86_64) current_target="x86_64-apple-darwin" ;; + Linux-x86_64) current_target="x86_64-unknown-linux-gnu" ;; + Linux-aarch64) current_target="aarch64-unknown-linux-gnu" ;; + *) current_target="" ;; + esac + + local lib_name + lib_name=$(rust_staticlib_name "$name") + + if [[ "$rust_target" == "$current_target" ]]; then + # Native build + echo " Building Rust staticlib (native)..." >&2 + cargo build --release --package docx-storage-local --lib + echo "target/release/$lib_name" + else + # Cross-compile (requires target installed) + if rustup target list --installed | grep -q "$rust_target"; then + echo " Building Rust staticlib (cross: $rust_target)..." >&2 + cargo build --release --package docx-storage-local --lib --target "$rust_target" + echo "target/$rust_target/release/$lib_name" + else + echo " SKIP: Rust target $rust_target not installed (run: rustup target add $rust_target)" >&2 + echo "" + return 0 + fi + fi +} + +# Build standalone Rust binary (for remote server use) +build_rust_binary() { + local name="$1" + local out="$2" + local rust_target="${RUST_TARGETS[$name]}" + local current_target + + local arch + arch="$(uname -m)" + case "$(uname -s)-$arch" in + Darwin-arm64) current_target="aarch64-apple-darwin" ;; + Darwin-x86_64) current_target="x86_64-apple-darwin" ;; + Linux-x86_64) current_target="x86_64-unknown-linux-gnu" ;; + Linux-aarch64) current_target="aarch64-unknown-linux-gnu" ;; + *) current_target="" ;; + esac + + local binary_name="docx-storage-local" + [[ "$name" == windows-* ]] && binary_name="docx-storage-local.exe" + + if [[ "$rust_target" == "$current_target" ]]; then + echo " Building Rust storage binary (native)..." + cargo build --release --package docx-storage-local + cp "target/release/$binary_name" "$out/" 2>/dev/null || \ + cp "target/release/docx-storage-local" "$out/$binary_name" + else + if rustup target list --installed | grep -q "$rust_target"; then + echo " Building Rust storage binary (cross: $rust_target)..." + cargo build --release --package docx-storage-local --target "$rust_target" + cp "target/$rust_target/release/$binary_name" "$out/" 2>/dev/null || \ + cp "target/$rust_target/release/docx-storage-local" "$out/$binary_name" + else + echo " SKIP: Rust binary target $rust_target not installed" + return 0 + fi + fi + + if [[ -f "$out/$binary_name" ]]; then + local size + size=$(du -sh "$out/$binary_name" | cut -f1) + echo " Built: $out/$binary_name ($size)" + fi +} + publish_target() { local name="$1" local rid="${TARGETS[$name]}" @@ -65,40 +177,94 @@ publish_target() { export LIBRARY_PATH="/opt/homebrew/lib:${LIBRARY_PATH:-}" fi - echo "==> Publishing docx-mcp ($name / $rid)..." - publish_project "$SERVER_PROJECT" "docx-mcp" "$rid" "$out" + # 1. Build Rust staticlib + echo "==> Building Rust staticlib ($name)..." + local rust_lib_path + rust_lib_path=$(build_rust_staticlib "$name") + + if [[ -z "$rust_lib_path" ]]; then + echo " SKIP: Could not build Rust staticlib for $name" + return 0 + fi + + local abs_rust_lib_path + abs_rust_lib_path="$(pwd)/$rust_lib_path" + + if [[ -f "$abs_rust_lib_path" ]]; then + local size + size=$(du -sh "$abs_rust_lib_path" | cut -f1) + echo " Staticlib: $abs_rust_lib_path ($size)" + fi + + # 2. Build .NET with embedded Rust + echo "==> Publishing docx-mcp ($name / $rid) [embedded storage]..." + publish_project "$SERVER_PROJECT" "docx-mcp" "$rid" "$out" "$abs_rust_lib_path" + + echo "==> Publishing docx-cli ($name / $rid) [embedded storage]..." + publish_project "$CLI_PROJECT" "docx-cli" "$rid" "$out" "$abs_rust_lib_path" + + # 3. (Optional) Build standalone Rust binary for remote server use + echo "==> Publishing docx-storage-local binary ($name) [standalone]..." + build_rust_binary "$name" "$out" +} + +publish_rust_only() { + local rid_name="$1" + local out="$OUTPUT_DIR/$rid_name" + mkdir -p "$out" + + echo "==> Building Rust staticlib ($rid_name)..." + local rust_lib_path + rust_lib_path=$(build_rust_staticlib "$rid_name") + + if [[ -n "$rust_lib_path" && -f "$rust_lib_path" ]]; then + local size + size=$(du -sh "$rust_lib_path" | cut -f1) + echo " Staticlib: $rust_lib_path ($size)" + cp "$rust_lib_path" "$out/" + fi + + echo "==> Building Rust binary ($rid_name)..." + build_rust_binary "$rid_name" "$out" +} - echo "==> Publishing docx-cli ($name / $rid)..." - publish_project "$CLI_PROJECT" "docx-cli" "$rid" "$out" +detect_current_platform() { + local arch + arch="$(uname -m)" + case "$(uname -s)-$arch" in + Darwin-arm64) echo "macos-arm64" ;; + Darwin-x86_64) echo "macos-x64" ;; + Linux-x86_64) echo "linux-x64" ;; + Linux-aarch64) echo "linux-arm64" ;; + *) echo ""; return 1 ;; + esac } main() { local target="${1:-current}" - echo "docx-mcp NativeAOT publisher" - echo "==============================" + echo "docx-mcp NativeAOT publisher (embedded storage)" + echo "=================================================" if [[ "$target" == "all" ]]; then for name in "${!TARGETS[@]}"; do publish_target "$name" done + elif [[ "$target" == "rust" ]]; then + # Build only Rust artifacts for current platform + local rid_name + rid_name=$(detect_current_platform) || { echo "Unsupported platform"; exit 1; } + publish_rust_only "$rid_name" elif [[ "$target" == "current" ]]; then # Detect current platform - local arch rid_name - arch="$(uname -m)" - case "$(uname -s)-$arch" in - Darwin-arm64) rid_name="macos-arm64" ;; - Darwin-x86_64) rid_name="macos-x64" ;; - Linux-x86_64) rid_name="linux-x64" ;; - Linux-aarch64) rid_name="linux-arm64" ;; - *) echo "Unsupported platform: $(uname -s)-$arch"; exit 1 ;; - esac + local rid_name + rid_name=$(detect_current_platform) || { echo "Unsupported platform: $(uname -s)-$(uname -m)"; exit 1; } publish_target "$rid_name" elif [[ -n "${TARGETS[$target]+x}" ]]; then publish_target "$target" else echo "Unknown target: $target" - echo "Available: ${!TARGETS[*]} all current" + echo "Available: ${!TARGETS[*]} all current rust" exit 1 fi diff --git a/src/DocxMcp.Cli/DocxMcp.Cli.csproj b/src/DocxMcp.Cli/DocxMcp.Cli.csproj index 07841a2..8b4ea96 100644 --- a/src/DocxMcp.Cli/DocxMcp.Cli.csproj +++ b/src/DocxMcp.Cli/DocxMcp.Cli.csproj @@ -16,4 +16,13 @@ + + + + + + + + + diff --git a/src/DocxMcp.Cli/Program.cs b/src/DocxMcp.Cli/Program.cs index 16468c6..8811bfa 100644 --- a/src/DocxMcp.Cli/Program.cs +++ b/src/DocxMcp.Cli/Program.cs @@ -1,20 +1,89 @@ using System.Text.Json; using DocxMcp; -using DocxMcp.Cli; using DocxMcp.Diff; using DocxMcp.ExternalChanges; -using DocxMcp.Persistence; +using DocxMcp.Grpc; using DocxMcp.Tools; using Microsoft.Extensions.Logging.Abstractions; // --- Bootstrap --- -var sessionsDir = Environment.GetEnvironmentVariable("DOCX_SESSIONS_DIR"); -var store = new SessionStore(NullLogger.Instance, sessionsDir); -var sessions = new SessionManager(store, NullLogger.Instance); -var externalTracker = new ExternalChangeTracker(sessions, NullLogger.Instance); -sessions.SetExternalChangeTracker(externalTracker); -sessions.RestoreSessions(); +// Parse global --tenant flag first +var tenantId = TenantContextHelper.LocalTenant; +var filteredArgs = new List(); +for (int i = 0; i < args.Length; i++) +{ + if (args[i] == "--tenant" && i + 1 < args.Length) + { + tenantId = args[i + 1]; + i++; // Skip the value + } + else if (!args[i].StartsWith("--tenant=")) + { + filteredArgs.Add(args[i]); + } + else + { + tenantId = args[i].Substring("--tenant=".Length); + } +} +args = filteredArgs.ToArray(); + +// Set tenant context for all operations +TenantContextHelper.CurrentTenantId = tenantId; + +// Create gRPC storage clients (embedded or remote) +var isDebug = Environment.GetEnvironmentVariable("DEBUG") is not null; +var storageOptions = StorageClientOptions.FromEnvironment(); +IHistoryStorage historyStorage; +ISyncStorage syncStorage; + +if (!string.IsNullOrEmpty(storageOptions.ServerUrl)) +{ + // Dual mode — remote for history, local embedded for sync/watch + if (isDebug) Console.Error.WriteLine("[cli] Using dual mode: remote=" + storageOptions.ServerUrl); + var launcher = new GrpcLauncher(storageOptions, NullLogger.Instance); + historyStorage = HistoryStorageClient.CreateAsync(storageOptions, launcher, NullLogger.Instance).GetAwaiter().GetResult(); + + // Local embedded for sync/watch + NativeStorage.Init(storageOptions.GetEffectiveLocalStorageDir()); + var localHandler = new System.Net.Http.SocketsHttpHandler + { + ConnectCallback = (_, _) => new ValueTask(new InMemoryPipeStream()) + }; + var localChannel = Grpc.Net.Client.GrpcChannel.ForAddress("http://in-memory", new Grpc.Net.Client.GrpcChannelOptions + { + HttpHandler = localHandler + }); + syncStorage = new SyncStorageClient(localChannel, NullLogger.Instance); +} +else +{ + // Embedded mode — single in-memory channel for both + if (isDebug) Console.Error.WriteLine("[cli] Using embedded mode (in-memory gRPC)"); + NativeStorage.Init(storageOptions.GetEffectiveLocalStorageDir()); + if (isDebug) Console.Error.WriteLine("[cli] NativeStorage initialized, creating GrpcChannel..."); + var handler = new System.Net.Http.SocketsHttpHandler + { + ConnectCallback = (context, ct) => + { + if (isDebug) Console.Error.WriteLine($"[cli] ConnectCallback: {context.DnsEndPoint.Host}:{context.DnsEndPoint.Port}"); + return new ValueTask(new InMemoryPipeStream()); + } + }; + var channel = Grpc.Net.Client.GrpcChannel.ForAddress("http://in-memory", new Grpc.Net.Client.GrpcChannelOptions + { + HttpHandler = handler + }); + historyStorage = new HistoryStorageClient(channel, NullLogger.Instance); + syncStorage = new SyncStorageClient(channel, NullLogger.Instance); +} + +var sessions = new SessionManager(historyStorage, NullLogger.Instance); +var tenant = new TenantScope(sessions); +var syncManager = new SyncManager(syncStorage, NullLogger.Instance); +var gate = new ExternalChangeGate(historyStorage); +var docToolsLogger = NullLogger.Instance; if (args.Length == 0) { @@ -36,16 +105,18 @@ string ResolveDocId(string idOrPath) var result = command switch { "open" => CmdOpen(args), - "list" => DocumentTools.DocumentList(sessions), - "close" => DocumentTools.DocumentClose(sessions, null, ResolveDocId(Require(args, 1, "doc_id_or_path"))), - "save" => DocumentTools.DocumentSave(sessions, null, ResolveDocId(Require(args, 1, "doc_id_or_path")), GetNonFlagArg(args, 2)), - "snapshot" => DocumentTools.DocumentSnapshot(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "list" => DocumentTools.DocumentList(docToolsLogger, tenant), + "close" => DocumentTools.DocumentClose(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path"))), + "save" => DocumentTools.DocumentSave(docToolsLogger, tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), GetNonFlagArg(args, 2)), + "set-source" => DocumentTools.DocumentSetSource(docToolsLogger, tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), + Require(args, 2, "path"), auto_sync: !HasFlag(args, "--no-auto-sync")), + "snapshot" => DocumentTools.DocumentSnapshot(tenant, ResolveDocId(Require(args, 1, "doc_id_or_path")), HasFlag(args, "--discard-redo")), - "query" => QueryTool.Query(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), Require(args, 2, "path"), + "query" => QueryTool.Query(tenant, ResolveDocId(Require(args, 1, "doc_id_or_path")), Require(args, 2, "path"), OptNamed(args, "--format") ?? "json", ParseIntOpt(OptNamed(args, "--offset")), ParseIntOpt(OptNamed(args, "--limit"))), - "count" => CountTool.CountElements(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), Require(args, 2, "path")), + "count" => CountTool.CountElements(tenant, ResolveDocId(Require(args, 1, "doc_id_or_path")), Require(args, 2, "path")), // Generic patch (multi-operation) "patch" => CmdPatch(args), @@ -65,14 +136,14 @@ string ResolveDocId(string idOrPath) "style-table" => CmdStyleTable(args), // History commands - "undo" => HistoryTools.DocumentUndo(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "undo" => HistoryTools.DocumentUndo(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), ParseInt(GetNonFlagArg(args, 2), 1)), - "redo" => HistoryTools.DocumentRedo(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "redo" => HistoryTools.DocumentRedo(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), ParseInt(GetNonFlagArg(args, 2), 1)), - "history" => HistoryTools.DocumentHistory(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "history" => HistoryTools.DocumentHistory(tenant, ResolveDocId(Require(args, 1, "doc_id_or_path")), ParseInt(OptNamed(args, "--offset"), 0), ParseInt(OptNamed(args, "--limit"), 20)), - "jump-to" => HistoryTools.DocumentJumpTo(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "jump-to" => HistoryTools.DocumentJumpTo(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), int.Parse(Require(args, 2, "position"))), // Comment commands @@ -81,12 +152,7 @@ string ResolveDocId(string idOrPath) "comment-delete" => CmdCommentDelete(args), // Export commands - "export-html" => ExportTools.ExportHtml(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), - Require(args, 2, "output_path")), - "export-markdown" => ExportTools.ExportMarkdown(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), - Require(args, 2, "output_path")), - "export-pdf" => ExportTools.ExportPdf(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), - Require(args, 2, "output_path")).GetAwaiter().GetResult(), + "export" => CmdExport(args), // Read commands "read-section" => CmdReadSection(args), @@ -94,11 +160,11 @@ string ResolveDocId(string idOrPath) // Revision (Track Changes) commands "revision-list" => CmdRevisionList(args), - "revision-accept" => RevisionTools.RevisionAccept(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "revision-accept" => RevisionTools.RevisionAccept(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), int.Parse(Require(args, 2, "revision_id"))), - "revision-reject" => RevisionTools.RevisionReject(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "revision-reject" => RevisionTools.RevisionReject(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), int.Parse(Require(args, 2, "revision_id"))), - "track-changes-enable" => RevisionTools.TrackChangesEnable(sessions, ResolveDocId(Require(args, 1, "doc_id_or_path")), + "track-changes-enable" => RevisionTools.TrackChangesEnable(tenant, syncManager, ResolveDocId(Require(args, 1, "doc_id_or_path")), ParseBool(Require(args, 2, "enabled"))), // Diff commands @@ -131,7 +197,7 @@ string ResolveDocId(string idOrPath) string CmdOpen(string[] a) { var path = GetNonFlagArg(a, 1); - return DocumentTools.DocumentOpen(sessions, null, path); + return DocumentTools.DocumentOpen(docToolsLogger, tenant, syncManager, path); } string CmdPatch(string[] a) @@ -140,7 +206,7 @@ string CmdPatch(string[] a) var dryRun = HasFlag(a, "--dry-run"); // patches can be arg[2] or read from stdin var patches = GetNonFlagArg(a, 2) ?? ReadStdin(); - return PatchTool.ApplyPatch(sessions, null, docId, patches, dryRun); + return PatchTool.ApplyPatch(tenant, syncManager, gate, docId, patches, dryRun); } string CmdAdd(string[] a) @@ -149,7 +215,7 @@ string CmdAdd(string[] a) var path = Require(a, 2, "path"); var value = GetNonFlagArg(a, 3) ?? ReadStdin(); var dryRun = HasFlag(a, "--dry-run"); - return ElementTools.AddElement(sessions, null, docId, path, value, dryRun); + return ElementTools.AddElement(tenant, syncManager, gate, docId, path, value, dryRun); } string CmdReplace(string[] a) @@ -158,7 +224,7 @@ string CmdReplace(string[] a) var path = Require(a, 2, "path"); var value = GetNonFlagArg(a, 3) ?? ReadStdin(); var dryRun = HasFlag(a, "--dry-run"); - return ElementTools.ReplaceElement(sessions, null, docId, path, value, dryRun); + return ElementTools.ReplaceElement(tenant, syncManager, gate, docId, path, value, dryRun); } string CmdRemove(string[] a) @@ -166,7 +232,7 @@ string CmdRemove(string[] a) var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); var path = Require(a, 2, "path"); var dryRun = HasFlag(a, "--dry-run"); - return ElementTools.RemoveElement(sessions, null, docId, path, dryRun); + return ElementTools.RemoveElement(tenant, syncManager, gate, docId, path, dryRun); } string CmdMove(string[] a) @@ -175,7 +241,7 @@ string CmdMove(string[] a) var from = Require(a, 2, "from"); var to = Require(a, 3, "to"); var dryRun = HasFlag(a, "--dry-run"); - return ElementTools.MoveElement(sessions, null, docId, from, to, dryRun); + return ElementTools.MoveElement(tenant, syncManager, gate, docId, from, to, dryRun); } string CmdCopy(string[] a) @@ -184,7 +250,7 @@ string CmdCopy(string[] a) var from = Require(a, 2, "from"); var to = Require(a, 3, "to"); var dryRun = HasFlag(a, "--dry-run"); - return ElementTools.CopyElement(sessions, null, docId, from, to, dryRun); + return ElementTools.CopyElement(tenant, syncManager, gate, docId, from, to, dryRun); } string CmdReplaceText(string[] a) @@ -195,7 +261,7 @@ string CmdReplaceText(string[] a) var replace = Require(a, 4, "replace"); var maxCount = ParseInt(OptNamed(a, "--max-count"), 1); var dryRun = HasFlag(a, "--dry-run"); - return TextTools.ReplaceText(sessions, null, docId, path, find, replace, maxCount, dryRun); + return TextTools.ReplaceText(tenant, syncManager, gate, docId, path, find, replace, maxCount, dryRun); } string CmdRemoveColumn(string[] a) @@ -204,7 +270,7 @@ string CmdRemoveColumn(string[] a) var path = Require(a, 2, "path"); var column = int.Parse(Require(a, 3, "column")); var dryRun = HasFlag(a, "--dry-run"); - return TableTools.RemoveTableColumn(sessions, null, docId, path, column, dryRun); + return TableTools.RemoveTableColumn(tenant, syncManager, gate, docId, path, column, dryRun); } string CmdStyleElement(string[] a) @@ -212,7 +278,7 @@ string CmdStyleElement(string[] a) var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); var style = Require(a, 2, "style"); var path = OptNamed(a, "--path") ?? GetNonFlagArg(a, 3); - return StyleTools.StyleElement(sessions, docId, style, path); + return StyleTools.StyleElement(tenant, syncManager, docId, style, path); } string CmdStyleParagraph(string[] a) @@ -220,7 +286,7 @@ string CmdStyleParagraph(string[] a) var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); var style = Require(a, 2, "style"); var path = OptNamed(a, "--path") ?? GetNonFlagArg(a, 3); - return StyleTools.StyleParagraph(sessions, docId, style, path); + return StyleTools.StyleParagraph(tenant, syncManager, docId, style, path); } string CmdStyleTable(string[] a) @@ -230,7 +296,7 @@ string CmdStyleTable(string[] a) var cellStyle = OptNamed(a, "--cell-style"); var rowStyle = OptNamed(a, "--row-style"); var path = OptNamed(a, "--path"); - return StyleTools.StyleTable(sessions, docId, style, cellStyle, rowStyle, path); + return StyleTools.StyleTable(tenant, syncManager, docId, style, cellStyle, rowStyle, path); } string CmdCommentAdd(string[] a) @@ -241,7 +307,7 @@ string CmdCommentAdd(string[] a) var anchorText = OptNamed(a, "--anchor-text"); var author = OptNamed(a, "--author"); var initials = OptNamed(a, "--initials"); - return CommentTools.CommentAdd(sessions, docId, path, text, anchorText, author, initials); + return CommentTools.CommentAdd(tenant, syncManager, docId, path, text, anchorText, author, initials); } string CmdCommentList(string[] a) @@ -250,7 +316,7 @@ string CmdCommentList(string[] a) var author = OptNamed(a, "--author"); var offset = ParseIntOpt(OptNamed(a, "--offset")); var limit = ParseIntOpt(OptNamed(a, "--limit")); - return CommentTools.CommentList(sessions, docId, author, offset, limit); + return CommentTools.CommentList(tenant, docId, author, offset, limit); } string CmdCommentDelete(string[] a) @@ -258,7 +324,28 @@ string CmdCommentDelete(string[] a) var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); var commentId = ParseIntOpt(OptNamed(a, "--id")); var author = OptNamed(a, "--author"); - return CommentTools.CommentDelete(sessions, docId, commentId, author); + return CommentTools.CommentDelete(tenant, syncManager, docId, commentId, author); +} + +string CmdExport(string[] a) +{ + var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); + var format = Require(a, 2, "format"); + var outputPath = a.Length > 3 ? a[3] : null; + + var content = ExportTools.Export(tenant, docId, format).GetAwaiter().GetResult(); + + // If an output path is given, write to file (for CLI convenience) + if (outputPath is not null) + { + if (format is "pdf" or "docx") + File.WriteAllBytes(outputPath, Convert.FromBase64String(content)); + else + File.WriteAllText(outputPath, content); + return $"Exported to '{outputPath}'."; + } + + return content; } string CmdReadSection(string[] a) @@ -268,7 +355,7 @@ string CmdReadSection(string[] a) var format = OptNamed(a, "--format"); var offset = ParseIntOpt(OptNamed(a, "--offset")); var limit = ParseIntOpt(OptNamed(a, "--limit")); - return ReadSectionTool.ReadSection(sessions, docId, sectionIndex, format, offset, limit); + return ReadSectionTool.ReadSection(tenant, docId, sectionIndex, format, offset, limit); } string CmdReadHeading(string[] a) @@ -281,7 +368,7 @@ string CmdReadHeading(string[] a) var format = OptNamed(a, "--format"); var offset = ParseIntOpt(OptNamed(a, "--offset")); var limit = ParseIntOpt(OptNamed(a, "--limit")); - return ReadHeadingContentTool.ReadHeadingContent(sessions, docId, + return ReadHeadingContentTool.ReadHeadingContent(tenant, docId, headingText, headingIndex, headingLevel, includeSubHeadings, format, offset, limit); } @@ -292,7 +379,7 @@ string CmdRevisionList(string[] a) var type = OptNamed(a, "--type"); var offset = ParseIntOpt(OptNamed(a, "--offset")); var limit = ParseIntOpt(OptNamed(a, "--limit")); - return RevisionTools.RevisionList(sessions, docId, author, type, offset, limit); + return RevisionTools.RevisionList(tenant, docId, author, type, offset, limit); } string CmdDiff(string[] a) @@ -303,7 +390,7 @@ string CmdDiff(string[] a) var threshold = ParseDouble(OptNamed(a, "--threshold"), DiffEngine.DefaultSimilarityThreshold); var format = OptNamed(a, "--format") ?? "text"; - var session = sessions.Get(docId); + using var session = sessions.Get(docId); var targetPath = filePath ?? session.SourcePath ?? throw new ArgumentException("No file path specified and session has no source file."); @@ -420,137 +507,25 @@ string CmdCheckExternal(string[] a) { var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); var acknowledge = HasFlag(a, "--acknowledge"); - - // Check for pending changes first, then check for new changes - var pending = externalTracker.GetLatestUnacknowledgedChange(docId); - if (pending is null) - { - pending = externalTracker.CheckForChanges(docId); - } - - if (pending is null) - { - return "No external changes detected. The document is in sync with the source file."; - } - - // Acknowledge if requested - if (acknowledge) - { - externalTracker.AcknowledgeChange(docId, pending.Id); - } - - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"External changes detected in '{Path.GetFileName(pending.SourcePath)}'"); - sb.AppendLine($"Detected at: {pending.DetectedAt:yyyy-MM-dd HH:mm:ss UTC}"); - sb.AppendLine(); - sb.AppendLine($"Summary: +{pending.Summary.Added} -{pending.Summary.Removed} ~{pending.Summary.Modified}"); - sb.AppendLine(); - sb.AppendLine($"Change ID: {pending.Id}"); - sb.AppendLine($"Source: {pending.SourcePath}"); - sb.AppendLine($"Status: {(pending.Acknowledged || acknowledge ? "Acknowledged" : "Pending")}"); - - if (!pending.Acknowledged && !acknowledge) - { - sb.AppendLine(); - sb.AppendLine("Use --acknowledge to acknowledge, or use 'sync-external' to sync."); - } - - return sb.ToString(); + return ExternalChangeTools.GetExternalChanges(tenant, syncManager, gate, docId, acknowledge); } string CmdSyncExternal(string[] a) { var docId = ResolveDocId(Require(a, 1, "doc_id_or_path")); - var changeId = OptNamed(a, "--change-id"); - - var result = externalTracker.SyncExternalChanges(docId, changeId); - - var sb = new System.Text.StringBuilder(); - sb.AppendLine(result.Message); - - if (result.Success && result.HasChanges) - { - sb.AppendLine(); - sb.AppendLine($"WAL Position: {result.WalPosition}"); - - if (result.Summary is not null) - { - sb.AppendLine(); - sb.AppendLine("Body Changes:"); - sb.AppendLine($" Added: {result.Summary.Added}"); - sb.AppendLine($" Removed: {result.Summary.Removed}"); - sb.AppendLine($" Modified: {result.Summary.Modified}"); - } - - if (result.UncoveredChanges?.Count > 0) - { - sb.AppendLine(); - sb.AppendLine($"Uncovered Changes ({result.UncoveredChanges.Count}):"); - foreach (var uc in result.UncoveredChanges.Take(10)) - { - sb.AppendLine($" [{uc.ChangeKind}] {uc.Type}: {uc.Description}"); - } - if (result.UncoveredChanges.Count > 10) - { - sb.AppendLine($" ... and {result.UncoveredChanges.Count - 10} more"); - } - } - } - - return sb.ToString(); + return ExternalChangeTools.SyncExternalChanges(tenant, syncManager, gate, docId); } -string CmdWatch(string[] a) +string CmdWatch(string[] _) { - var path = Require(a, 1, "path"); - var autoSync = HasFlag(a, "--auto-sync"); - var debounceMs = ParseInt(OptNamed(a, "--debounce"), 500); - var pattern = OptNamed(a, "--pattern") ?? "*.docx"; - var recursive = HasFlag(a, "--recursive"); - - using var daemon = new WatchDaemon(sessions, externalTracker, debounceMs, autoSync); - - var fullPath = Path.GetFullPath(path); - if (File.Exists(fullPath)) - { - // Watch a single file - var sessionId = FindOrCreateSession(fullPath); - daemon.WatchFile(sessionId, fullPath); - } - else if (Directory.Exists(fullPath)) - { - // Watch a folder - daemon.WatchFolder(fullPath, pattern, recursive); - } - else - { - return $"Path not found: {fullPath}"; - } - - // Handle Ctrl+C - var cts = new CancellationTokenSource(); - Console.CancelKeyPress += (_, e) => - { - e.Cancel = true; - cts.Cancel(); - }; - - try - { - daemon.RunAsync(cts.Token).GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - // Expected on Ctrl+C - } - - return "[DAEMON] Stopped."; + return "Watch command removed. External change watching is now handled by the gRPC ExternalWatchService.\n" + + "Use 'check-external' to manually check for changes, or 'sync-external' to sync."; } string CmdInspect(string[] a) { var idOrPath = Require(a, 1, "doc_id_or_path"); - var session = sessions.ResolveSession(idOrPath); + using var session = sessions.ResolveSession(idOrPath); var history = sessions.GetHistory(session.Id); var sb = new System.Text.StringBuilder(); @@ -598,39 +573,9 @@ string CmdInspect(string[] a) } } - // Check for pending external changes - var pending = externalTracker.GetLatestUnacknowledgedChange(session.Id); - if (pending is not null) - { - sb.AppendLine(); - sb.AppendLine("Pending External Change:"); - sb.AppendLine($" Change ID: {pending.Id}"); - sb.AppendLine($" Detected: {pending.DetectedAt:yyyy-MM-dd HH:mm:ss} UTC"); - sb.AppendLine($" Summary: +{pending.Summary.Added} -{pending.Summary.Removed} ~{pending.Summary.Modified}"); - } - return sb.ToString(); } -string FindOrCreateSession(string filePath) -{ - // Check if session already exists for this file - foreach (var (id, sessPath) in sessions.List()) - { - if (sessPath is not null && Path.GetFullPath(sessPath) == Path.GetFullPath(filePath)) - { - return id; - } - } - - // Create new session (use EnsureTracked instead of StartWatching - // to avoid creating an FSW that competes with the WatchDaemon) - var session = sessions.Open(filePath); - externalTracker.EnsureTracked(session.Id); - Console.WriteLine($"[SESSION] Created session {session.Id} for {Path.GetFileName(filePath)}"); - return session.Id; -} - // --- Argument helpers --- static string Require(string[] a, int idx, string name) @@ -704,6 +649,7 @@ static void PrintUsage() open [path] Open file or create new document list List open sessions save [output_path] Save document to disk + set-source [--no-auto-sync] Set/change save target inspect Show detailed session information Administrative commands (CLI-only, not exposed to MCP): @@ -752,9 +698,7 @@ Generic patch (multi-operation): track-changes-enable Enable/disable Track Changes Export commands: - export-html - export-markdown - export-pdf + export [output_path] (format: html, markdown, pdf, docx) Diff commands: diff [file_path] [--threshold 0.6] [--format text|json|patch] @@ -770,15 +714,17 @@ Sync session with external file (records in WAL) watch [--auto-sync] [--debounce ms] [--pattern *.docx] [--recursive] Watch file or folder for changes (daemon mode) - Options: - --dry-run Simulate operation without applying changes + Global options: + --tenant Specify tenant ID for multi-tenant deployments (optional) + --dry-run Simulate operation without applying changes Environment: - DOCX_SESSIONS_DIR Override sessions directory (shared with MCP server) + STORAGE_GRPC_URL gRPC storage server URL (auto-launches local if not set) + DOCX_SESSIONS_DIR Override sessions directory (legacy, for local storage) DOCX_WAL_COMPACT_THRESHOLD Auto-compact WAL after N entries (default: 50) DOCX_CHECKPOINT_INTERVAL Create checkpoint every N entries (default: 10) DOCX_AUTO_SAVE Auto-save to source file after each edit (default: true) - DEBUG Enable debug logging for sync operations + DEBUG Enable debug logging for sync operations Sessions persist between invocations and are shared with the MCP server. WAL history is preserved automatically; use 'close' to permanently delete a session. diff --git a/src/DocxMcp.Cli/WatchDaemon.cs b/src/DocxMcp.Cli/WatchDaemon.cs deleted file mode 100644 index 1f40b33..0000000 --- a/src/DocxMcp.Cli/WatchDaemon.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System.Collections.Concurrent; -using DocxMcp; -using DocxMcp.ExternalChanges; - -namespace DocxMcp.Cli; - -/// -/// File/folder watch daemon for continuous monitoring of external document changes. -/// Can run in notification-only or auto-sync mode. -/// -public sealed class WatchDaemon : IDisposable -{ - private readonly SessionManager _sessions; - private readonly ExternalChangeTracker _tracker; - private readonly ConcurrentDictionary _watchers = new(); - private readonly ConcurrentDictionary _debounceTimestamps = new(); - private readonly int _debounceMs; - private readonly bool _autoSync; - private readonly Action _onOutput; - private readonly CancellationTokenSource _cts = new(); - private bool _disposed; - - public WatchDaemon( - SessionManager sessions, - ExternalChangeTracker tracker, - int debounceMs = 500, - bool autoSync = false, - Action? onOutput = null) - { - _sessions = sessions; - _tracker = tracker; - _debounceMs = debounceMs; - _autoSync = autoSync; - _onOutput = onOutput ?? Console.WriteLine; - } - - /// - /// Watch a single file for changes. - /// - /// Session ID associated with the file. - /// Path to the file to watch. - public void WatchFile(string sessionId, string filePath) - { - if (_disposed) throw new ObjectDisposedException(nameof(WatchDaemon)); - - var fullPath = Path.GetFullPath(filePath); - if (!File.Exists(fullPath)) - { - _onOutput($"[WARN] File not found: {fullPath}"); - return; - } - - var directory = Path.GetDirectoryName(fullPath)!; - var fileName = Path.GetFileName(fullPath); - - var watcher = new FileSystemWatcher(directory, fileName) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - EnableRaisingEvents = true - }; - - watcher.Changed += (_, e) => OnFileChanged(sessionId, e.FullPath); - watcher.Renamed += (_, e) => OnFileRenamed(sessionId, e.OldFullPath, e.FullPath); - watcher.Deleted += (_, e) => OnFileDeleted(sessionId, e.FullPath); - - // Stop the tracker's internal FSW to avoid dual-watcher race condition. - // The daemon will drive change detection via CheckForChanges. - _tracker.StopWatching(sessionId); - _tracker.EnsureTracked(sessionId); - - _watchers[$"{sessionId}:{fullPath}"] = watcher; - _onOutput($"[WATCH] Watching {fileName} for session {sessionId}"); - - // Initial sync — diff + import before watching - _onOutput($"[INIT] Running initial sync for {fileName}..."); - try - { - ProcessChange(sessionId, fullPath, isImport: true); - } - catch (Exception ex) - { - _onOutput($"[WARN] Initial sync failed: {ex.Message}"); - } - } - - /// - /// Watch a folder for .docx file changes. - /// Creates sessions for files that don't have one. - /// - /// Path to the folder to watch. - /// File pattern to match (default: *.docx). - /// Whether to watch subdirectories. - public void WatchFolder(string folderPath, string pattern = "*.docx", bool includeSubdirectories = false) - { - if (_disposed) throw new ObjectDisposedException(nameof(WatchDaemon)); - - var fullPath = Path.GetFullPath(folderPath); - if (!Directory.Exists(fullPath)) - { - _onOutput($"[WARN] Directory not found: {fullPath}"); - return; - } - - var watcher = new FileSystemWatcher(fullPath, pattern) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - IncludeSubdirectories = includeSubdirectories, - EnableRaisingEvents = true - }; - - watcher.Changed += (_, e) => OnFolderFileChanged(e.FullPath); - watcher.Created += (_, e) => OnFolderFileCreated(e.FullPath); - watcher.Renamed += (_, e) => OnFolderFileRenamed(e.OldFullPath, e.FullPath); - watcher.Deleted += (_, e) => OnFolderFileDeleted(e.FullPath); - - _watchers[$"folder:{fullPath}"] = watcher; - _onOutput($"[WATCH] Watching folder {fullPath} for {pattern}"); - - // Register existing files and run initial sync - foreach (var file in Directory.EnumerateFiles(fullPath, pattern, - includeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly)) - { - var registeredSessionId = TryRegisterExistingFile(file); - if (registeredSessionId is not null) - { - _onOutput($"[INIT] Running initial sync for {Path.GetFileName(file)}..."); - try - { - ProcessChange(registeredSessionId, file, isImport: true); - } - catch (Exception ex) - { - _onOutput($"[WARN] Initial sync failed for {Path.GetFileName(file)}: {ex.Message}"); - } - } - } - } - - /// - /// Run the daemon until cancellation is requested. - /// - public async Task RunAsync(CancellationToken cancellationToken = default) - { - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token); - - _onOutput($"[DAEMON] Started. Mode: {(_autoSync ? "auto-sync" : "notify-only")}. Press Ctrl+C to stop."); - - try - { - await Task.Delay(Timeout.Infinite, linked.Token); - } - catch (OperationCanceledException) - { - _onOutput("[DAEMON] Stopping..."); - } - } - - /// - /// Stop the daemon. - /// - public void Stop() - { - _cts.Cancel(); - } - - private static bool DebugEnabled => - Environment.GetEnvironmentVariable("DEBUG") is not null; - - private void OnFileChanged(string sessionId, string filePath) - { - if (DebugEnabled) - _onOutput($"[DEBUG:watch] FSW fired for {Path.GetFileName(filePath)} (session {sessionId})"); - - // Debounce - var key = $"{sessionId}:{filePath}"; - var now = DateTime.UtcNow; - if (_debounceTimestamps.TryGetValue(key, out var last) && - (now - last).TotalMilliseconds < _debounceMs) - { - if (DebugEnabled) - _onOutput($"[DEBUG:watch] Debounced (last: {(now - last).TotalMilliseconds:F0}ms ago, threshold: {_debounceMs}ms)"); - return; - } - _debounceTimestamps[key] = now; - - if (DebugEnabled) - _onOutput($"[DEBUG:watch] Scheduling ProcessChange after {_debounceMs}ms debounce"); - - // Wait for debounce period - Task.Delay(_debounceMs).ContinueWith(_ => - { - try - { - ProcessChange(sessionId, filePath); - } - catch (Exception ex) - { - _onOutput($"[ERROR] {sessionId}: {ex.Message}"); - if (DebugEnabled) - _onOutput($"[DEBUG:watch] Exception: {ex}"); - } - }); - } - - private void ProcessChange(string sessionId, string filePath, bool isImport = false) - { - if (DebugEnabled) - _onOutput($"[DEBUG:watch] ProcessChange called for {Path.GetFileName(filePath)}"); - - // Always sync into WAL — watch is meant to keep the session in sync automatically - var result = _tracker.SyncExternalChanges(sessionId, isImport: isImport); - if (DebugEnabled) - _onOutput($"[DEBUG:watch] SyncResult: HasChanges={result.HasChanges}, Message={result.Message}"); - - if (result.HasChanges) - { - _onOutput($"[SYNC] {Path.GetFileName(filePath)} (+{result.Summary!.Added} -{result.Summary.Removed} ~{result.Summary.Modified}) WAL:{result.WalPosition}"); - - if (result.Patches is { Count: > 0 }) - { - var patchesArr = new System.Text.Json.Nodes.JsonArray( - result.Patches.Select(p => (System.Text.Json.Nodes.JsonNode?)System.Text.Json.Nodes.JsonNode.Parse(p.ToJsonString())).ToArray()); - _onOutput(patchesArr.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); - } - - if (result.UncoveredChanges is { Count: > 0 }) - { - _onOutput($" Uncovered: {string.Join(", ", result.UncoveredChanges.Select(u => $"[{u.ChangeKind}] {u.Type}"))}"); - } - } - } - - private void OnFileRenamed(string sessionId, string oldPath, string newPath) - { - _onOutput($"[RENAME] {sessionId}: {Path.GetFileName(oldPath)} -> {Path.GetFileName(newPath)}"); - } - - private void OnFileDeleted(string sessionId, string filePath) - { - _onOutput($"[DELETE] {sessionId}: {Path.GetFileName(filePath)} - source file deleted!"); - } - - private void OnFolderFileChanged(string filePath) - { - var sessionId = FindSessionForFile(filePath); - if (sessionId is not null) - { - OnFileChanged(sessionId, filePath); - } - } - - private void OnFolderFileCreated(string filePath) - { - _onOutput($"[NEW] {Path.GetFileName(filePath)} created. Use 'open {filePath}' to start a session."); - } - - private void OnFolderFileRenamed(string oldPath, string newPath) - { - _onOutput($"[RENAME] {Path.GetFileName(oldPath)} -> {Path.GetFileName(newPath)}"); - } - - private void OnFolderFileDeleted(string filePath) - { - var sessionId = FindSessionForFile(filePath); - if (sessionId is not null) - { - _onOutput($"[DELETE] {Path.GetFileName(filePath)} deleted (session {sessionId} orphaned)"); - } - else - { - _onOutput($"[DELETE] {Path.GetFileName(filePath)} deleted"); - } - } - - private string? FindSessionForFile(string filePath) - { - var fullPath = Path.GetFullPath(filePath); - foreach (var (id, path) in _sessions.List()) - { - if (path is not null && Path.GetFullPath(path) == fullPath) - { - return id; - } - } - return null; - } - - private string? TryRegisterExistingFile(string filePath) - { - var sessionId = FindSessionForFile(filePath); - if (sessionId is not null) - { - _tracker.StartWatching(sessionId); - _onOutput($"[TRACK] {Path.GetFileName(filePath)} -> session {sessionId}"); - } - return sessionId; - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - - _cts.Cancel(); - _cts.Dispose(); - - foreach (var watcher in _watchers.Values) - { - watcher.EnableRaisingEvents = false; - watcher.Dispose(); - } - _watchers.Clear(); - } -} diff --git a/src/DocxMcp.Grpc/DocxMcp.Grpc.csproj b/src/DocxMcp.Grpc/DocxMcp.Grpc.csproj new file mode 100644 index 0000000..a5d269e --- /dev/null +++ b/src/DocxMcp.Grpc/DocxMcp.Grpc.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + DocxMcp.Grpc + enable + enable + true + false + true + + + + + + + + + + + + + + + + + + diff --git a/src/DocxMcp.Grpc/GrpcLauncher.cs b/src/DocxMcp.Grpc/GrpcLauncher.cs new file mode 100644 index 0000000..f42717d --- /dev/null +++ b/src/DocxMcp.Grpc/GrpcLauncher.cs @@ -0,0 +1,411 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; + +namespace DocxMcp.Grpc; + +/// +/// Handles auto-launching the gRPC storage server for local mode. +/// On Unix: uses Unix domain sockets with PID-based unique paths. +/// On Windows: uses TCP with dynamically allocated ports. +/// +public sealed class GrpcLauncher : IDisposable +{ + private readonly StorageClientOptions _options; + private readonly ILogger? _logger; + private Process? _serverProcess; + private string? _launchedSocketPath; + private int? _launchedPort; + private bool _disposed; + + public GrpcLauncher(StorageClientOptions options, ILogger? logger = null) + { + _options = options; + _logger = logger; + + // Ensure child process is killed when parent exits + AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose(); + Console.CancelKeyPress += (_, _) => Dispose(); + } + + /// + /// Ensure the gRPC server is running. + /// Returns the connection string to use. + /// + public async Task EnsureServerRunningAsync(CancellationToken cancellationToken = default) + { + // If a server URL is configured, use it directly (no auto-launch) + if (!string.IsNullOrEmpty(_options.ServerUrl)) + { + _logger?.LogDebug("Using configured server URL: {Url}", _options.ServerUrl); + return _options.ServerUrl; + } + + if (OperatingSystem.IsWindows()) + { + return await EnsureServerRunningTcpAsync(cancellationToken); + } + else + { + return await EnsureServerRunningUnixAsync(cancellationToken); + } + } + + private async Task EnsureServerRunningUnixAsync(CancellationToken cancellationToken) + { + var socketPath = _options.GetEffectiveSocketPath(); + + // Check if server is already running at this socket + if (await IsUnixServerRunningAsync(socketPath, cancellationToken)) + { + _logger?.LogDebug("Storage server already running at {SocketPath}", socketPath); + return $"unix://{socketPath}"; + } + + if (!_options.AutoLaunch) + { + throw new InvalidOperationException( + $"Storage server not running at {socketPath} and auto-launch is disabled. " + + "Set STORAGE_GRPC_URL or start the server manually."); + } + + // Auto-launch the server + await LaunchUnixServerAsync(socketPath, cancellationToken); + _launchedSocketPath = socketPath; + + return $"unix://{socketPath}"; + } + + private async Task EnsureServerRunningTcpAsync(CancellationToken cancellationToken) + { + // On Windows, we need to find an available port + var port = GetAvailablePort(); + + if (!_options.AutoLaunch) + { + throw new InvalidOperationException( + "Storage server not running and auto-launch is disabled. " + + "Set STORAGE_GRPC_URL or start the server manually."); + } + + // Auto-launch the server on TCP + await LaunchTcpServerAsync(port, cancellationToken); + _launchedPort = port; + + return $"http://127.0.0.1:{port}"; + } + + private static int GetAvailablePort() + { + // Let the OS assign an available port + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private async Task IsUnixServerRunningAsync(string socketPath, CancellationToken cancellationToken) + { + if (!File.Exists(socketPath)) + return false; + + try + { + using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + var endpoint = new UnixDomainSocketEndPoint(socketPath); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + + await socket.ConnectAsync(endpoint, cts.Token); + return true; + } + catch (Exception ex) when (ex is SocketException or OperationCanceledException) + { + _logger?.LogDebug("Socket exists but server not responding: {Error}", ex.Message); + return false; + } + } + + private async Task IsTcpServerRunningAsync(int port, CancellationToken cancellationToken) + { + try + { + using var client = new TcpClient(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + + await client.ConnectAsync(IPAddress.Loopback, port, cts.Token); + return true; + } + catch (Exception ex) when (ex is SocketException or OperationCanceledException) + { + _logger?.LogDebug("TCP port {Port} not responding: {Error}", port, ex.Message); + return false; + } + } + + private async Task LaunchUnixServerAsync(string socketPath, CancellationToken cancellationToken) + { + var serverPath = FindServerBinary(); + if (serverPath is null) + { + throw new FileNotFoundException( + "Could not find docx-storage-local binary. " + + "Set STORAGE_SERVER_PATH or ensure it's in PATH."); + } + + _logger?.LogInformation("Launching storage server: {Path} (unix socket: {Socket})", serverPath, socketPath); + + // Remove stale socket file + if (File.Exists(socketPath)) + { + try { File.Delete(socketPath); } + catch { /* ignore */ } + } + + // Ensure parent directory exists + var socketDir = Path.GetDirectoryName(socketPath); + if (socketDir is not null && !Directory.Exists(socketDir)) + { + Directory.CreateDirectory(socketDir); + } + + var parentPid = Environment.ProcessId; + var logFile = GetLogFilePath(); + var localStorageDir = _options.GetEffectiveLocalStorageDir(); + + var startInfo = new ProcessStartInfo + { + FileName = serverPath, + Arguments = $"--transport unix --unix-socket \"{socketPath}\" --local-storage-dir \"{localStorageDir}\" --parent-pid {parentPid}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.Environment["RUST_LOG"] = "info"; + + _logger?.LogDebug("Storage directory: {Dir}", localStorageDir); + + await LaunchAndWaitAsync(startInfo, () => IsUnixServerRunningAsync(socketPath, cancellationToken), logFile, cancellationToken); + } + + private async Task LaunchTcpServerAsync(int port, CancellationToken cancellationToken) + { + var serverPath = FindServerBinary(); + if (serverPath is null) + { + throw new FileNotFoundException( + "Could not find docx-storage-local binary. " + + "Set STORAGE_SERVER_PATH or ensure it's in PATH."); + } + + _logger?.LogInformation("Launching storage server: {Path} (tcp port: {Port})", serverPath, port); + + var parentPid = Environment.ProcessId; + var logFile = GetLogFilePath(); + var localStorageDir = _options.GetEffectiveLocalStorageDir(); + + var startInfo = new ProcessStartInfo + { + FileName = serverPath, + Arguments = $"--transport tcp --port {port} --local-storage-dir \"{localStorageDir}\" --parent-pid {parentPid}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.Environment["RUST_LOG"] = "info"; + + _logger?.LogDebug("Storage directory: {Dir}", localStorageDir); + + await LaunchAndWaitAsync(startInfo, () => IsTcpServerRunningAsync(port, cancellationToken), logFile, cancellationToken); + } + + private async Task LaunchAndWaitAsync(ProcessStartInfo startInfo, Func> isRunning, string logFile, CancellationToken cancellationToken) + { + _serverProcess = new Process { StartInfo = startInfo }; + _serverProcess.Start(); + + // Redirect output to log file for debugging + _ = Task.Run(async () => + { + try + { + await using var logStream = new FileStream(logFile, FileMode.Create, FileAccess.Write, FileShare.Read); + await using var writer = new StreamWriter(logStream) { AutoFlush = true }; + + var stderrTask = Task.Run(async () => + { + string? line; + while ((line = await _serverProcess.StandardError.ReadLineAsync(cancellationToken)) is not null) + { + await writer.WriteLineAsync($"[stderr] {line}"); + } + }, cancellationToken); + + var stdoutTask = Task.Run(async () => + { + string? line; + while ((line = await _serverProcess.StandardOutput.ReadLineAsync(cancellationToken)) is not null) + { + await writer.WriteLineAsync($"[stdout] {line}"); + } + }, cancellationToken); + + await Task.WhenAll(stderrTask, stdoutTask); + } + catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) + { + // Expected when process exits + } + }, cancellationToken); + + _logger?.LogInformation("Storage server log file: {LogFile}", logFile); + + var maxWait = _options.ConnectTimeout; + var pollInterval = TimeSpan.FromMilliseconds(100); + var elapsed = TimeSpan.Zero; + + while (elapsed < maxWait) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_serverProcess.HasExited) + { + // Wait a bit for log file to be written + await Task.Delay(100, cancellationToken); + var logContent = File.Exists(logFile) ? await File.ReadAllTextAsync(logFile, cancellationToken) : "(no log)"; + throw new InvalidOperationException( + $"Storage server exited unexpectedly with code {_serverProcess.ExitCode}. Log:\n{logContent}"); + } + + if (await isRunning()) + { + _logger?.LogInformation("Storage server started successfully (PID: {Pid})", _serverProcess.Id); + return; + } + + await Task.Delay(pollInterval, cancellationToken); + elapsed += pollInterval; + } + + _serverProcess.Kill(); + throw new TimeoutException( + $"Storage server did not become ready within {maxWait.TotalSeconds} seconds."); + } + + private static string GetLogFilePath() + { + var pid = Environment.ProcessId; + var tempDir = Path.GetTempPath(); + return Path.Combine(tempDir, $"docx-storage-local-{pid}.log"); + } + + private string? FindServerBinary() + { + // Check configured path first + if (!string.IsNullOrEmpty(_options.StorageServerPath)) + { + if (File.Exists(_options.StorageServerPath)) + return _options.StorageServerPath; + _logger?.LogWarning("Configured server path not found: {Path}", _options.StorageServerPath); + } + + var binaryName = OperatingSystem.IsWindows() ? "docx-storage-local.exe" : "docx-storage-local"; + + // Check PATH + var pathEnv = Environment.GetEnvironmentVariable("PATH"); + if (pathEnv is not null) + { + var separator = OperatingSystem.IsWindows() ? ';' : ':'; + + foreach (var dir in pathEnv.Split(separator)) + { + var candidate = Path.Combine(dir, binaryName); + if (File.Exists(candidate)) + return candidate; + } + } + + // Check relative to app base directory + var assemblyDir = AppContext.BaseDirectory; + if (!string.IsNullOrEmpty(assemblyDir)) + { + var platformDir = GetPlatformDir(); + + var relativePaths = new[] + { + // Same directory (for deployed apps) + Path.Combine(assemblyDir, binaryName), + // From tests/DocxMcp.Tests/bin/Debug/net10.0/ -> dist/{platform}/ + Path.Combine(assemblyDir, "..", "..", "..", "..", "..", "dist", platformDir, binaryName), + // From src/*/bin/Debug/net10.0/ -> dist/{platform}/ + Path.Combine(assemblyDir, "..", "..", "..", "..", "..", "dist", platformDir, binaryName), + }; + + foreach (var path in relativePaths) + { + var fullPath = Path.GetFullPath(path); + _logger?.LogDebug("Checking for server binary at: {Path}", fullPath); + if (File.Exists(fullPath)) + return fullPath; + } + } + + return null; + } + + private static string GetPlatformDir() + { + if (OperatingSystem.IsMacOS()) + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "macos-arm64" : "macos-x64"; + if (OperatingSystem.IsLinux()) + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64"; + if (OperatingSystem.IsWindows()) + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "windows-arm64" : "windows-x64"; + return "unknown"; + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + if (_serverProcess is { HasExited: false }) + { + try + { + _logger?.LogInformation("Shutting down storage server (PID: {Pid})", _serverProcess.Id); + _serverProcess.Kill(entireProcessTree: true); + _serverProcess.WaitForExit(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error shutting down storage server"); + } + } + + _serverProcess?.Dispose(); + + // Clean up socket file (Unix only) + if (_launchedSocketPath is not null && File.Exists(_launchedSocketPath)) + { + try + { + File.Delete(_launchedSocketPath); + _logger?.LogDebug("Cleaned up socket file: {SocketPath}", _launchedSocketPath); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to clean up socket file: {SocketPath}", _launchedSocketPath); + } + } + } +} diff --git a/src/DocxMcp.Grpc/HistoryStorageClient.cs b/src/DocxMcp.Grpc/HistoryStorageClient.cs new file mode 100644 index 0000000..12d3b1b --- /dev/null +++ b/src/DocxMcp.Grpc/HistoryStorageClient.cs @@ -0,0 +1,541 @@ +using System.Net.Sockets; +using Grpc.Core; +using Grpc.Net.Client; +using Grpc.Net.Client.Configuration; +using Microsoft.Extensions.Logging; + +namespace DocxMcp.Grpc; + +/// +/// gRPC client for history storage operations (StorageService). +/// Handles sessions, index, WAL, checkpoints, and health check. +/// +public sealed class HistoryStorageClient : IHistoryStorage +{ + private readonly GrpcChannel _channel; + private readonly StorageService.StorageServiceClient _client; + private readonly ILogger? _logger; + private readonly int _chunkSize; + + /// + /// Default chunk size for streaming uploads: 256KB + /// + public const int DefaultChunkSize = 256 * 1024; + + public HistoryStorageClient(GrpcChannel channel, ILogger? logger = null, int chunkSize = DefaultChunkSize) + { + _channel = channel; + _client = new StorageService.StorageServiceClient(channel); + _logger = logger; + _chunkSize = chunkSize; + } + + /// + /// Create a HistoryStorageClient from options. + /// + public static async Task CreateAsync( + StorageClientOptions options, + GrpcLauncher? launcher = null, + ILogger? logger = null, + CancellationToken cancellationToken = default) + { + var channel = await CreateChannelAsync(options, launcher, cancellationToken); + return new HistoryStorageClient(channel, logger); + } + + /// + /// Create GrpcChannelOptions with a retry policy for transient failures. + /// Retries Unavailable status codes with exponential backoff (budget ~25s). + /// + public static GrpcChannelOptions CreateRetryChannelOptions(GrpcChannelOptions? baseOptions = null) + { + var retryPolicy = new RetryPolicy + { + MaxAttempts = 5, + InitialBackoff = TimeSpan.FromSeconds(1), + MaxBackoff = TimeSpan.FromSeconds(10), + BackoffMultiplier = 2, + RetryableStatusCodes = { StatusCode.Unavailable } + }; + + var options = baseOptions ?? new GrpcChannelOptions(); + options.ServiceConfig = new ServiceConfig + { + MethodConfigs = + { + new MethodConfig + { + Names = { MethodName.Default }, + RetryPolicy = retryPolicy + } + } + }; + return options; + } + + internal static async Task CreateChannelAsync( + StorageClientOptions options, + GrpcLauncher? launcher = null, + CancellationToken cancellationToken = default) + { + string address; + + if (!string.IsNullOrEmpty(options.ServerUrl)) + { + address = options.ServerUrl; + } + else if (launcher is not null) + { + address = await launcher.EnsureServerRunningAsync(cancellationToken); + } + else + { + throw new InvalidOperationException( + "Either ServerUrl must be configured or a GrpcLauncher must be provided for auto-launch."); + } + + if (address.StartsWith("unix://")) + { + var socketPath = address.Substring("unix://".Length); + var connectionFactory = new UnixDomainSocketConnectionFactory(socketPath); + var socketsHandler = new SocketsHttpHandler + { + ConnectCallback = connectionFactory.ConnectAsync + }; + + return GrpcChannel.ForAddress("http://localhost", CreateRetryChannelOptions( + new GrpcChannelOptions { HttpHandler = socketsHandler })); + } + + return GrpcChannel.ForAddress(address, CreateRetryChannelOptions()); + } + + /// + /// Connection factory for Unix Domain Sockets. + /// + private sealed class UnixDomainSocketConnectionFactory(string socketPath) + { + public async ValueTask ConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken) + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + try + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + } + + // ========================================================================= + // Session Operations + // ========================================================================= + + public async Task<(byte[]? Data, bool Found)> LoadSessionAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new LoadSessionRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + using var call = _client.LoadSession(request, cancellationToken: cancellationToken); + + var data = new List(); + bool found = false; + bool isFirst = true; + + await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken)) + { + if (isFirst) + { + found = chunk.Found; + isFirst = false; + if (!found) return (null, false); + } + data.AddRange(chunk.Data); + } + + _logger?.LogDebug("Loaded session {SessionId} for tenant {TenantId} ({Bytes} bytes)", + sessionId, tenantId, data.Count); + + return (data.ToArray(), found); + } + + public async Task SaveSessionAsync( + string tenantId, string sessionId, byte[] data, CancellationToken cancellationToken = default) + { + using var call = _client.SaveSession(cancellationToken: cancellationToken); + + var chunks = ChunkData(data); + bool isFirst = true; + + foreach (var (chunk, isLast) in chunks) + { + var msg = new SaveSessionChunk + { + Data = Google.Protobuf.ByteString.CopyFrom(chunk), + IsLast = isLast + }; + + if (isFirst) + { + msg.Context = new TenantContext { TenantId = tenantId }; + msg.SessionId = sessionId; + isFirst = false; + } + + await call.RequestStream.WriteAsync(msg, cancellationToken); + } + + await call.RequestStream.CompleteAsync(); + var response = await call; + + if (!response.Success) + throw new InvalidOperationException($"Failed to save session {sessionId}"); + + _logger?.LogDebug("Saved session {SessionId} for tenant {TenantId} ({Bytes} bytes)", + sessionId, tenantId, data.Length); + } + + public async Task> ListSessionsAsync( + string tenantId, CancellationToken cancellationToken = default) + { + var request = new ListSessionsRequest + { + Context = new TenantContext { TenantId = tenantId } + }; + + var response = await _client.ListSessionsAsync(request, cancellationToken: cancellationToken); + return response.Sessions.Select(s => new SessionInfoDto( + s.SessionId, + string.IsNullOrEmpty(s.SourcePath) ? null : s.SourcePath, + DateTimeOffset.FromUnixTimeSeconds(s.CreatedAtUnix).UtcDateTime, + DateTimeOffset.FromUnixTimeSeconds(s.ModifiedAtUnix).UtcDateTime, + s.SizeBytes + )).ToList(); + } + + public async Task DeleteSessionAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new DeleteSessionRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await _client.DeleteSessionAsync(request, cancellationToken: cancellationToken); + return response.Existed; + } + + public async Task<(bool Exists, bool PendingExternalChange)> SessionExistsAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new SessionExistsRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await _client.SessionExistsAsync(request, cancellationToken: cancellationToken); + return (response.Exists, response.PendingExternalChange); + } + + // ========================================================================= + // Index Operations + // ========================================================================= + + public async Task<(byte[]? Data, bool Found)> LoadIndexAsync( + string tenantId, CancellationToken cancellationToken = default) + { + var request = new LoadIndexRequest + { + Context = new TenantContext { TenantId = tenantId } + }; + + var response = await _client.LoadIndexAsync(request, cancellationToken: cancellationToken); + + if (!response.Found) + return (null, false); + + return (response.IndexJson.ToByteArray(), true); + } + + public async Task<(bool Success, bool AlreadyExists)> AddSessionToIndexAsync( + string tenantId, string sessionId, SessionIndexEntryDto entry, + CancellationToken cancellationToken = default) + { + var request = new AddSessionToIndexRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId, + Entry = new SessionIndexEntry + { + SourcePath = entry.SourcePath ?? "", + CreatedAtUnix = new DateTimeOffset(entry.CreatedAt).ToUnixTimeSeconds(), + ModifiedAtUnix = new DateTimeOffset(entry.ModifiedAt).ToUnixTimeSeconds(), + WalPosition = entry.WalPosition + } + }; + request.Entry.CheckpointPositions.AddRange(entry.CheckpointPositions); + + var response = await _client.AddSessionToIndexAsync(request, cancellationToken: cancellationToken); + return (response.Success, response.AlreadyExists); + } + + public async Task<(bool Success, bool NotFound)> UpdateSessionInIndexAsync( + string tenantId, string sessionId, + long? modifiedAtUnix = null, ulong? walPosition = null, + IEnumerable? addCheckpointPositions = null, + IEnumerable? removeCheckpointPositions = null, + ulong? cursorPosition = null, + bool? pendingExternalChange = null, + string? sourcePath = null, + CancellationToken cancellationToken = default) + { + var request = new UpdateSessionInIndexRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + if (modifiedAtUnix.HasValue) request.ModifiedAtUnix = modifiedAtUnix.Value; + if (walPosition.HasValue) request.WalPosition = walPosition.Value; + if (addCheckpointPositions is not null) request.AddCheckpointPositions.AddRange(addCheckpointPositions); + if (removeCheckpointPositions is not null) request.RemoveCheckpointPositions.AddRange(removeCheckpointPositions); + if (cursorPosition.HasValue) request.CursorPosition = cursorPosition.Value; + if (pendingExternalChange.HasValue) request.PendingExternalChange = pendingExternalChange.Value; + if (sourcePath is not null) request.SourcePath = sourcePath; + + var response = await _client.UpdateSessionInIndexAsync(request, cancellationToken: cancellationToken); + return (response.Success, response.NotFound); + } + + public async Task<(bool Success, bool Existed)> RemoveSessionFromIndexAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new RemoveSessionFromIndexRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await _client.RemoveSessionFromIndexAsync(request, cancellationToken: cancellationToken); + return (response.Success, response.Existed); + } + + // ========================================================================= + // WAL Operations + // ========================================================================= + + public async Task AppendWalAsync( + string tenantId, string sessionId, IEnumerable entries, + CancellationToken cancellationToken = default) + { + var request = new AppendWalRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + foreach (var entry in entries) + { + request.Entries.Add(new WalEntry + { + Position = entry.Position, + Operation = entry.Operation, + Path = entry.Path, + PatchJson = Google.Protobuf.ByteString.CopyFrom(entry.PatchJson), + TimestampUnix = new DateTimeOffset(entry.Timestamp).ToUnixTimeSeconds() + }); + } + + var response = await _client.AppendWalAsync(request, cancellationToken: cancellationToken); + + if (!response.Success) + throw new InvalidOperationException($"Failed to append WAL for session {sessionId}"); + + return response.NewPosition; + } + + public async Task<(IReadOnlyList Entries, bool HasMore)> ReadWalAsync( + string tenantId, string sessionId, ulong fromPosition = 0, ulong limit = 0, + CancellationToken cancellationToken = default) + { + var request = new ReadWalRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId, + FromPosition = fromPosition, + Limit = limit + }; + + var response = await _client.ReadWalAsync(request, cancellationToken: cancellationToken); + + var entries = response.Entries.Select(e => new WalEntryDto( + e.Position, e.Operation, e.Path, + e.PatchJson.ToByteArray(), + DateTimeOffset.FromUnixTimeSeconds(e.TimestampUnix).UtcDateTime + )).ToList(); + + return (entries, response.HasMore); + } + + public async Task TruncateWalAsync( + string tenantId, string sessionId, ulong keepFromPosition, + CancellationToken cancellationToken = default) + { + var request = new TruncateWalRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId, + KeepFromPosition = keepFromPosition + }; + + var response = await _client.TruncateWalAsync(request, cancellationToken: cancellationToken); + return response.EntriesRemoved; + } + + // ========================================================================= + // Checkpoint Operations + // ========================================================================= + + public async Task SaveCheckpointAsync( + string tenantId, string sessionId, ulong position, byte[] data, + CancellationToken cancellationToken = default) + { + using var call = _client.SaveCheckpoint(cancellationToken: cancellationToken); + + var chunks = ChunkData(data); + bool isFirst = true; + + foreach (var (chunk, isLast) in chunks) + { + var msg = new SaveCheckpointChunk + { + Data = Google.Protobuf.ByteString.CopyFrom(chunk), + IsLast = isLast + }; + + if (isFirst) + { + msg.Context = new TenantContext { TenantId = tenantId }; + msg.SessionId = sessionId; + msg.Position = position; + isFirst = false; + } + + await call.RequestStream.WriteAsync(msg, cancellationToken); + } + + await call.RequestStream.CompleteAsync(); + var response = await call; + + if (!response.Success) + throw new InvalidOperationException($"Failed to save checkpoint at position {position}"); + + _logger?.LogDebug("Saved checkpoint at position {Position} for session {SessionId} ({Bytes} bytes)", + position, sessionId, data.Length); + } + + public async Task<(byte[]? Data, ulong Position, bool Found)> LoadCheckpointAsync( + string tenantId, string sessionId, ulong position = 0, + CancellationToken cancellationToken = default) + { + var request = new LoadCheckpointRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId, + Position = position + }; + + using var call = _client.LoadCheckpoint(request, cancellationToken: cancellationToken); + + var data = new List(); + bool found = false; + ulong actualPosition = 0; + bool isFirst = true; + + await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken)) + { + if (isFirst) + { + found = chunk.Found; + actualPosition = chunk.Position; + isFirst = false; + if (!found) return (null, 0, false); + } + data.AddRange(chunk.Data); + } + + _logger?.LogDebug("Loaded checkpoint at position {Position} for session {SessionId} ({Bytes} bytes)", + actualPosition, sessionId, data.Count); + + return (data.ToArray(), actualPosition, found); + } + + public async Task> ListCheckpointsAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new ListCheckpointsRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await _client.ListCheckpointsAsync(request, cancellationToken: cancellationToken); + return response.Checkpoints.Select(c => new CheckpointInfoDto( + c.Position, DateTimeOffset.FromUnixTimeSeconds(c.CreatedAtUnix).UtcDateTime, c.SizeBytes + )).ToList(); + } + + // ========================================================================= + // Health Check + // ========================================================================= + + public async Task<(bool Healthy, string Backend, string Version)> HealthCheckAsync( + CancellationToken cancellationToken = default) + { + var response = await _client.HealthCheckAsync(new HealthCheckRequest(), cancellationToken: cancellationToken); + return (response.Healthy, response.Backend, response.Version); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private IEnumerable<(byte[] Chunk, bool IsLast)> ChunkData(byte[] data) + { + if (data.Length == 0) + { + yield return (Array.Empty(), true); + yield break; + } + + int offset = 0; + while (offset < data.Length) + { + int remaining = data.Length - offset; + int size = Math.Min(_chunkSize, remaining); + bool isLast = offset + size >= data.Length; + + var chunk = new byte[size]; + Array.Copy(data, offset, chunk, 0, size); + + yield return (chunk, isLast); + offset += size; + } + } + + public async ValueTask DisposeAsync() + { + _channel.Dispose(); + await Task.CompletedTask; + } +} diff --git a/src/DocxMcp.Grpc/IHistoryStorage.cs b/src/DocxMcp.Grpc/IHistoryStorage.cs new file mode 100644 index 0000000..027ab6c --- /dev/null +++ b/src/DocxMcp.Grpc/IHistoryStorage.cs @@ -0,0 +1,74 @@ +namespace DocxMcp.Grpc; + +/// +/// Interface for history storage operations (sessions, index, WAL, checkpoints). +/// Maps to the StorageService gRPC service. +/// +public interface IHistoryStorage : IAsyncDisposable +{ + // Session operations + Task<(byte[]? Data, bool Found)> LoadSessionAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + Task SaveSessionAsync( + string tenantId, string sessionId, byte[] data, CancellationToken cancellationToken = default); + + Task DeleteSessionAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + Task<(bool Exists, bool PendingExternalChange)> SessionExistsAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + Task> ListSessionsAsync( + string tenantId, CancellationToken cancellationToken = default); + + // Index operations (atomic - server handles locking internally) + Task<(byte[]? Data, bool Found)> LoadIndexAsync( + string tenantId, CancellationToken cancellationToken = default); + + Task<(bool Success, bool AlreadyExists)> AddSessionToIndexAsync( + string tenantId, string sessionId, SessionIndexEntryDto entry, + CancellationToken cancellationToken = default); + + Task<(bool Success, bool NotFound)> UpdateSessionInIndexAsync( + string tenantId, string sessionId, + long? modifiedAtUnix = null, ulong? walPosition = null, + IEnumerable? addCheckpointPositions = null, + IEnumerable? removeCheckpointPositions = null, + ulong? cursorPosition = null, + bool? pendingExternalChange = null, + string? sourcePath = null, + CancellationToken cancellationToken = default); + + Task<(bool Success, bool Existed)> RemoveSessionFromIndexAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + // WAL operations + Task AppendWalAsync( + string tenantId, string sessionId, IEnumerable entries, + CancellationToken cancellationToken = default); + + Task<(IReadOnlyList Entries, bool HasMore)> ReadWalAsync( + string tenantId, string sessionId, ulong fromPosition = 0, ulong limit = 0, + CancellationToken cancellationToken = default); + + Task TruncateWalAsync( + string tenantId, string sessionId, ulong keepFromPosition, + CancellationToken cancellationToken = default); + + // Checkpoint operations + Task SaveCheckpointAsync( + string tenantId, string sessionId, ulong position, byte[] data, + CancellationToken cancellationToken = default); + + Task<(byte[]? Data, ulong Position, bool Found)> LoadCheckpointAsync( + string tenantId, string sessionId, ulong position = 0, + CancellationToken cancellationToken = default); + + Task> ListCheckpointsAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + // Health check + Task<(bool Healthy, string Backend, string Version)> HealthCheckAsync( + CancellationToken cancellationToken = default); +} diff --git a/src/DocxMcp.Grpc/ISyncStorage.cs b/src/DocxMcp.Grpc/ISyncStorage.cs new file mode 100644 index 0000000..1c90d9d --- /dev/null +++ b/src/DocxMcp.Grpc/ISyncStorage.cs @@ -0,0 +1,132 @@ +namespace DocxMcp.Grpc; + +/// +/// Interface for sync storage operations (source sync + external watch + browsing). +/// Maps to the SourceSyncService and ExternalWatchService gRPC services. +/// +public interface ISyncStorage : IAsyncDisposable +{ + // SourceSync operations + Task<(bool Success, string Error)> RegisterSourceAsync( + string tenantId, string sessionId, SourceType sourceType, + string? connectionId, string path, string? fileId, bool autoSync, + CancellationToken cancellationToken = default); + + Task UnregisterSourceAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + Task<(bool Success, string Error)> UpdateSourceAsync( + string tenantId, string sessionId, + SourceType? sourceType = null, string? connectionId = null, + string? path = null, string? fileId = null, bool? autoSync = null, + CancellationToken cancellationToken = default); + + Task<(bool Success, string Error, long SyncedAtUnix)> SyncToSourceAsync( + string tenantId, string sessionId, byte[] data, + CancellationToken cancellationToken = default); + + Task GetSyncStatusAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + // ExternalWatch operations + Task<(bool Success, string WatchId, string Error)> StartWatchAsync( + string tenantId, string sessionId, SourceType sourceType, + string? connectionId, string path, string? fileId, int pollIntervalSeconds = 0, + CancellationToken cancellationToken = default); + + Task StopWatchAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + Task<(bool HasChanges, SourceMetadataDto? Current, SourceMetadataDto? Known)> CheckForChangesAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + Task GetSourceMetadataAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default); + + /// + /// Subscribe to external change events for specified sessions. + /// Returns an IAsyncEnumerable that yields events as they occur. + /// + IAsyncEnumerable WatchChangesAsync( + string tenantId, IEnumerable sessionIds, CancellationToken cancellationToken = default); + + // Browse operations + Task> ListConnectionsAsync( + string tenantId, SourceType? filterType = null, + CancellationToken cancellationToken = default); + + Task ListConnectionFilesAsync( + string tenantId, SourceType sourceType, string? connectionId, + string? path = null, string? pageToken = null, int pageSize = 50, + CancellationToken cancellationToken = default); + + Task DownloadFromSourceAsync( + string tenantId, SourceType sourceType, string? connectionId, + string path, string? fileId = null, + CancellationToken cancellationToken = default); +} + +/// +/// Sync status DTO. +/// +public record SyncStatusDto( + string SessionId, + SourceType SourceType, + string? ConnectionId, + string Path, + string? FileId, + bool AutoSyncEnabled, + long? LastSyncedAtUnix, + bool HasPendingChanges, + string? LastError); + +/// +/// Source metadata DTO. +/// +public record SourceMetadataDto( + long SizeBytes, + long ModifiedAtUnix, + string? Etag, + string? VersionId, + byte[]? ContentHash); + +// Note: ExternalChangeType is generated from proto/storage.proto + +/// +/// External change event DTO. +/// +public record ExternalChangeEventDto( + string SessionId, + ExternalChangeType ChangeType, + SourceMetadataDto? OldMetadata, + SourceMetadataDto? NewMetadata, + long DetectedAtUnix, + string? NewUri); + +/// +/// Connection info DTO. +/// +public record ConnectionInfoDto( + string ConnectionId, + SourceType Type, + string DisplayName, + string? ProviderAccountId); + +/// +/// File entry DTO. +/// +public record FileEntryDto( + string Name, + string Path, + string? FileId, + bool IsFolder, + long SizeBytes, + long ModifiedAtUnix, + string? MimeType); + +/// +/// File list result DTO with pagination. +/// +public record FileListResultDto( + List Files, + string? NextPageToken); diff --git a/src/DocxMcp.Grpc/InMemoryPipeStream.cs b/src/DocxMcp.Grpc/InMemoryPipeStream.cs new file mode 100644 index 0000000..af01ee8 --- /dev/null +++ b/src/DocxMcp.Grpc/InMemoryPipeStream.cs @@ -0,0 +1,204 @@ +using System.Runtime.InteropServices; + +namespace DocxMcp.Grpc; + +/// +/// Stream wrapper that delegates I/O to the statically linked Rust storage library +/// via P/Invoke. Used as the transport for in-memory gRPC when storage is embedded. +/// +public sealed partial class InMemoryPipeStream : Stream +{ + [LibraryImport("*")] + private static unsafe partial long docx_pipe_read(byte* buf, nuint maxLen); + + [LibraryImport("*")] + private static unsafe partial long docx_pipe_write(byte* buf, nuint len); + + [LibraryImport("*")] + private static partial int docx_pipe_flush(); + + private static readonly bool IsDebug = + Environment.GetEnvironmentVariable("DEBUG") is not null; + + private static string HexDump(byte[] buf, int offset, int count) + { + var len = Math.Min(count, 64); + var hex = BitConverter.ToString(buf, offset, len).Replace("-", " "); + return count > 64 ? hex + "..." : hex; + } + + private static unsafe string HexDumpPtr(byte* ptr, int count) + { + var len = Math.Min(count, 64); + var bytes = new byte[len]; + for (int i = 0; i < len; i++) bytes[i] = ptr[i]; + var hex = BitConverter.ToString(bytes).Replace("-", " "); + return count > 64 ? hex + "..." : hex; + } + + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + + public override unsafe int Read(byte[] buffer, int offset, int count) + { + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + Console.Error.WriteLine($"[pipe-stream T{tid}] Read(byte[], offset={offset}, count={count})"); + } + if (count == 0) return 0; + fixed (byte* ptr = &buffer[offset]) + { + var result = docx_pipe_read(ptr, (nuint)count); + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + if (result > 0) + Console.Error.WriteLine($"[pipe-stream T{tid}] Read => {result} bytes: {HexDumpPtr(ptr, (int)result)}"); + else + Console.Error.WriteLine($"[pipe-stream T{tid}] Read => {result}"); + } + return result >= 0 ? (int)result : throw new IOException("Pipe read failed"); + } + } + + public override unsafe void Write(byte[] buffer, int offset, int count) + { + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + Console.Error.WriteLine($"[pipe-stream T{tid}] Write(byte[], offset={offset}, count={count}): {HexDump(buffer, offset, count)}"); + } + if (count == 0) return; + fixed (byte* ptr = &buffer[offset]) + { + var result = docx_pipe_write(ptr, (nuint)count); + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + Console.Error.WriteLine($"[pipe-stream T{tid}] Write => {result}"); + } + if (result < 0) throw new IOException("Pipe write failed"); + } + } + + public override unsafe int Read(Span buffer) + { + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + Console.Error.WriteLine($"[pipe-stream T{tid}] Read(Span, len={buffer.Length})"); + } + if (buffer.Length == 0) return 0; + fixed (byte* ptr = buffer) + { + var result = docx_pipe_read(ptr, (nuint)buffer.Length); + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + if (result > 0) + Console.Error.WriteLine($"[pipe-stream T{tid}] Read(Span) => {result} bytes: {HexDumpPtr(ptr, (int)result)}"); + else + Console.Error.WriteLine($"[pipe-stream T{tid}] Read(Span) => {result}"); + } + return result >= 0 ? (int)result : throw new IOException("Pipe read failed"); + } + } + + public override unsafe void Write(ReadOnlySpan buffer) + { + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + Console.Error.WriteLine($"[pipe-stream T{tid}] Write(Span, len={buffer.Length})"); + } + if (buffer.Length == 0) return; + fixed (byte* ptr = buffer) + { + var result = docx_pipe_write(ptr, (nuint)buffer.Length); + if (IsDebug) + { + var tid = Environment.CurrentManagedThreadId; + Console.Error.WriteLine($"[pipe-stream T{tid}] Write(Span) => {result}"); + } + if (result < 0) throw new IOException("Pipe write failed"); + } + } + + // Async overrides — critical for HTTP/2 full-duplex. + // The default Stream.ReadAsync serializes with writes on the same thread. + // Task.Run ensures reads and writes happen on separate thread pool threads. + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) + { + if (IsDebug) + Console.Error.WriteLine($"[pipe-stream T{Environment.CurrentManagedThreadId}] ReadAsync(byte[], offset={offset}, count={count})"); + if (count == 0) return Task.FromResult(0); + return Task.Run(() => Read(buffer, offset, count), ct); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + if (IsDebug) + Console.Error.WriteLine($"[pipe-stream T{Environment.CurrentManagedThreadId}] ReadAsync(Memory, len={buffer.Length})"); + if (buffer.Length == 0) return new ValueTask(0); + // Memory → ArraySegment → byte[] path for safe Task.Run usage + if (MemoryMarshal.TryGetArray((ReadOnlyMemory)buffer, out var segment)) + { + return new ValueTask(Task.Run(() => Read(segment.Array!, segment.Offset, segment.Count), ct)); + } + // Fallback: copy through a temp buffer + var temp = new byte[buffer.Length]; + return new ValueTask(Task.Run(() => + { + var n = Read(temp, 0, temp.Length); + temp.AsSpan(0, n).CopyTo(buffer.Span); + return n; + }, ct)); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) + { + if (IsDebug) + Console.Error.WriteLine($"[pipe-stream T{Environment.CurrentManagedThreadId}] WriteAsync(byte[], offset={offset}, count={count})"); + if (count == 0) return Task.CompletedTask; + return Task.Run(() => Write(buffer, offset, count), ct); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + { + if (IsDebug) + Console.Error.WriteLine($"[pipe-stream T{Environment.CurrentManagedThreadId}] WriteAsync(Memory, len={buffer.Length})"); + if (buffer.Length == 0) return ValueTask.CompletedTask; + if (MemoryMarshal.TryGetArray(buffer, out var segment)) + { + return new ValueTask(Task.Run(() => Write(segment.Array!, segment.Offset, segment.Count), ct)); + } + // Fallback: copy + var temp = buffer.ToArray(); + return new ValueTask(Task.Run(() => Write(temp, 0, temp.Length), ct)); + } + + public override Task FlushAsync(CancellationToken ct) + { + return Task.Run(Flush, ct); + } + + public override void Flush() + { + if (IsDebug) + Console.Error.WriteLine("[pipe-stream] Flush()"); + if (docx_pipe_flush() != 0) + throw new IOException("Pipe flush failed"); + } + + // Required abstract members (not supported for pipe stream) + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); +} diff --git a/src/DocxMcp.Grpc/IndexTypes.cs b/src/DocxMcp.Grpc/IndexTypes.cs new file mode 100644 index 0000000..5a1729e --- /dev/null +++ b/src/DocxMcp.Grpc/IndexTypes.cs @@ -0,0 +1,47 @@ +namespace DocxMcp.Grpc; + +/// +/// DTO for session index entry used in atomic index operations. +/// Named with Dto suffix to avoid conflict with proto-generated SessionIndexEntry. +/// +public sealed record SessionIndexEntryDto( + string? SourcePath, + DateTime CreatedAt, + DateTime ModifiedAt, + ulong WalPosition, + IReadOnlyList CheckpointPositions +); + +/// +/// DTO for session info returned by list operations. +/// Named with Dto suffix to avoid conflict with proto-generated SessionInfo. +/// +public sealed record SessionInfoDto( + string SessionId, + string? SourcePath, + DateTime CreatedAt, + DateTime ModifiedAt, + long SizeBytes +); + +/// +/// DTO for checkpoint info. +/// Named with Dto suffix to avoid conflict with proto-generated CheckpointInfo. +/// +public sealed record CheckpointInfoDto( + ulong Position, + DateTime CreatedAt, + long SizeBytes +); + +/// +/// DTO for WAL entry. +/// Named with Dto suffix to avoid conflict with proto-generated WalEntry. +/// +public sealed record WalEntryDto( + ulong Position, + string Operation, + string Path, + byte[] PatchJson, + DateTime Timestamp +); diff --git a/src/DocxMcp.Grpc/NativeStorage.cs b/src/DocxMcp.Grpc/NativeStorage.cs new file mode 100644 index 0000000..fcda5b0 --- /dev/null +++ b/src/DocxMcp.Grpc/NativeStorage.cs @@ -0,0 +1,43 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace DocxMcp.Grpc; + +/// +/// Static helper for initializing/shutting down the embedded Rust storage library. +/// Uses P/Invoke to call into the statically linked Rust staticlib. +/// +public static partial class NativeStorage +{ + [LibraryImport("*")] + private static unsafe partial int docx_storage_init(byte* configJson); + + [LibraryImport("*")] + private static partial int docx_storage_shutdown(); + + private static readonly bool IsDebug = + Environment.GetEnvironmentVariable("DEBUG") is not null; + + public static void Init(string localStorageDir) + { + if (IsDebug) Console.Error.WriteLine($"[native] Init: localStorageDir={localStorageDir}"); + // Escape backslashes and quotes in the path for JSON + var escapedPath = localStorageDir.Replace("\\", "\\\\").Replace("\"", "\\\""); + var json = $$$"""{"local_storage_dir":"{{{escapedPath}}}"}"""; + if (IsDebug) Console.Error.WriteLine($"[native] Init: json={json}"); + var bytes = Encoding.UTF8.GetBytes(json + "\0"); + unsafe + { + fixed (byte* ptr = bytes) + { + var result = docx_storage_init(ptr); + if (IsDebug) Console.Error.WriteLine($"[native] Init: docx_storage_init returned {result}"); + if (result != 0) + throw new InvalidOperationException("Failed to initialize native storage"); + } + } + if (IsDebug) Console.Error.WriteLine("[native] Init: done"); + } + + public static void Shutdown() => docx_storage_shutdown(); +} diff --git a/src/DocxMcp.Grpc/StorageClientOptions.cs b/src/DocxMcp.Grpc/StorageClientOptions.cs new file mode 100644 index 0000000..8bbf195 --- /dev/null +++ b/src/DocxMcp.Grpc/StorageClientOptions.cs @@ -0,0 +1,138 @@ +namespace DocxMcp.Grpc; + +/// +/// Configuration options for the gRPC storage client. +/// +public sealed class StorageClientOptions +{ + /// + /// gRPC server URL for history storage (e.g., "http://localhost:50051"). + /// If null, auto-launch mode uses Unix socket. + /// + public string? ServerUrl { get; set; } + + /// + /// gRPC server URL for sync/watch storage (e.g., "http://localhost:50052"). + /// If null, uses local embedded sync via InMemoryPipeStream. + /// Used for remote sync backends like Google Drive. + /// + public string? SyncServerUrl { get; set; } + + /// + /// Path to Unix socket (e.g., "/tmp/docx-storage-local.sock"). + /// Used when ServerUrl is null and on Unix-like systems. + /// + public string? UnixSocketPath { get; set; } + + /// + /// Whether to auto-launch the gRPC server if not running. + /// Only applies when ServerUrl is null. + /// + public bool AutoLaunch { get; set; } = true; + + /// + /// Path to the storage server binary for auto-launch. + /// If null, searches in PATH or relative to current assembly. + /// + public string? StorageServerPath { get; set; } + + /// + /// Base directory for local storage. + /// Passed to the storage server via --local-storage-dir. + /// The server will create {base}/{tenant_id}/sessions/ structure. + /// Default: LocalApplicationData/docx-mcp + /// + public string? LocalStorageDir { get; set; } + + /// + /// Get effective local storage directory. + /// Note: This returns the BASE directory, not the sessions directory. + /// The storage server adds {tenant_id}/sessions/ to this path. + /// + public string GetEffectiveLocalStorageDir() + { + if (LocalStorageDir is not null) + return LocalStorageDir; + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(localAppData, "docx-mcp"); + } + + /// + /// Timeout for connecting to the gRPC server. + /// + public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Default timeout for gRPC calls. + /// + public TimeSpan DefaultCallTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Get effective socket/pipe path for IPC. + /// The path includes the current process PID to ensure uniqueness + /// and proper fork/join semantics (each parent gets its own child server). + /// On Windows, returns a named pipe path. On Unix, returns a socket path. + /// + public string GetEffectiveSocketPath() + { + if (UnixSocketPath is not null) + return UnixSocketPath; + + var pid = Environment.ProcessId; + + if (OperatingSystem.IsWindows()) + { + // Windows named pipe - unique per process + return $@"\\.\pipe\docx-storage-local-{pid}"; + } + + // Unix socket - unique per process + var socketName = $"docx-storage-local-{pid}.sock"; + var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + return runtimeDir is not null + ? Path.Combine(runtimeDir, socketName) + : Path.Combine("/tmp", socketName); + } + + /// + /// Check if we're using Windows named pipes. + /// + public bool IsWindowsNamedPipe => OperatingSystem.IsWindows() && UnixSocketPath is null; + + /// + /// Create options from environment variables. + /// + public static StorageClientOptions FromEnvironment() + { + var options = new StorageClientOptions(); + + var serverUrl = Environment.GetEnvironmentVariable("STORAGE_GRPC_URL"); + if (!string.IsNullOrEmpty(serverUrl)) + options.ServerUrl = serverUrl; + + var syncServerUrl = Environment.GetEnvironmentVariable("SYNC_GRPC_URL"); + if (!string.IsNullOrEmpty(syncServerUrl)) + options.SyncServerUrl = syncServerUrl; + + var socketPath = Environment.GetEnvironmentVariable("STORAGE_GRPC_SOCKET"); + if (!string.IsNullOrEmpty(socketPath)) + options.UnixSocketPath = socketPath; + + var serverPath = Environment.GetEnvironmentVariable("STORAGE_SERVER_PATH"); + if (!string.IsNullOrEmpty(serverPath)) + options.StorageServerPath = serverPath; + + var autoLaunch = Environment.GetEnvironmentVariable("STORAGE_AUTO_LAUNCH"); + if (autoLaunch is not null && autoLaunch.Equals("false", StringComparison.OrdinalIgnoreCase)) + options.AutoLaunch = false; + + // Support both new and legacy environment variable names + var localStorageDir = Environment.GetEnvironmentVariable("LOCAL_STORAGE_DIR") + ?? Environment.GetEnvironmentVariable("DOCX_SESSIONS_DIR"); + if (!string.IsNullOrEmpty(localStorageDir)) + options.LocalStorageDir = localStorageDir; + + return options; + } +} diff --git a/src/DocxMcp.Grpc/SyncStorageClient.cs b/src/DocxMcp.Grpc/SyncStorageClient.cs new file mode 100644 index 0000000..c666566 --- /dev/null +++ b/src/DocxMcp.Grpc/SyncStorageClient.cs @@ -0,0 +1,400 @@ +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.Logging; + +namespace DocxMcp.Grpc; + +/// +/// gRPC client for sync storage operations (SourceSyncService + ExternalWatchService). +/// Handles source registration, sync-to-source, external file watching, and connection browsing. +/// +public class SyncStorageClient : ISyncStorage +{ + private readonly GrpcChannel _channel; + private readonly ILogger? _logger; + private readonly int _chunkSize; + + /// + /// Default chunk size for streaming uploads: 256KB + /// + public const int DefaultChunkSize = 256 * 1024; + + public SyncStorageClient(GrpcChannel channel, ILogger? logger = null, int chunkSize = DefaultChunkSize) + { + _channel = channel; + _logger = logger; + _chunkSize = chunkSize; + } + + private SourceSyncService.SourceSyncServiceClient GetSyncClient() + => new SourceSyncService.SourceSyncServiceClient(_channel); + + private ExternalWatchService.ExternalWatchServiceClient GetWatchClient() + => new ExternalWatchService.ExternalWatchServiceClient(_channel); + + // ========================================================================= + // SourceSync Operations + // ========================================================================= + + public async Task<(bool Success, string Error)> RegisterSourceAsync( + string tenantId, string sessionId, SourceType sourceType, + string? connectionId, string path, string? fileId, bool autoSync, + CancellationToken cancellationToken = default) + { + var request = new RegisterSourceRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId, + Source = new SourceDescriptor + { + Type = sourceType, + ConnectionId = connectionId ?? "", + Path = path, + FileId = fileId ?? "" + }, + AutoSync = autoSync + }; + + var response = await GetSyncClient().RegisterSourceAsync(request, cancellationToken: cancellationToken); + return (response.Success, response.Error); + } + + public async Task UnregisterSourceAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new UnregisterSourceRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await GetSyncClient().UnregisterSourceAsync(request, cancellationToken: cancellationToken); + return response.Success; + } + + public async Task<(bool Success, string Error)> UpdateSourceAsync( + string tenantId, string sessionId, + SourceType? sourceType = null, string? connectionId = null, + string? path = null, string? fileId = null, bool? autoSync = null, + CancellationToken cancellationToken = default) + { + var request = new UpdateSourceRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + if (sourceType.HasValue && path is not null) + { + request.Source = new SourceDescriptor + { + Type = sourceType.Value, + ConnectionId = connectionId ?? "", + Path = path, + FileId = fileId ?? "" + }; + } + + if (autoSync.HasValue) + { + request.AutoSync = autoSync.Value; + request.UpdateAutoSync = true; + } + + var response = await GetSyncClient().UpdateSourceAsync(request, cancellationToken: cancellationToken); + return (response.Success, response.Error); + } + + public async Task<(bool Success, string Error, long SyncedAtUnix)> SyncToSourceAsync( + string tenantId, string sessionId, byte[] data, + CancellationToken cancellationToken = default) + { + using var call = GetSyncClient().SyncToSource(cancellationToken: cancellationToken); + + var chunks = ChunkData(data); + bool isFirst = true; + + foreach (var (chunk, isLast) in chunks) + { + var msg = new SyncToSourceChunk + { + Data = Google.Protobuf.ByteString.CopyFrom(chunk), + IsLast = isLast + }; + + if (isFirst) + { + msg.Context = new TenantContext { TenantId = tenantId }; + msg.SessionId = sessionId; + isFirst = false; + } + + await call.RequestStream.WriteAsync(msg, cancellationToken); + } + + await call.RequestStream.CompleteAsync(); + var response = await call; + + _logger?.LogDebug("Synced session {SessionId} for tenant {TenantId} ({Bytes} bytes, success={Success})", + sessionId, tenantId, data.Length, response.Success); + + return (response.Success, response.Error, response.SyncedAtUnix); + } + + public async Task GetSyncStatusAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new GetSyncStatusRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await GetSyncClient().GetSyncStatusAsync(request, cancellationToken: cancellationToken); + + if (!response.Registered || response.Status is null) + return null; + + var status = response.Status; + return new SyncStatusDto( + status.SessionId, + (SourceType)(int)status.Source.Type, + string.IsNullOrEmpty(status.Source.ConnectionId) ? null : status.Source.ConnectionId, + status.Source.Path, + string.IsNullOrEmpty(status.Source.FileId) ? null : status.Source.FileId, + status.AutoSyncEnabled, + status.LastSyncedAtUnix > 0 ? status.LastSyncedAtUnix : null, + status.HasPendingChanges, + string.IsNullOrEmpty(status.LastError) ? null : status.LastError); + } + + // ========================================================================= + // ExternalWatch Operations + // ========================================================================= + + public async Task<(bool Success, string WatchId, string Error)> StartWatchAsync( + string tenantId, string sessionId, SourceType sourceType, + string? connectionId, string path, string? fileId, int pollIntervalSeconds = 0, + CancellationToken cancellationToken = default) + { + var request = new StartWatchRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId, + Source = new SourceDescriptor + { + Type = sourceType, + ConnectionId = connectionId ?? "", + Path = path, + FileId = fileId ?? "" + }, + PollIntervalSeconds = pollIntervalSeconds + }; + + var response = await GetWatchClient().StartWatchAsync(request, cancellationToken: cancellationToken); + return (response.Success, response.WatchId, response.Error); + } + + public async Task StopWatchAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new StopWatchRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await GetWatchClient().StopWatchAsync(request, cancellationToken: cancellationToken); + return response.Success; + } + + public async Task<(bool HasChanges, SourceMetadataDto? Current, SourceMetadataDto? Known)> CheckForChangesAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new CheckForChangesRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await GetWatchClient().CheckForChangesAsync(request, cancellationToken: cancellationToken); + + return ( + response.HasChanges, + response.CurrentMetadata is not null ? ConvertMetadata(response.CurrentMetadata) : null, + response.KnownMetadata is not null ? ConvertMetadata(response.KnownMetadata) : null + ); + } + + public async Task GetSourceMetadataAsync( + string tenantId, string sessionId, CancellationToken cancellationToken = default) + { + var request = new GetSourceMetadataRequest + { + Context = new TenantContext { TenantId = tenantId }, + SessionId = sessionId + }; + + var response = await GetWatchClient().GetSourceMetadataAsync(request, cancellationToken: cancellationToken); + + if (!response.Success || response.Metadata is null) + return null; + + return ConvertMetadata(response.Metadata); + } + + public async IAsyncEnumerable WatchChangesAsync( + string tenantId, IEnumerable sessionIds, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var request = new WatchChangesRequest + { + Context = new TenantContext { TenantId = tenantId } + }; + request.SessionIds.AddRange(sessionIds); + + using var call = GetWatchClient().WatchChanges(request, cancellationToken: cancellationToken); + + await foreach (var evt in call.ResponseStream.ReadAllAsync(cancellationToken)) + { + yield return new ExternalChangeEventDto( + evt.SessionId, + (ExternalChangeType)(int)evt.ChangeType, + evt.OldMetadata is not null ? ConvertMetadata(evt.OldMetadata) : null, + evt.NewMetadata is not null ? ConvertMetadata(evt.NewMetadata) : null, + evt.DetectedAtUnix, + string.IsNullOrEmpty(evt.NewUri) ? null : evt.NewUri + ); + } + } + + // ========================================================================= + // Browse Operations + // ========================================================================= + + public async Task> ListConnectionsAsync( + string tenantId, SourceType? filterType = null, + CancellationToken cancellationToken = default) + { + var request = new ListConnectionsRequest + { + Context = new TenantContext { TenantId = tenantId }, + FilterType = filterType ?? 0 + }; + + var response = await GetSyncClient().ListConnectionsAsync(request, cancellationToken: cancellationToken); + + return response.Connections.Select(c => new ConnectionInfoDto( + c.ConnectionId, + (SourceType)(int)c.Type, + c.DisplayName, + string.IsNullOrEmpty(c.ProviderAccountId) ? null : c.ProviderAccountId + )).ToList(); + } + + public async Task ListConnectionFilesAsync( + string tenantId, SourceType sourceType, string? connectionId, + string? path = null, string? pageToken = null, int pageSize = 50, + CancellationToken cancellationToken = default) + { + var request = new ListConnectionFilesRequest + { + Context = new TenantContext { TenantId = tenantId }, + Type = sourceType, + ConnectionId = connectionId ?? "", + Path = path ?? "", + PageToken = pageToken ?? "", + PageSize = pageSize + }; + + var response = await GetSyncClient().ListConnectionFilesAsync(request, cancellationToken: cancellationToken); + + var files = response.Files.Select(f => new FileEntryDto( + f.Name, + f.Path, + string.IsNullOrEmpty(f.FileId) ? null : f.FileId, + f.IsFolder, + f.SizeBytes, + f.ModifiedAtUnix, + string.IsNullOrEmpty(f.MimeType) ? null : f.MimeType + )).ToList(); + + return new FileListResultDto( + files, + string.IsNullOrEmpty(response.NextPageToken) ? null : response.NextPageToken + ); + } + + public async Task DownloadFromSourceAsync( + string tenantId, SourceType sourceType, string? connectionId, + string path, string? fileId = null, + CancellationToken cancellationToken = default) + { + var request = new DownloadFromSourceRequest + { + Context = new TenantContext { TenantId = tenantId }, + Type = sourceType, + ConnectionId = connectionId ?? "", + Path = path, + FileId = fileId ?? "" + }; + + using var call = GetSyncClient().DownloadFromSource(request, cancellationToken: cancellationToken); + + var data = new MemoryStream(); + await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken)) + { + if (chunk.Data.Length > 0) + data.Write(chunk.Data.Span); + + if (chunk.IsLast) + break; + } + + return data.ToArray(); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + private static SourceMetadataDto ConvertMetadata(SourceMetadata metadata) + { + return new SourceMetadataDto( + metadata.SizeBytes, + metadata.ModifiedAtUnix, + string.IsNullOrEmpty(metadata.Etag) ? null : metadata.Etag, + string.IsNullOrEmpty(metadata.VersionId) ? null : metadata.VersionId, + metadata.ContentHash.IsEmpty ? null : metadata.ContentHash.ToByteArray() + ); + } + + private IEnumerable<(byte[] Chunk, bool IsLast)> ChunkData(byte[] data) + { + if (data.Length == 0) + { + yield return (Array.Empty(), true); + yield break; + } + + int offset = 0; + while (offset < data.Length) + { + int remaining = data.Length - offset; + int size = Math.Min(_chunkSize, remaining); + bool isLast = offset + size >= data.Length; + + var chunk = new byte[size]; + Array.Copy(data, offset, chunk, 0, size); + + yield return (chunk, isLast); + offset += size; + } + } + + public async ValueTask DisposeAsync() + { + _channel.Dispose(); + await Task.CompletedTask; + } +} diff --git a/src/DocxMcp.Grpc/TenantContext.cs b/src/DocxMcp.Grpc/TenantContext.cs new file mode 100644 index 0000000..bf6a955 --- /dev/null +++ b/src/DocxMcp.Grpc/TenantContext.cs @@ -0,0 +1,79 @@ +namespace DocxMcp.Grpc; + +/// +/// Helper class for managing tenant context in gRPC calls. +/// +public static class TenantContextHelper +{ + /// + /// Default tenant ID for local CLI usage. + /// Empty string for backward compatibility with legacy session paths + /// (stores directly in sessions/ without tenant prefix). + /// + public const string LocalTenant = ""; + + /// + /// Default tenant ID for MCP stdio usage. + /// Empty string for backward compatibility with legacy session paths. + /// + public const string DefaultTenant = ""; + + /// + /// Current tenant context stored as AsyncLocal for per-request isolation. + /// + private static readonly AsyncLocal _currentTenant = new(); + + /// + /// Get or set the current tenant ID. + /// + public static string CurrentTenantId + { + get => _currentTenant.Value ?? DefaultTenant; + set => _currentTenant.Value = value; + } + + /// + /// Create a TenantContext protobuf message. + /// + public static TenantContext Create(string? tenantId = null) + { + return new TenantContext + { + TenantId = tenantId ?? CurrentTenantId + }; + } + + /// + /// Execute an action with a specific tenant context. + /// + public static T WithTenant(string tenantId, Func action) + { + var previous = _currentTenant.Value; + try + { + _currentTenant.Value = tenantId; + return action(); + } + finally + { + _currentTenant.Value = previous; + } + } + + /// + /// Execute an async action with a specific tenant context. + /// + public static async Task WithTenantAsync(string tenantId, Func> action) + { + var previous = _currentTenant.Value; + try + { + _currentTenant.Value = tenantId; + return await action(); + } + finally + { + _currentTenant.Value = previous; + } + } +} diff --git a/src/DocxMcp/DocxMcp.csproj b/src/DocxMcp/DocxMcp.csproj index 1a4d1c3..fffaec3 100644 --- a/src/DocxMcp/DocxMcp.csproj +++ b/src/DocxMcp/DocxMcp.csproj @@ -16,10 +16,28 @@ + + + + - + + + + + + + + + + + + + + + diff --git a/src/DocxMcp/DocxSession.cs b/src/DocxMcp/DocxSession.cs index bea49ad..0f852db 100644 --- a/src/DocxMcp/DocxSession.cs +++ b/src/DocxMcp/DocxSession.cs @@ -14,7 +14,15 @@ public sealed class DocxSession : IDisposable public string Id { get; } public MemoryStream Stream { get; } public WordprocessingDocument Document { get; } - public string? SourcePath { get; } + public string? SourcePath { get; private set; } + + /// + /// Set the source path (used when setting "Save As" target for new documents). + /// + internal void SetSourcePath(string? path) + { + SourcePath = path; + } private DocxSession(string id, WordprocessingDocument document, MemoryStream stream, string? sourcePath) { diff --git a/src/DocxMcp/ExternalChanges/ExternalChangeGate.cs b/src/DocxMcp/ExternalChanges/ExternalChangeGate.cs new file mode 100644 index 0000000..e08c593 --- /dev/null +++ b/src/DocxMcp/ExternalChanges/ExternalChangeGate.cs @@ -0,0 +1,163 @@ +using DocxMcp.Diff; +using DocxMcp.Grpc; +using DocxMcp.Helpers; + +namespace DocxMcp.ExternalChanges; + +/// +/// Lightweight gate that tracks pending external changes per session. +/// Blocks edits (via PatchTool) until changes are acknowledged or synced. +/// +/// State is persisted in the session index via gRPC (pending_external_change flag), +/// so it survives restarts and is shared across MCP instances. +/// +/// Detection sources: +/// - Manual: get_external_changes tool calls CheckForChanges() +/// - Automatic: gRPC WatchChanges stream calls NotifyExternalChange() +/// +public sealed class ExternalChangeGate +{ + private readonly IHistoryStorage _history; + + public ExternalChangeGate(IHistoryStorage history) + { + _history = history; + } + + /// + /// Check if a source file has changed compared to the session. + /// If changed, sets the pending flag in the index (blocking edits). + /// Returns change details if changes were detected. + /// + public PendingExternalChange? CheckForChanges(string tenantId, SessionManager sessions, string sessionId, SyncManager? sync = null) + { + // If already pending, compute fresh diff details but don't re-flag + if (HasPendingChanges(tenantId, sessionId)) + { + return ComputeChangeDetails(tenantId, sessions, sessionId, sync); + } + + using var session = sessions.Get(sessionId); + var fileBytes = sync != null + ? sync.ReadSourceBytes(tenantId, sessionId, session.SourcePath) + : (session.SourcePath != null && File.Exists(session.SourcePath) ? File.ReadAllBytes(session.SourcePath) : null); + if (fileBytes is null) + return null; + + var sessionBytes = session.ToBytes(); + var sessionHash = ContentHasher.ComputeContentHash(sessionBytes); + var fileHash = ContentHasher.ComputeContentHash(fileBytes); + + if (sessionHash == fileHash) + { + // File matches session — ensure flag is cleared + ClearPending(tenantId, sessionId); + return null; + } + + // Content differs — set pending flag and compute diff + SetPending(tenantId, sessionId, true); + + var diff = DiffEngine.Compare(sessionBytes, fileBytes); + + return new PendingExternalChange + { + Id = $"ext_{sessionId}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid().ToString("N")[..8]}", + SessionId = sessionId, + DetectedAt = DateTime.UtcNow, + SourcePath = session.SourcePath ?? "(cloud)", + Summary = diff.Summary, + Changes = diff.Changes.Select(ExternalElementChange.FromElementChange).ToList() + }; + } + + /// + /// Check if there are pending external changes for a session. + /// Reads from the gRPC storage index — works across restarts and instances. + /// + public bool HasPendingChanges(string tenantId, string sessionId) + { + var (_, pending) = _history.SessionExistsAsync(tenantId, sessionId) + .GetAwaiter().GetResult(); + return pending; + } + + /// + /// Acknowledge a pending change, allowing edits to continue. + /// Clears the pending flag in the index. + /// + public bool Acknowledge(string tenantId, string sessionId) + { + if (!HasPendingChanges(tenantId, sessionId)) + return false; + + SetPending(tenantId, sessionId, false); + return true; + } + + /// + /// Clear pending state for a session (after sync or close). + /// + public void ClearPending(string tenantId, string sessionId) + { + SetPending(tenantId, sessionId, false); + } + + /// + /// Notify that an external change was detected. + /// Called by the gRPC WatchChanges stream consumer. + /// + public void NotifyExternalChange(string tenantId, SessionManager sessions, string sessionId, SyncManager? sync = null) + { + CheckForChanges(tenantId, sessions, sessionId, sync); + } + + /// + /// Set the pending_external_change flag in the session index via gRPC. + /// + private void SetPending(string tenantId, string sessionId, bool pending) + { + _history.UpdateSessionInIndexAsync(tenantId, sessionId, + pendingExternalChange: pending).GetAwaiter().GetResult(); + } + + /// + /// Compute change details without modifying state. + /// Used when pending flag is already set to return fresh diff info. + /// + private static PendingExternalChange? ComputeChangeDetails(string tenantId, SessionManager sessions, string sessionId, SyncManager? sync = null) + { + using var session = sessions.Get(sessionId); + var fileBytes = sync != null + ? sync.ReadSourceBytes(tenantId, sessionId, session.SourcePath) + : (session.SourcePath != null && File.Exists(session.SourcePath) ? File.ReadAllBytes(session.SourcePath) : null); + if (fileBytes is null) + return null; + + var sessionBytes = session.ToBytes(); + var diff = DiffEngine.Compare(sessionBytes, fileBytes); + + return new PendingExternalChange + { + Id = $"ext_{sessionId}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid().ToString("N")[..8]}", + SessionId = sessionId, + DetectedAt = DateTime.UtcNow, + SourcePath = session.SourcePath ?? "(cloud)", + Summary = diff.Summary, + Changes = diff.Changes.Select(ExternalElementChange.FromElementChange).ToList() + }; + } +} + +/// +/// A pending external change that must be acknowledged before editing. +/// +public sealed class PendingExternalChange +{ + public required string Id { get; init; } + public required string SessionId { get; init; } + public required DateTime DetectedAt { get; init; } + public required string SourcePath { get; init; } + public required DiffSummary Summary { get; init; } + public required List Changes { get; init; } +} diff --git a/src/DocxMcp/ExternalChanges/ExternalChangeNotificationService.cs b/src/DocxMcp/ExternalChanges/ExternalChangeNotificationService.cs deleted file mode 100644 index 6d85672..0000000 --- a/src/DocxMcp/ExternalChanges/ExternalChangeNotificationService.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace DocxMcp.ExternalChanges; - -/// -/// Background service that monitors for external changes. -/// When an external change is detected, it automatically syncs the session -/// with the external file (same behavior as the CLI watch daemon). -/// -public sealed class ExternalChangeNotificationService : BackgroundService -{ - private readonly ExternalChangeTracker _tracker; - private readonly SessionManager _sessions; - private readonly ILogger _logger; - - public ExternalChangeNotificationService( - ExternalChangeTracker tracker, - SessionManager sessions, - ILogger logger) - { - _tracker = tracker; - _sessions = sessions; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("External change notification service started."); - - // Subscribe to external change events - _tracker.ExternalChangeDetected += OnExternalChangeDetected; - - // Start watching all existing sessions with source paths - foreach (var (sessionId, sourcePath) in _sessions.List()) - { - if (sourcePath is not null) - { - _tracker.StartWatching(sessionId); - } - } - - // Keep the service running - try - { - await Task.Delay(Timeout.Infinite, stoppingToken); - } - catch (TaskCanceledException) - { - // Normal shutdown - } - finally - { - _tracker.ExternalChangeDetected -= OnExternalChangeDetected; - _logger.LogInformation("External change notification service stopped."); - } - } - - private void OnExternalChangeDetected(object? sender, ExternalChangeDetectedEventArgs e) - { - try - { - _logger.LogInformation( - "External change detected for session {SessionId}. Auto-syncing.", - e.SessionId); - - var result = _tracker.SyncExternalChanges(e.SessionId, e.Patch.Id); - - if (result.HasChanges) - { - _logger.LogInformation( - "Auto-synced session {SessionId}: +{Added} -{Removed} ~{Modified}.", - e.SessionId, - result.Summary?.Added ?? 0, - result.Summary?.Removed ?? 0, - result.Summary?.Modified ?? 0); - } - else - { - _logger.LogDebug("No logical changes after sync for session {SessionId}.", e.SessionId); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to auto-sync external changes for session {SessionId}.", e.SessionId); - } - } -} diff --git a/src/DocxMcp/ExternalChanges/ExternalChangePatch.cs b/src/DocxMcp/ExternalChanges/ExternalChangePatch.cs index 761cebc..7d7ad3e 100644 --- a/src/DocxMcp/ExternalChanges/ExternalChangePatch.cs +++ b/src/DocxMcp/ExternalChanges/ExternalChangePatch.cs @@ -5,128 +5,6 @@ namespace DocxMcp.ExternalChanges; -/// -/// Represents an external change event detected on a document. -/// Contains the diff and generated patches for the LLM to review. -/// -public sealed class ExternalChangePatch -{ - /// - /// Unique identifier for this external change event. - /// - public required string Id { get; init; } - - /// - /// Session ID this change applies to. - /// - public required string SessionId { get; init; } - - /// - /// When the external change was detected. - /// - public required DateTime DetectedAt { get; init; } - - /// - /// Path to the source file that was modified. - /// - public required string SourcePath { get; init; } - - /// - /// Hash of the file before the external change (session state). - /// - public required string PreviousHash { get; init; } - - /// - /// Hash of the file after the external change (new external state). - /// - public required string NewHash { get; init; } - - /// - /// Summary of changes detected. - /// - public required DiffSummary Summary { get; init; } - - /// - /// List of individual changes detected. - /// - public required List Changes { get; init; } - - /// - /// Generated patches that would transform the session to match the external file. - /// - public required List Patches { get; init; } - - /// - /// Whether this change has been acknowledged by the LLM. - /// - public bool Acknowledged { get; set; } - - /// - /// When the change was acknowledged (if applicable). - /// - public DateTime? AcknowledgedAt { get; set; } - - /// - /// Convert to a human-readable summary for the LLM. - /// - public string ToLlmSummary() - { - var lines = new List - { - $"## External Document Change Detected", - $"", - $"**Session**: {SessionId}", - $"**File**: {SourcePath}", - $"**Detected at**: {DetectedAt:yyyy-MM-dd HH:mm:ss UTC}", - $"", - $"### Summary", - $"- **Added**: {Summary.Added} element(s)", - $"- **Removed**: {Summary.Removed} element(s)", - $"- **Modified**: {Summary.Modified} element(s)", - $"- **Moved**: {Summary.Moved} element(s)", - $"- **Total changes**: {Summary.TotalChanges}", - $"" - }; - - if (Changes.Count > 0) - { - lines.Add("### Changes"); - foreach (var change in Changes.Take(20)) // Limit to first 20 - { - lines.Add($"- {change.Description}"); - } - - if (Changes.Count > 20) - { - lines.Add($"- ... and {Changes.Count - 20} more changes"); - } - } - - lines.Add(""); - lines.Add("### Required Action"); - lines.Add("You must acknowledge this external change before continuing to edit the document."); - lines.Add("Use `acknowledge_external_change` to proceed."); - - return string.Join("\n", lines); - } - - /// - /// Convert to JSON for storage/transmission. - /// - public string ToJson(bool indented = false) - { - return JsonSerializer.Serialize(this, ExternalChangeJsonContext.Default.ExternalChangePatch); - } - - /// - /// Parse from JSON. - /// - public static ExternalChangePatch? FromJson(string json) - { - return JsonSerializer.Deserialize(json, ExternalChangeJsonContext.Default.ExternalChangePatch); - } -} - /// /// Simplified change record for external changes (without OpenXML references). /// @@ -155,33 +33,6 @@ public static ExternalElementChange FromElementChange(ElementChange change) } } -/// -/// Collection of pending external changes for a session. -/// -public sealed class PendingExternalChanges -{ - /// - /// Session ID. - /// - public required string SessionId { get; init; } - - /// - /// List of unacknowledged external changes (most recent first). - /// - public List Changes { get; init; } = []; - - /// - /// Whether there are pending changes that need acknowledgment. - /// - public bool HasPendingChanges => Changes.Any(c => !c.Acknowledged); - - /// - /// Get the most recent unacknowledged change. - /// - public ExternalChangePatch? MostRecentPending => - Changes.FirstOrDefault(c => !c.Acknowledged); -} - /// /// Result of a sync external changes operation. /// @@ -202,9 +53,6 @@ public sealed class SyncResult /// List of uncovered changes (headers, footers, images, etc.). public List? UncoveredChanges { get; init; } - /// The change ID that was acknowledged (if any). - public string? AcknowledgedChangeId { get; init; } - /// Position in WAL after sync. public int? WalPosition { get; init; } @@ -244,7 +92,6 @@ public static SyncResult Synced( Summary = summary, UncoveredChanges = uncoveredChanges, Patches = patches, - AcknowledgedChangeId = acknowledgedChangeId, WalPosition = walPosition, Message = $"Synced: +{summary.Added} -{summary.Removed} ~{summary.Modified}{uncoveredMsg}. WAL position: {walPosition}" }; @@ -254,9 +101,7 @@ public static SyncResult Synced( /// /// JSON serialization context for external changes (AOT-safe). /// -[JsonSerializable(typeof(ExternalChangePatch))] [JsonSerializable(typeof(ExternalElementChange))] -[JsonSerializable(typeof(PendingExternalChanges))] [JsonSerializable(typeof(DiffSummary))] [JsonSerializable(typeof(SyncResult))] [JsonSerializable(typeof(UncoveredChange))] diff --git a/src/DocxMcp/ExternalChanges/ExternalChangeTracker.cs b/src/DocxMcp/ExternalChanges/ExternalChangeTracker.cs deleted file mode 100644 index caed4df..0000000 --- a/src/DocxMcp/ExternalChanges/ExternalChangeTracker.cs +++ /dev/null @@ -1,628 +0,0 @@ -using System.Collections.Concurrent; -using System.Security.Cryptography; -using System.Text.Json; -using DocxMcp.Diff; -using DocxMcp.Helpers; -using DocxMcp.Persistence; -using DocumentFormat.OpenXml.Packaging; -using Microsoft.Extensions.Logging; - -namespace DocxMcp.ExternalChanges; - -/// -/// Tracks external modifications to source files and generates logical patches. -/// Uses FileSystemWatcher for real-time detection with polling fallback. -/// -public sealed class ExternalChangeTracker : IDisposable -{ - private readonly SessionManager _sessions; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _watchedSessions = new(); - private readonly ConcurrentDictionary> _pendingChanges = new(); - private readonly object _lock = new(); - - /// - /// Enable debug logging via DEBUG environment variable. - /// - private static bool DebugEnabled => - Environment.GetEnvironmentVariable("DEBUG") is not null; - - /// - /// Event raised when an external change is detected. - /// - public event EventHandler? ExternalChangeDetected; - - public ExternalChangeTracker(SessionManager sessions, ILogger logger) - { - _sessions = sessions; - _logger = logger; - } - - /// - /// Start watching a session's source file for external changes. - /// - public void StartWatching(string sessionId) - { - try - { - var session = _sessions.Get(sessionId); - if (session.SourcePath is null) - { - _logger.LogDebug("Session {SessionId} has no source path, skipping watch.", sessionId); - return; - } - - if (!File.Exists(session.SourcePath)) - { - _logger.LogWarning("Source file not found for session {SessionId}: {Path}", - sessionId, session.SourcePath); - return; - } - - if (_watchedSessions.ContainsKey(sessionId)) - { - _logger.LogDebug("Session {SessionId} is already being watched.", sessionId); - return; - } - - var watched = new WatchedSession - { - SessionId = sessionId, - SourcePath = session.SourcePath, - LastKnownHash = ComputeFileHash(session.SourcePath), - LastKnownSize = new FileInfo(session.SourcePath).Length, - LastChecked = DateTime.UtcNow, - SessionSnapshot = session.ToBytes() - }; - - // Create FileSystemWatcher - var directory = Path.GetDirectoryName(session.SourcePath)!; - var fileName = Path.GetFileName(session.SourcePath); - - watched.Watcher = new FileSystemWatcher(directory, fileName) - { - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - EnableRaisingEvents = true - }; - - watched.Watcher.Changed += (_, e) => OnFileChanged(sessionId, e.FullPath); - watched.Watcher.Renamed += (_, e) => OnFileRenamed(sessionId, e.OldFullPath, e.FullPath); - - _watchedSessions[sessionId] = watched; - _pendingChanges[sessionId] = []; - - _logger.LogInformation("Started watching session {SessionId} source file: {Path}", - sessionId, session.SourcePath); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to start watching session {SessionId}.", sessionId); - } - } - - /// - /// Stop watching a session's source file. - /// - public void StopWatching(string sessionId) - { - if (_watchedSessions.TryRemove(sessionId, out var watched)) - { - watched.Watcher?.Dispose(); - _logger.LogInformation("Stopped watching session {SessionId}.", sessionId); - } - _pendingChanges.TryRemove(sessionId, out _); - } - - /// - /// Update the session snapshot after applying changes (e.g., after save). - /// - public void UpdateSessionSnapshot(string sessionId) - { - if (_watchedSessions.TryGetValue(sessionId, out var watched)) - { - try - { - var session = _sessions.Get(sessionId); - watched.SessionSnapshot = session.ToBytes(); - watched.LastKnownHash = ComputeFileHash(watched.SourcePath); - watched.LastKnownSize = new FileInfo(watched.SourcePath).Length; - watched.LastChecked = DateTime.UtcNow; - - _logger.LogDebug("Updated session snapshot for {SessionId}.", sessionId); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to update session snapshot for {SessionId}.", sessionId); - } - } - } - - /// - /// Register a session for tracking without creating a FileSystemWatcher. - /// Use this when an external component (e.g., WatchDaemon) manages the FSW. - /// - public void EnsureTracked(string sessionId) - { - if (_watchedSessions.ContainsKey(sessionId)) - return; - - try - { - var session = _sessions.Get(sessionId); - if (session.SourcePath is null || !File.Exists(session.SourcePath)) - return; - - var watched = new WatchedSession - { - SessionId = sessionId, - SourcePath = session.SourcePath, - LastKnownHash = ComputeFileHash(session.SourcePath), - LastKnownSize = new FileInfo(session.SourcePath).Length, - LastChecked = DateTime.UtcNow, - SessionSnapshot = session.ToBytes() - }; - - _watchedSessions[sessionId] = watched; - _pendingChanges[sessionId] = []; - - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Registered session {sessionId} for tracking (no FSW)"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to register session {SessionId} for tracking.", sessionId); - } - } - - /// - /// Manually check for external changes (polling fallback). - /// - public ExternalChangePatch? CheckForChanges(string sessionId) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] CheckForChanges called for session {sessionId}"); - - if (!_watchedSessions.TryGetValue(sessionId, out var watched)) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Session not tracked, registering without FSW"); - // Not being tracked, register without FSW and check - EnsureTracked(sessionId); - if (!_watchedSessions.TryGetValue(sessionId, out watched)) - return null; - } - - return DetectAndGeneratePatch(watched); - } - - /// - /// Get pending external changes for a session. - /// - public PendingExternalChanges GetPendingChanges(string sessionId) - { - var changes = _pendingChanges.GetOrAdd(sessionId, _ => []); - return new PendingExternalChanges - { - SessionId = sessionId, - Changes = changes.OrderByDescending(c => c.DetectedAt).ToList() - }; - } - - /// - /// Get the most recent unacknowledged change for a session. - /// - public ExternalChangePatch? GetLatestUnacknowledgedChange(string sessionId) - { - if (_pendingChanges.TryGetValue(sessionId, out var changes)) - { - return changes - .Where(c => !c.Acknowledged) - .OrderByDescending(c => c.DetectedAt) - .FirstOrDefault(); - } - return null; - } - - /// - /// Check if a session has pending unacknowledged changes. - /// - public bool HasPendingChanges(string sessionId) - { - return GetLatestUnacknowledgedChange(sessionId) is not null; - } - - /// - /// Acknowledge an external change, allowing the LLM to continue editing. - /// - public bool AcknowledgeChange(string sessionId, string changeId) - { - if (_pendingChanges.TryGetValue(sessionId, out var changes)) - { - var change = changes.FirstOrDefault(c => c.Id == changeId); - if (change is not null) - { - change.Acknowledged = true; - change.AcknowledgedAt = DateTime.UtcNow; - - _logger.LogInformation("External change {ChangeId} acknowledged for session {SessionId}.", - changeId, sessionId); - return true; - } - } - return false; - } - - /// - /// Acknowledge all pending changes for a session. - /// - public int AcknowledgeAllChanges(string sessionId) - { - int count = 0; - if (_pendingChanges.TryGetValue(sessionId, out var changes)) - { - foreach (var change in changes.Where(c => !c.Acknowledged)) - { - change.Acknowledged = true; - change.AcknowledgedAt = DateTime.UtcNow; - count++; - } - } - return count; - } - - /// - /// Synchronize the session with external file changes. - /// This is the full sync workflow: - /// 1. Reload document from disk (store FULL bytes in WAL) - /// 2. Re-assign ALL dmcp:ids - /// 3. Detect uncovered changes (headers, images, etc.) - /// 4. Create WAL entry with full document snapshot - /// 5. Force checkpoint - /// 6. Replace in-memory session - /// - /// Session ID to sync. - /// Optional change ID to acknowledge. - /// Result of the sync operation. - public SyncResult SyncExternalChanges(string sessionId, string? changeId = null, bool isImport = false) - { - lock (_lock) - { - try - { - var session = _sessions.Get(sessionId); - if (session.SourcePath is null) - return SyncResult.Failure("Session has no source path. Cannot sync."); - - if (!File.Exists(session.SourcePath)) - return SyncResult.Failure($"Source file not found: {session.SourcePath}"); - - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:sync] Starting sync for session {sessionId}"); - - // 1. Read external file (store FULL bytes) - var newBytes = File.ReadAllBytes(session.SourcePath); - var previousBytes = session.ToBytes(); - - // 2. Compute CONTENT hashes (ignoring IDs) for change detection - // This prevents duplicate WAL entries when only ID attributes differ - var previousContentHash = ContentHasher.ComputeContentHash(previousBytes); - var newContentHash = ContentHasher.ComputeContentHash(newBytes); - - if (DebugEnabled) - { - Console.Error.WriteLine($"[DEBUG:sync] Previous content hash: {previousContentHash}"); - Console.Error.WriteLine($"[DEBUG:sync] New content hash: {newContentHash}"); - } - - if (previousContentHash == newContentHash) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:sync] Content unchanged, skipping sync"); - return SyncResult.NoChanges(); - } - - // 3. Compute full byte hashes for WAL metadata (for debugging/auditing) - var previousHash = ComputeBytesHash(previousBytes); - var newHash = ComputeBytesHash(newBytes); - - if (DebugEnabled) - { - Console.Error.WriteLine($"[DEBUG:sync] Content changed, proceeding with sync"); - Console.Error.WriteLine($"[DEBUG:sync] Previous bytes hash: {previousHash}"); - Console.Error.WriteLine($"[DEBUG:sync] New bytes hash: {newHash}"); - } - - _logger.LogInformation( - "Syncing external changes for session {SessionId}. Previous hash: {Old}, New hash: {New}", - sessionId, previousHash, newHash); - - // 3. Open new document and detect changes BEFORE replacing session - List uncoveredChanges; - DiffResult diff; - - using (var newStream = new MemoryStream(newBytes)) - using (var newDoc = WordprocessingDocument.Open(newStream, isEditable: false)) - { - // Detect uncovered changes (headers, footers, images, etc.) - uncoveredChanges = DiffEngine.DetectUncoveredChanges(session.Document, newDoc); - - // Detect body changes - diff = DiffEngine.Compare(previousBytes, newBytes); - } - - // 4. Create new session with re-assigned IDs - var newSession = DocxSession.FromBytes(newBytes, session.Id, session.SourcePath); - ElementIdManager.EnsureNamespace(newSession.Document); - ElementIdManager.EnsureAllIds(newSession.Document); - - // Get updated bytes after ID assignment - var finalBytes = newSession.ToBytes(); - - // 5. Build WAL entry with FULL document snapshot - var walEntry = new WalEntry - { - EntryType = isImport ? WalEntryType.Import : WalEntryType.ExternalSync, - Timestamp = DateTime.UtcNow, - Patches = JsonSerializer.Serialize(diff.ToPatches(), DocxMcp.Models.DocxJsonContext.Default.ListJsonObject), - Description = BuildSyncDescription(diff.Summary, uncoveredChanges), - SyncMeta = new ExternalSyncMeta - { - SourcePath = session.SourcePath, - PreviousHash = previousHash, - NewHash = newHash, - Summary = diff.Summary, - UncoveredChanges = uncoveredChanges, - DocumentSnapshot = finalBytes - } - }; - - // 6. Append to WAL + checkpoint + replace session - var walPosition = _sessions.AppendExternalSync(sessionId, walEntry, newSession); - - // 7. Update watched session state - if (_watchedSessions.TryGetValue(sessionId, out var watched)) - { - watched.LastKnownHash = newHash; - watched.SessionSnapshot = finalBytes; - watched.LastChecked = DateTime.UtcNow; - } - - // 8. Acknowledge change if specified - if (changeId is not null) - AcknowledgeChange(sessionId, changeId); - - _logger.LogInformation( - "External sync completed for session {SessionId}. Body: +{Added} -{Removed} ~{Modified}. Uncovered: {Uncovered}", - sessionId, diff.Summary.Added, diff.Summary.Removed, diff.Summary.Modified, uncoveredChanges.Count); - - return SyncResult.Synced(diff.Summary, uncoveredChanges, diff.ToPatches(), changeId, walPosition); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to sync external changes for session {SessionId}.", sessionId); - return SyncResult.Failure($"Sync failed: {ex.Message}"); - } - } - } - - private static string BuildSyncDescription(DiffSummary summary, List uncovered) - { - var parts = new List { "[EXTERNAL SYNC]" }; - - if (summary.TotalChanges > 0) - parts.Add($"+{summary.Added} -{summary.Removed} ~{summary.Modified}"); - else - parts.Add("no body changes"); - - if (uncovered.Count > 0) - { - var types = uncovered - .Select(u => u.Type.ToString().ToLowerInvariant()) - .Distinct() - .Take(3); - parts.Add($"({uncovered.Count} uncovered: {string.Join(", ", types)})"); - } - - return string.Join(" ", parts); - } - - private static string ComputeBytesHash(byte[] bytes) - { - var hash = SHA256.HashData(bytes); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - private void OnFileChanged(string sessionId, string filePath) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] FSW fired for {Path.GetFileName(filePath)} (session {sessionId})"); - - // Debounce: wait a bit for file to be fully written - Task.Delay(500).ContinueWith(_ => - { - try - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Processing FSW event after 500ms debounce"); - - if (_watchedSessions.TryGetValue(sessionId, out var watched)) - { - var patch = DetectAndGeneratePatch(watched); - if (patch is not null) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Change detected, raising event (patch={patch.Id})"); - RaiseExternalChangeDetected(sessionId, patch); - } - else if (DebugEnabled) - { - Console.Error.WriteLine($"[DEBUG:tracker] No changes detected after FSW event"); - } - } - else if (DebugEnabled) - { - Console.Error.WriteLine($"[DEBUG:tracker] Session {sessionId} not in watched sessions"); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error processing file change for session {SessionId}.", sessionId); - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Exception in OnFileChanged: {ex}"); - } - }); - } - - private void OnFileRenamed(string sessionId, string oldPath, string newPath) - { - _logger.LogWarning("Source file for session {SessionId} was renamed from {OldPath} to {NewPath}.", - sessionId, oldPath, newPath); - - // Update the watched path - if (_watchedSessions.TryGetValue(sessionId, out var watched)) - { - watched.SourcePath = newPath; - } - } - - private ExternalChangePatch? DetectAndGeneratePatch(WatchedSession watched) - { - lock (_lock) - { - try - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] DetectAndGeneratePatch for {Path.GetFileName(watched.SourcePath)}"); - - if (!File.Exists(watched.SourcePath)) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Source file does not exist: {watched.SourcePath}"); - _logger.LogWarning("Source file no longer exists: {Path}", watched.SourcePath); - return null; - } - - // Check if file has actually changed - var currentHash = ComputeFileHash(watched.SourcePath); - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] File hash: {currentHash}, Last known: {watched.LastKnownHash}"); - if (currentHash == watched.LastKnownHash) - { - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Hash unchanged, no changes"); - return null; // No change - } - - _logger.LogInformation("External change detected for session {SessionId}. Previous hash: {Old}, New hash: {New}", - watched.SessionId, watched.LastKnownHash, currentHash); - - // Read the external file - var externalBytes = File.ReadAllBytes(watched.SourcePath); - - // Compare with session snapshot - var diff = DiffEngine.Compare(watched.SessionSnapshot, externalBytes); - - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] Diff result: HasChanges={diff.HasChanges}, HasAnyChanges={diff.HasAnyChanges}, Changes={diff.Changes.Count}, Uncovered={diff.UncoveredChanges.Count}"); - - if (!diff.HasChanges) - { - // File changed but no logical diff (maybe just metadata) - if (DebugEnabled) - Console.Error.WriteLine($"[DEBUG:tracker] No body changes, updating hash only"); - watched.LastKnownHash = currentHash; - watched.LastChecked = DateTime.UtcNow; - return null; - } - - // Generate the external change patch - var patch = new ExternalChangePatch - { - Id = $"ext_{watched.SessionId}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid().ToString("N")[..8]}", - SessionId = watched.SessionId, - DetectedAt = DateTime.UtcNow, - SourcePath = watched.SourcePath, - PreviousHash = watched.LastKnownHash, - NewHash = currentHash, - Summary = diff.Summary, - Changes = diff.Changes.Select(ExternalElementChange.FromElementChange).ToList(), - Patches = diff.ToPatches() - }; - - // Store in pending changes - if (_pendingChanges.TryGetValue(watched.SessionId, out var changes)) - { - changes.Add(patch); - } - - // Update watched state - watched.LastKnownHash = currentHash; - watched.LastChecked = DateTime.UtcNow; - - _logger.LogInformation("Generated external change patch {PatchId} for session {SessionId}: {Summary}", - patch.Id, watched.SessionId, $"{diff.Summary.TotalChanges} changes"); - - return patch; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to generate external change patch for session {SessionId}.", - watched.SessionId); - return null; - } - } - } - - private void RaiseExternalChangeDetected(string sessionId, ExternalChangePatch patch) - { - try - { - ExternalChangeDetected?.Invoke(this, new ExternalChangeDetectedEventArgs - { - SessionId = sessionId, - Patch = patch - }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error in ExternalChangeDetected event handler."); - } - } - - private static string ComputeFileHash(string path) - { - using var stream = File.OpenRead(path); - var hash = SHA256.HashData(stream); - return Convert.ToHexString(hash).ToLowerInvariant(); - } - - public void Dispose() - { - foreach (var watched in _watchedSessions.Values) - { - watched.Watcher?.Dispose(); - } - _watchedSessions.Clear(); - _pendingChanges.Clear(); - } - - private sealed class WatchedSession - { - public required string SessionId { get; init; } - public required string SourcePath { get; set; } - public required string LastKnownHash { get; set; } - public required long LastKnownSize { get; set; } - public required DateTime LastChecked { get; set; } - public required byte[] SessionSnapshot { get; set; } - public FileSystemWatcher? Watcher { get; set; } - } -} - -/// -/// Event args for external change detection. -/// -public sealed class ExternalChangeDetectedEventArgs : EventArgs -{ - public required string SessionId { get; init; } - public required ExternalChangePatch Patch { get; init; } -} diff --git a/src/DocxMcp/Helpers/ElementFactory.cs b/src/DocxMcp/Helpers/ElementFactory.cs index e49a3bd..625a5e3 100644 --- a/src/DocxMcp/Helpers/ElementFactory.cs +++ b/src/DocxMcp/Helpers/ElementFactory.cs @@ -301,10 +301,19 @@ private static Paragraph CreateParagraph(JsonElement value) { paragraph.ParagraphProperties = CreateParagraphProperties(props); } - else if (value.TryGetProperty("style", out var style) && !value.TryGetProperty("runs", out _)) + else if (value.TryGetProperty("style", out var style)) { - // Legacy: when no runs array, "style" applies to both paragraph and run - paragraph.ParagraphProperties = CreateParagraphProperties(style); + if (value.TryGetProperty("runs", out _)) + { + // When runs are present, "style" is a paragraph style name (e.g. "Heading1") + paragraph.ParagraphProperties ??= new ParagraphProperties(); + paragraph.ParagraphProperties.ParagraphStyleId = new ParagraphStyleId { Val = style.GetString() }; + } + else + { + // Legacy: when no runs array, "style" applies to both paragraph and run + paragraph.ParagraphProperties = CreateParagraphProperties(style); + } } PopulateRuns(paragraph, value); diff --git a/src/DocxMcp/Helpers/GrpcErrorHelper.cs b/src/DocxMcp/Helpers/GrpcErrorHelper.cs new file mode 100644 index 0000000..5c6a8d0 --- /dev/null +++ b/src/DocxMcp/Helpers/GrpcErrorHelper.cs @@ -0,0 +1,29 @@ +using Grpc.Core; +using ModelContextProtocol; + +namespace DocxMcp.Helpers; + +/// +/// Wraps gRPC errors as McpException so the MCP SDK includes the error message +/// in tool responses instead of the generic "An error occurred invoking 'tool_name'." +/// +public static class GrpcErrorHelper +{ + public static McpException Wrap(RpcException ex, string context) + { + var message = ex.StatusCode switch + { + StatusCode.Unavailable => $"Storage backend unavailable: {context}. The service may be restarting.", + StatusCode.DeadlineExceeded => $"Storage operation timed out: {context}.", + StatusCode.NotFound => $"Not found: {context}.", + StatusCode.Internal => $"Storage internal error: {context} — {ex.Status.Detail}", + _ => $"Storage error ({ex.StatusCode}): {context} — {ex.Status.Detail}", + }; + return new McpException(message, ex); + } + + public static McpException WrapNotFound(string docId) + { + return new McpException($"Document '{docId}' not found. Use document_list to see open sessions."); + } +} diff --git a/src/DocxMcp/Helpers/StyleHelper.cs b/src/DocxMcp/Helpers/StyleHelper.cs index 89dfb28..3fc7a93 100644 --- a/src/DocxMcp/Helpers/StyleHelper.cs +++ b/src/DocxMcp/Helpers/StyleHelper.cs @@ -528,6 +528,8 @@ public static void MergeTableRowProperties(TableRow row, JsonElement style) public static List CollectRuns(OpenXmlElement element) { + if (element is Run r) + return [r]; return element.Descendants().ToList(); } diff --git a/src/DocxMcp/Persistence/HistoryTypes.cs b/src/DocxMcp/Persistence/HistoryTypes.cs index 912f6ac..d5a287c 100644 --- a/src/DocxMcp/Persistence/HistoryTypes.cs +++ b/src/DocxMcp/Persistence/HistoryTypes.cs @@ -7,6 +7,12 @@ public sealed class UndoRedoResult public int Position { get; set; } public int Steps { get; set; } public string Message { get; set; } = ""; + + /// + /// The serialized document bytes at the new position. + /// Avoids an extra gRPC roundtrip for auto-save after undo/redo. + /// + public byte[]? CurrentBytes { get; set; } } public sealed class HistoryEntry diff --git a/src/DocxMcp/Persistence/MappedWal.cs b/src/DocxMcp/Persistence/MappedWal.cs deleted file mode 100644 index 260e18e..0000000 --- a/src/DocxMcp/Persistence/MappedWal.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System.IO.MemoryMappedFiles; -using System.Text; - -namespace DocxMcp.Persistence; - -/// -/// A memory-mapped write-ahead log. Appends go to the OS page cache (RAM); -/// the kernel flushes dirty pages to disk in the background. -/// -/// File format: [8 bytes: data length (long)][UTF-8 JSONL data...] -/// -public sealed class MappedWal : IDisposable -{ - private const int HeaderSize = 8; - private const long InitialCapacity = 1024 * 1024; // 1 MB - - private readonly string _path; - private readonly object _lock = new(); - private MemoryMappedFile _mmf; - private MemoryMappedViewAccessor _accessor; - private long _dataLength; - private long _capacity; - - /// - /// Byte offsets of each JSONL line within the data region (relative to HeaderSize). - /// _lineOffsets[i] = offset of line i, _lineOffsets[i+1] or _dataLength = end. - /// - private readonly List _lineOffsets = new(); - - public MappedWal(string path) - { - _path = path; - - if (File.Exists(path) && new FileInfo(path).Length >= HeaderSize) - { - var fileSize = new FileInfo(path).Length; - _capacity = Math.Max(fileSize, InitialCapacity); - _mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, _capacity); - _accessor = _mmf.CreateViewAccessor(); - _dataLength = _accessor.ReadInt64(0); - // Sanity check - if (_dataLength < 0 || _dataLength > _capacity - HeaderSize) - _dataLength = 0; - BuildLineOffsets(); - } - else - { - _capacity = InitialCapacity; - EnsureFileWithCapacity(_path, _capacity); - _mmf = MemoryMappedFile.CreateFromFile(_path, FileMode.Open, null, _capacity); - _accessor = _mmf.CreateViewAccessor(); - _dataLength = 0; - _accessor.Write(0, _dataLength); - } - } - - public int EntryCount - { - get - { - lock (_lock) - { - return _lineOffsets.Count; - } - } - } - - /// - /// Re-read the data length header from the memory-mapped file and rebuild - /// line offsets if another process has appended to the WAL. - /// No-op when data length is unchanged (common single-process case). - /// - public void Refresh() - { - lock (_lock) - { - var currentLength = _accessor.ReadInt64(0); - if (currentLength != _dataLength - && currentLength >= 0 - && currentLength <= _capacity - HeaderSize) - { - _dataLength = currentLength; - BuildLineOffsets(); - } - } - } - - public void Append(string line) - { - lock (_lock) - { - var bytes = Encoding.UTF8.GetBytes(line + "\n"); - var needed = HeaderSize + _dataLength + bytes.Length; - if (needed > _capacity) - Grow(needed); - - // Record offset of this new line before writing - _lineOffsets.Add(_dataLength); - - _accessor.WriteArray(HeaderSize + (int)_dataLength, bytes, 0, bytes.Length); - _dataLength += bytes.Length; - _accessor.Write(0, _dataLength); - _accessor.Flush(); - } - } - - /// - /// Read entries in range [fromIndex, toIndex). - /// - public List ReadRange(int fromIndex, int toIndex) - { - lock (_lock) - { - if (fromIndex < 0) fromIndex = 0; - if (toIndex > _lineOffsets.Count) toIndex = _lineOffsets.Count; - if (fromIndex >= toIndex) - return new(); - - var result = new List(toIndex - fromIndex); - for (int i = fromIndex; i < toIndex; i++) - { - result.Add(ReadLineAt(i)); - } - return result; - } - } - - /// - /// Read a single entry by index. - /// - public string ReadEntry(int index) - { - lock (_lock) - { - if (index < 0 || index >= _lineOffsets.Count) - throw new ArgumentOutOfRangeException(nameof(index), - $"Index {index} out of range [0, {_lineOffsets.Count})."); - return ReadLineAt(index); - } - } - - public List ReadAll() - { - lock (_lock) - { - return ReadRangeUnlocked(0, _lineOffsets.Count); - } - } - - /// - /// Keep first entries, discard the rest. - /// - public void TruncateAt(int count) - { - lock (_lock) - { - if (count <= 0) - { - _dataLength = 0; - _lineOffsets.Clear(); - _accessor.Write(0, _dataLength); - _accessor.Flush(); - return; - } - - if (count >= _lineOffsets.Count) - return; // nothing to truncate - - // New data length = start of the entry at 'count' (i.e., end of entry count-1) - _dataLength = _lineOffsets[count]; - _lineOffsets.RemoveRange(count, _lineOffsets.Count - count); - _accessor.Write(0, _dataLength); - _accessor.Flush(); - } - } - - public void Truncate() - { - lock (_lock) - { - _dataLength = 0; - _lineOffsets.Clear(); - _accessor.Write(0, _dataLength); - _accessor.Flush(); - } - } - - public void Dispose() - { - lock (_lock) - { - _accessor.Dispose(); - _mmf.Dispose(); - } - } - - /// - /// Build the offset index by scanning the data region for newline characters. - /// Called once on construction. - /// - private void BuildLineOffsets() - { - _lineOffsets.Clear(); - if (_dataLength == 0) - return; - - var bytes = new byte[_dataLength]; - _accessor.ReadArray(HeaderSize, bytes, 0, (int)_dataLength); - - for (long i = 0; i < _dataLength; i++) - { - if (i == 0 || bytes[i - 1] == (byte)'\n') - { - // Skip empty trailing lines - if (i < _dataLength && bytes[i] != (byte)'\n') - _lineOffsets.Add(i); - } - } - } - - /// - /// Read a single line at the given offset index. Must be called under _lock. - /// - private string ReadLineAt(int index) - { - var start = _lineOffsets[index]; - var end = (index + 1 < _lineOffsets.Count) - ? _lineOffsets[index + 1] - : _dataLength; - - // Trim trailing newline - var length = (int)(end - start); - if (length > 0) - { - var bytes = new byte[length]; - _accessor.ReadArray(HeaderSize + (int)start, bytes, 0, length); - // Strip trailing \n - var text = Encoding.UTF8.GetString(bytes).TrimEnd('\n'); - return text; - } - return ""; - } - - private List ReadRangeUnlocked(int fromIndex, int toIndex) - { - if (fromIndex < 0) fromIndex = 0; - if (toIndex > _lineOffsets.Count) toIndex = _lineOffsets.Count; - if (fromIndex >= toIndex) - return new(); - - var result = new List(toIndex - fromIndex); - for (int i = fromIndex; i < toIndex; i++) - { - result.Add(ReadLineAt(i)); - } - return result; - } - - private void Grow(long needed) - { - // Must be called under _lock - _accessor.Dispose(); - _mmf.Dispose(); - - var newCapacity = _capacity; - while (newCapacity < needed) - newCapacity *= 2; - - _capacity = newCapacity; - EnsureFileWithCapacity(_path, _capacity); - _mmf = MemoryMappedFile.CreateFromFile(_path, FileMode.Open, null, _capacity); - _accessor = _mmf.CreateViewAccessor(); - } - - private static void EnsureFileWithCapacity(string path, long capacity) - { - using var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite); - if (fs.Length < capacity) - fs.SetLength(capacity); - } -} diff --git a/src/DocxMcp/Persistence/SessionIndex.cs b/src/DocxMcp/Persistence/SessionIndex.cs index b1cf0d1..865cdad 100644 --- a/src/DocxMcp/Persistence/SessionIndex.cs +++ b/src/DocxMcp/Persistence/SessionIndex.cs @@ -2,29 +2,106 @@ namespace DocxMcp.Persistence; -public sealed class SessionIndexFile +/// +/// Session index containing metadata about all sessions. +/// +public sealed class SessionIndex { + [JsonPropertyName("version")] public int Version { get; set; } = 1; - public List Sessions { get; set; } = new(); + + [JsonPropertyName("sessions")] + public List Sessions { get; set; } = []; + + /// + /// Get a session entry by ID. + /// + public SessionIndexEntry? GetById(string id) => + Sessions.FirstOrDefault(s => s.Id == id); + + /// + /// Try to get a session entry by ID. + /// + public bool TryGetValue(string id, out SessionIndexEntry? entry) + { + entry = GetById(id); + return entry is not null; + } + + /// + /// Check if a session exists. + /// + public bool ContainsKey(string id) => + Sessions.Any(s => s.Id == id); + + /// + /// Insert or update a session entry. + /// + public void Upsert(SessionIndexEntry entry) + { + var existing = Sessions.FindIndex(s => s.Id == entry.Id); + if (existing >= 0) + Sessions[existing] = entry; + else + Sessions.Add(entry); + } + + /// + /// Remove a session entry by ID. + /// + public bool Remove(string id) + { + var existing = Sessions.FindIndex(s => s.Id == id); + if (existing >= 0) + { + Sessions.RemoveAt(existing); + return true; + } + return false; + } } -public sealed class SessionEntry +/// +/// A single session entry in the index. +/// +public sealed class SessionIndexEntry { + [JsonPropertyName("id")] public string Id { get; set; } = ""; + + [JsonPropertyName("source_path")] public string? SourcePath { get; set; } + + [JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; } + + [JsonPropertyName("last_modified_at")] public DateTime LastModifiedAt { get; set; } - public string DocxFile { get; set; } = ""; + + [JsonPropertyName("docx_file")] + public string? DocxFile { get; set; } + + [JsonPropertyName("wal_count")] public int WalCount { get; set; } - public int CursorPosition { get; set; } = -1; - public List CheckpointPositions { get; set; } = new(); + + [JsonPropertyName("cursor_position")] + public int CursorPosition { get; set; } + + [JsonPropertyName("checkpoint_positions")] + public List CheckpointPositions { get; set; } = []; + + // Convenience property for code that uses WalPosition + [JsonIgnore] + public ulong WalPosition + { + get => (ulong)WalCount; + set => WalCount = (int)value; + } } -[JsonSerializable(typeof(SessionIndexFile))] -[JsonSerializable(typeof(SessionEntry))] -[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(SessionIndex))] +[JsonSerializable(typeof(SessionIndexEntry))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] -[JsonSourceGenerationOptions( - PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, - WriteIndented = true)] +[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] internal partial class SessionJsonContext : JsonSerializerContext { } diff --git a/src/DocxMcp/Persistence/SessionLock.cs b/src/DocxMcp/Persistence/SessionLock.cs deleted file mode 100644 index 3aece2a..0000000 --- a/src/DocxMcp/Persistence/SessionLock.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace DocxMcp.Persistence; - -/// -/// IDisposable wrapper around a FileStream opened with FileShare.None, -/// providing cross-process advisory file locking for index mutations. -/// Process crash releases lock automatically (OS closes file descriptors). -/// -public sealed class SessionLock : IDisposable -{ - private FileStream? _lockStream; - - internal SessionLock(FileStream lockStream) => _lockStream = lockStream; - - public void Dispose() - { - var stream = Interlocked.Exchange(ref _lockStream, null); - stream?.Dispose(); - } -} diff --git a/src/DocxMcp/Persistence/SessionStore.cs b/src/DocxMcp/Persistence/SessionStore.cs deleted file mode 100644 index 37bc344..0000000 --- a/src/DocxMcp/Persistence/SessionStore.cs +++ /dev/null @@ -1,438 +0,0 @@ -using System.Collections.Concurrent; -using System.IO.MemoryMappedFiles; -using System.Text.Json; -using Microsoft.Extensions.Logging; - -namespace DocxMcp.Persistence; - -/// -/// Handles all disk I/O for session persistence using memory-mapped files. -/// Baselines are written via MemoryMappedFile (OS page cache handles flushing). -/// WAL files are kept mapped in memory for the lifetime of each session. -/// -public sealed class SessionStore : IDisposable -{ - private readonly string _sessionsDir; - private readonly string _indexPath; - private readonly string _lockPath; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _openWals = new(); - - public SessionStore(ILogger logger, string? sessionsDir = null) - { - _logger = logger; - _sessionsDir = sessionsDir - ?? Environment.GetEnvironmentVariable("DOCX_SESSIONS_DIR") - ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "docx-mcp", "sessions"); - _indexPath = Path.Combine(_sessionsDir, "index.json"); - _lockPath = Path.Combine(_sessionsDir, ".lock"); - } - - public string SessionsDir => _sessionsDir; - - public void EnsureDirectory() - { - Directory.CreateDirectory(_sessionsDir); - } - - // --- Cross-process file lock --- - - /// - /// Acquire an exclusive file lock for cross-process index mutations. - /// Uses exponential backoff with jitter on contention. - /// - public SessionLock AcquireLock(int maxRetries = 20, int initialDelayMs = 50) - { - EnsureDirectory(); - var delay = initialDelayMs; - for (int attempt = 0; attempt <= maxRetries; attempt++) - { - try - { - var fs = new FileStream(_lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); - return new SessionLock(fs); - } - catch (IOException) when (attempt < maxRetries) - { - Thread.Sleep(delay); - delay = Math.Min(delay * 2, 2000); - } - } - - throw new TimeoutException( - $"Failed to acquire session lock after {maxRetries} retries ({_lockPath})."); - } - - // --- Index operations --- - - public SessionIndexFile LoadIndex() - { - if (!File.Exists(_indexPath)) - return new SessionIndexFile(); - - try - { - var json = File.ReadAllText(_indexPath); - var index = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndexFile); - if (index is null || index.Version != 1) - return new SessionIndexFile(); - return index; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read session index; starting fresh."); - return new SessionIndexFile(); - } - } - - public void SaveIndex(SessionIndexFile index) - { - EnsureDirectory(); - var json = JsonSerializer.Serialize(index, SessionJsonContext.Default.SessionIndexFile); - AtomicWrite(_indexPath, json); - } - - // --- Baseline .docx operations (memory-mapped) --- - - /// - /// Persist document bytes as a baseline snapshot via memory-mapped file. - /// The write goes to the OS page cache; the kernel flushes to disk asynchronously. - /// File format: [8 bytes: data length][docx bytes] - /// - public void PersistBaseline(string sessionId, byte[] bytes) - { - EnsureDirectory(); - var path = BaselinePath(sessionId); - var capacity = bytes.Length + 8; - - // Ensure file exists with sufficient capacity - using var fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None); - fs.SetLength(capacity); - fs.Close(); - - using var mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, capacity); - using var accessor = mmf.CreateViewAccessor(); - accessor.Write(0, (long)bytes.Length); - accessor.WriteArray(8, bytes, 0, bytes.Length); - accessor.Flush(); - } - - /// - /// Load baseline snapshot bytes from a memory-mapped file. - /// - public byte[] LoadBaseline(string sessionId) - { - var path = BaselinePath(sessionId); - using var mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); - using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); - var length = accessor.ReadInt64(0); - if (length <= 0) - throw new InvalidOperationException($"Baseline for session '{sessionId}' is empty or corrupt."); - var bytes = new byte[length]; - accessor.ReadArray(8, bytes, 0, (int)length); - return bytes; - } - - public void DeleteSession(string sessionId) - { - // Close and remove the WAL mapping first - if (_openWals.TryRemove(sessionId, out var wal)) - wal.Dispose(); - - TryDelete(BaselinePath(sessionId)); - TryDelete(WalPath(sessionId)); - DeleteCheckpoints(sessionId); - } - - // --- WAL operations (memory-mapped) --- - - /// - /// Get or create a memory-mapped WAL for a session. - /// The WAL stays mapped for the session's lifetime. - /// - public MappedWal GetOrCreateWal(string sessionId) - { - return _openWals.GetOrAdd(sessionId, id => - { - EnsureDirectory(); - return new MappedWal(WalPath(id)); - }); - } - - public void AppendWal(string sessionId, string patchesJson) - { - var entry = new WalEntry - { - Patches = patchesJson, - Timestamp = DateTime.UtcNow - }; - var line = JsonSerializer.Serialize(entry, WalJsonContext.Default.WalEntry); - GetOrCreateWal(sessionId).Append(line); - } - - public void AppendWal(string sessionId, string patchesJson, string? description) - { - var entry = new WalEntry - { - Patches = patchesJson, - Timestamp = DateTime.UtcNow, - Description = description - }; - var line = JsonSerializer.Serialize(entry, WalJsonContext.Default.WalEntry); - GetOrCreateWal(sessionId).Append(line); - } - - public List ReadWal(string sessionId) - { - var wal = GetOrCreateWal(sessionId); - wal.Refresh(); - var patches = new List(); - - foreach (var line in wal.ReadAll()) - { - if (string.IsNullOrWhiteSpace(line)) - continue; - try - { - var entry = JsonSerializer.Deserialize(line, WalJsonContext.Default.WalEntry); - if (entry?.Patches is not null) - patches.Add(entry.Patches); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Skipping corrupt WAL line for session {SessionId}.", sessionId); - } - } - - return patches; - } - - /// - /// Read WAL entries in range [from, to) as patch strings. - /// - public List ReadWalRange(string sessionId, int from, int to) - { - var wal = GetOrCreateWal(sessionId); - wal.Refresh(); - var lines = wal.ReadRange(from, to); - var patches = new List(lines.Count); - - foreach (var line in lines) - { - if (string.IsNullOrWhiteSpace(line)) - continue; - try - { - var entry = JsonSerializer.Deserialize(line, WalJsonContext.Default.WalEntry); - if (entry?.Patches is not null) - patches.Add(entry.Patches); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Skipping corrupt WAL line for session {SessionId}.", sessionId); - } - } - - return patches; - } - - /// - /// Read WAL entries with full metadata (timestamps, descriptions). - /// - public List ReadWalEntries(string sessionId) - { - var wal = GetOrCreateWal(sessionId); - wal.Refresh(); - var entries = new List(); - - foreach (var line in wal.ReadAll()) - { - if (string.IsNullOrWhiteSpace(line)) - continue; - try - { - var entry = JsonSerializer.Deserialize(line, WalJsonContext.Default.WalEntry); - if (entry is not null) - entries.Add(entry); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Skipping corrupt WAL entry for session {SessionId}.", sessionId); - } - } - - return entries; - } - - public int WalEntryCount(string sessionId) - { - var wal = GetOrCreateWal(sessionId); - wal.Refresh(); - return wal.EntryCount; - } - - public void TruncateWal(string sessionId) - { - GetOrCreateWal(sessionId).Truncate(); - } - - /// - /// Keep first WAL entries, discard the rest. - /// - public void TruncateWalAt(string sessionId, int count) - { - GetOrCreateWal(sessionId).TruncateAt(count); - } - - // --- Checkpoint operations --- - - public string CheckpointPath(string sessionId, int position) => - Path.Combine(_sessionsDir, $"{sessionId}.ckpt.{position}.docx"); - - public string ImportCheckpointPath(string sessionId, int position) => - Path.Combine(_sessionsDir, $"{sessionId}.import.{position}.docx"); - - /// - /// Persist a checkpoint snapshot at the given WAL position. - /// Same memory-mapped format as baseline. - /// - public void PersistCheckpoint(string sessionId, int position, byte[] bytes) - { - PersistCheckpointToPath(CheckpointPath(sessionId, position), bytes); - } - - /// - /// Persist an import checkpoint snapshot at the given WAL position. - /// Uses the import checkpoint naming convention ({sessionId}.import.{position}.docx). - /// - public void PersistImportCheckpoint(string sessionId, int position, byte[] bytes) - { - PersistCheckpointToPath(ImportCheckpointPath(sessionId, position), bytes); - } - - private void PersistCheckpointToPath(string path, byte[] bytes) - { - EnsureDirectory(); - var capacity = bytes.Length + 8; - - using var fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None); - fs.SetLength(capacity); - fs.Close(); - - using var mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, capacity); - using var accessor = mmf.CreateViewAccessor(); - accessor.Write(0, (long)bytes.Length); - accessor.WriteArray(8, bytes, 0, bytes.Length); - accessor.Flush(); - } - - /// - /// Load the nearest checkpoint at or before targetPosition. - /// Falls back to baseline (position 0) if no checkpoint qualifies. - /// - public (int position, byte[] bytes) LoadNearestCheckpoint(string sessionId, int targetPosition, List knownPositions) - { - // Find the largest checkpoint position <= targetPosition - int bestPos = 0; - foreach (var pos in knownPositions) - { - if (pos <= targetPosition && pos > bestPos) - bestPos = pos; - } - - if (bestPos > 0) - { - // Check import checkpoint first (takes precedence), then regular checkpoint - var importPath = ImportCheckpointPath(sessionId, bestPos); - var ckptPath = CheckpointPath(sessionId, bestPos); - var path = File.Exists(importPath) ? importPath : ckptPath; - - if (File.Exists(path)) - { - try - { - using var mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); - using var accessor = mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); - var length = accessor.ReadInt64(0); - if (length > 0) - { - var bytes = new byte[length]; - accessor.ReadArray(8, bytes, 0, (int)length); - return (bestPos, bytes); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load checkpoint at position {Position} for session {SessionId}; falling back.", - bestPos, sessionId); - } - } - } - - // Fallback to baseline - return (0, LoadBaseline(sessionId)); - } - - /// - /// Delete all checkpoint files for a session. - /// - public void DeleteCheckpoints(string sessionId) - { - try - { - var dir = new DirectoryInfo(_sessionsDir); - if (!dir.Exists) return; - - foreach (var file in dir.GetFiles($"{sessionId}.ckpt.*.docx")) - TryDelete(file.FullName); - foreach (var file in dir.GetFiles($"{sessionId}.import.*.docx")) - TryDelete(file.FullName); - } - catch { /* best effort */ } - } - - /// - /// Delete checkpoint files for positions strictly greater than afterPosition. - /// - public void DeleteCheckpointsAfter(string sessionId, int afterPosition, List knownPositions) - { - foreach (var pos in knownPositions) - { - if (pos > afterPosition) - { - TryDelete(CheckpointPath(sessionId, pos)); - TryDelete(ImportCheckpointPath(sessionId, pos)); - } - } - } - - // --- Path helpers --- - - public string BaselinePath(string sessionId) => - Path.Combine(_sessionsDir, $"{sessionId}.docx"); - - public string WalPath(string sessionId) => - Path.Combine(_sessionsDir, $"{sessionId}.wal"); - - private void AtomicWrite(string path, string content) - { - var tempPath = path + ".tmp"; - File.WriteAllText(tempPath, content); - File.Move(tempPath, path, overwrite: true); - } - - private static void TryDelete(string path) - { - try { if (File.Exists(path)) File.Delete(path); } - catch { /* best effort */ } - } - - public void Dispose() - { - foreach (var wal in _openWals.Values) - wal.Dispose(); - _openWals.Clear(); - } -} diff --git a/src/DocxMcp/Program.cs b/src/DocxMcp/Program.cs index 5d372da..37efc1d 100644 --- a/src/DocxMcp/Program.cs +++ b/src/DocxMcp/Program.cs @@ -1,57 +1,188 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using DocxMcp; -using DocxMcp.Persistence; -using DocxMcp.Tools; using DocxMcp.ExternalChanges; +using DocxMcp.Grpc; +using DocxMcp.Tools; -var builder = Host.CreateApplicationBuilder(args); +var transport = Environment.GetEnvironmentVariable("MCP_TRANSPORT") ?? "stdio"; -// MCP requirement: all logging goes to stderr -builder.Logging.AddConsole(options => +if (transport == "http") { - options.LogToStandardErrorThreshold = LogLevel.Trace; -}); + // ─── HTTP mode: local dev / behind proxy (Koyeb) ─── + var builder = WebApplication.CreateBuilder(args); + + builder.Logging.AddConsole(); + builder.Logging.SetMinimumLevel(LogLevel.Information); + builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); + + RegisterStorageServices(builder.Services); -// Register persistence and session management -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); + // Multi-tenant: pool of SessionManagers, one per tenant + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHttpContextAccessor(); + builder.Services.AddScoped(); -// Register external change tracking -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); + builder.Services + .AddMcpServer(ConfigureMcpServer) + .WithHttpTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); -// Register MCP server with stdio transport and explicit tool types (AOT-safe) -builder.Services - .AddMcpServer(options => + var app = builder.Build(); + app.MapMcp("/mcp"); + app.Use(async (context, next) => { - options.ServerInfo = new() + if (context.Request.Path == "/health" && context.Request.Method == "GET") { - Name = "docx-mcp", - Version = "2.2.0" + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("""{"healthy":true,"version":"1.7.0"}"""); + return; + } + await next(); + }); + await app.RunAsync(); +} +else +{ + // ─── Stdio mode: Claude Code local, single tenant (unchanged behavior) ─── + var builder = Host.CreateApplicationBuilder(args); + + // MCP requirement: all logging goes to stderr + builder.Logging.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); + + RegisterStorageServices(builder.Services); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + + builder.Services + .AddMcpServer(ConfigureMcpServer) + .WithStdioServerTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); + + await builder.Build().RunAsync(); +} + +// ─── Shared helpers ─── + +static void ConfigureMcpServer(McpServerOptions options) +{ + options.ServerInfo = new() + { + Name = "docx-mcp", + Version = "1.7.0" + }; +} + +static void RegisterStorageServices(IServiceCollection services) +{ + var storageOptions = StorageClientOptions.FromEnvironment(); + + // ── IHistoryStorage ── + if (!string.IsNullOrEmpty(storageOptions.ServerUrl)) + { + // Remote history storage (Cloudflare R2, etc.) + services.AddSingleton(sp => + { + var logger = sp.GetService>(); + var launcherLogger = sp.GetService>(); + var launcher = new GrpcLauncher(storageOptions, launcherLogger); + return HistoryStorageClient.CreateAsync(storageOptions, launcher, logger).GetAwaiter().GetResult(); + }); + } + else + { + // Local embedded history storage + NativeStorage.Init(storageOptions.GetEffectiveLocalStorageDir()); + + var handler = new System.Net.Http.SocketsHttpHandler + { + ConnectCallback = (_, _) => + new ValueTask(new InMemoryPipeStream()) }; - }) - .WithStdioServerTransport() - // Document management - .WithTools() - // Query tools - .WithTools() - .WithTools() - .WithTools() - .WithTools() - // Element operations (individual tools with focused documentation) - .WithTools() - .WithTools() - .WithTools() - // Export, history, comments, styles - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools() - .WithTools(); - -await builder.Build().RunAsync(); + var channel = Grpc.Net.Client.GrpcChannel.ForAddress("http://in-memory", + HistoryStorageClient.CreateRetryChannelOptions( + new Grpc.Net.Client.GrpcChannelOptions { HttpHandler = handler })); + + services.AddSingleton(sp => + new HistoryStorageClient(channel, sp.GetService>())); + + // If no remote sync either, use same embedded channel + if (string.IsNullOrEmpty(storageOptions.SyncServerUrl)) + { + services.AddSingleton(sp => + new SyncStorageClient(channel, sp.GetService>())); + } + } + + // ── ISyncStorage ── + if (!string.IsNullOrEmpty(storageOptions.SyncServerUrl)) + { + // Remote sync/watch (Google Drive, etc.) + services.AddSingleton(sp => + { + var logger = sp.GetService>(); + var syncChannel = Grpc.Net.Client.GrpcChannel.ForAddress(storageOptions.SyncServerUrl, + HistoryStorageClient.CreateRetryChannelOptions()); + return new SyncStorageClient(syncChannel, logger); + }); + } + else if (!string.IsNullOrEmpty(storageOptions.ServerUrl)) + { + // Remote history but local embedded sync/watch + NativeStorage.Init(storageOptions.GetEffectiveLocalStorageDir()); + services.AddSingleton(sp => + { + var logger = sp.GetService>(); + var handler = new System.Net.Http.SocketsHttpHandler + { + ConnectCallback = (_, _) => + new ValueTask(new InMemoryPipeStream()) + }; + var channel = Grpc.Net.Client.GrpcChannel.ForAddress("http://in-memory", + HistoryStorageClient.CreateRetryChannelOptions( + new Grpc.Net.Client.GrpcChannelOptions { HttpHandler = handler })); + return new SyncStorageClient(channel, logger); + }); + } + // else: already registered above in the embedded block +} diff --git a/src/DocxMcp/SessionManager.cs b/src/DocxMcp/SessionManager.cs index edec193..04a9f8b 100644 --- a/src/DocxMcp/SessionManager.cs +++ b/src/DocxMcp/SessionManager.cs @@ -1,104 +1,166 @@ -using System.Collections.Concurrent; using System.Text.Json; -using DocxMcp.ExternalChanges; +using DocxMcp.Grpc; using DocxMcp.Persistence; using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +using GrpcWalEntry = DocxMcp.Grpc.WalEntryDto; +using WalEntry = DocxMcp.Persistence.WalEntry; namespace DocxMcp; /// -/// Thread-safe manager for document sessions with WAL-based persistence. -/// Sessions survive server restarts via baseline snapshots + write-ahead log replay. +/// Thread-safe manager for document sessions with gRPC-based persistence. +/// Sessions are stored via a gRPC history storage service with multi-tenant isolation. /// Supports undo/redo via WAL cursor + checkpoint replay. -/// Uses cross-process file locking to prevent index corruption when multiple -/// MCP server processes share the same sessions directory. +/// Sync and watch operations are handled separately by SyncManager. /// public sealed class SessionManager { - private readonly ConcurrentDictionary _sessions = new(); - private readonly ConcurrentDictionary _cursors = new(); - private readonly SessionStore _store; + private readonly IHistoryStorage _history; private readonly ILogger _logger; - private SessionIndexFile _index; - private readonly object _indexLock = new(); + private readonly string _tenantId; private readonly int _compactThreshold; - private readonly int _checkpointInterval; - private readonly bool _autoSaveEnabled; - private ExternalChangeTracker? _externalChangeTracker; - public SessionManager(SessionStore store, ILogger logger) + /// + /// The tenant ID for this SessionManager instance. + /// Captured at construction time to ensure consistency across threads. + /// + public string TenantId => _tenantId; + + /// + /// Create a SessionManager with the specified tenant ID. + /// If tenantId is null, uses the current tenant from TenantContextHelper. + /// + public SessionManager(IHistoryStorage history, ILogger logger, string? tenantId = null) { - _store = store; + _history = history; _logger = logger; - _index = new SessionIndexFile(); + _tenantId = tenantId ?? TenantContextHelper.CurrentTenantId; var thresholdEnv = Environment.GetEnvironmentVariable("DOCX_WAL_COMPACT_THRESHOLD"); _compactThreshold = int.TryParse(thresholdEnv, out var t) && t > 0 ? t : 50; - - var intervalEnv = Environment.GetEnvironmentVariable("DOCX_CHECKPOINT_INTERVAL"); - _checkpointInterval = int.TryParse(intervalEnv, out var ci) && ci > 0 ? ci : 10; - - var autoSaveEnv = Environment.GetEnvironmentVariable("DOCX_AUTO_SAVE"); - _autoSaveEnabled = autoSaveEnv is null || !string.Equals(autoSaveEnv, "false", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Set the external change tracker (setter injection to avoid circular dependency). - /// - public void SetExternalChangeTracker(ExternalChangeTracker tracker) - { - _externalChangeTracker = tracker; } public DocxSession Open(string path) { var session = DocxSession.Open(path); - if (!_sessions.TryAdd(session.Id, session)) + try + { + PersistNewSessionAsync(session).GetAwaiter().GetResult(); + } + catch { session.Dispose(); - throw new InvalidOperationException("Session ID collision — this should not happen."); + throw; } + return session; + } - PersistNewSession(session); + public DocxSession OpenFromBytes(byte[] data, string? displayPath = null) + { + var id = Guid.NewGuid().ToString("N")[..12]; + var session = DocxSession.FromBytes(data, id, displayPath); + try + { + PersistNewSessionAsync(session).GetAwaiter().GetResult(); + } + catch + { + session.Dispose(); + throw; + } return session; } public DocxSession Create() { var session = DocxSession.Create(); - if (!_sessions.TryAdd(session.Id, session)) + try + { + PersistNewSessionAsync(session).GetAwaiter().GetResult(); + } + catch { session.Dispose(); - throw new InvalidOperationException("Session ID collision — this should not happen."); + throw; } - - PersistNewSession(session); return session; } + /// + /// Load a session from gRPC checkpoint (stateless). + /// Fast path: exact checkpoint at cursor position (1 gRPC call). + /// Slow path: nearest checkpoint + WAL replay. + /// The caller MUST dispose the returned session. + /// public DocxSession Get(string id) { - if (_sessions.TryGetValue(id, out var session)) - return session; - throw new KeyNotFoundException($"No document session with ID '{id}'."); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var cursor = LoadCursorPosition(id, walCount); + + // Try to load checkpoint at or before cursor position + var (ckptData, ckptPos, ckptFound) = _history.LoadCheckpointAsync( + TenantId, id, (ulong)cursor).GetAwaiter().GetResult(); + + byte[] baseBytes; + int checkpointPosition; + + if (ckptFound && ckptData is not null && (int)ckptPos <= cursor) + { + baseBytes = ckptData; + checkpointPosition = (int)ckptPos; + } + else + { + // Fallback to baseline + var (baselineData, baselineFound) = _history.LoadSessionAsync(TenantId, id) + .GetAwaiter().GetResult(); + if (!baselineFound || baselineData is null) + throw new KeyNotFoundException($"No document session with ID '{id}'."); + baseBytes = baselineData; + checkpointPosition = 0; + } + + var sourcePath = LoadSourcePath(id); + var session = DocxSession.FromBytes(baseBytes, id, sourcePath); + + // Replay WAL entries from checkpoint to cursor if needed + if (cursor > checkpointPosition) + { + var walEntries = ReadWalEntriesAsync(id).GetAwaiter().GetResult(); + foreach (var patchJson in walEntries + .Skip(checkpointPosition) + .Take(cursor - checkpointPosition) + .Where(e => e.Patches is not null) + .Select(e => e.Patches!)) + { + try { ReplayPatch(session, patchJson); } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to replay WAL entry for session {SessionId}.", id); + break; + } + } + } + + return session; } /// /// Resolve a session by ID or file path. - /// - If the input looks like a session ID and matches, returns that session. - /// - If the input is a file path, checks for existing session with that path. + /// - If the input matches a session ID in the index, loads that session. + /// - If the input is a file path, checks the index for a session with that source_path. /// - If no existing session found and file exists, auto-opens a new session. /// - /// Either a session ID (12 hex chars) or a file path. - /// The resolved session. - /// If no session found and file doesn't exist. public DocxSession ResolveSession(string idOrPath) { - // First, try as session ID - if (_sessions.TryGetValue(idOrPath, out var session)) - return session; + // First, try as session ID via gRPC + var (exists, _) = _history.SessionExistsAsync(TenantId, idOrPath).GetAwaiter().GetResult(); + if (exists) + return Get(idOrPath); - // Check if it looks like a file path (has extension, path separator, or starts with ~ or /) + // Check if it looks like a file path var isLikelyPath = idOrPath.Contains(Path.DirectorySeparatorChar) || idOrPath.Contains(Path.AltDirectorySeparatorChar) || idOrPath.StartsWith('~') @@ -107,7 +169,6 @@ public DocxSession ResolveSession(string idOrPath) if (!isLikelyPath) { - // Doesn't look like a path, treat as missing session ID throw new KeyNotFoundException($"No document session with ID '{idOrPath}'."); } @@ -119,16 +180,16 @@ public DocxSession ResolveSession(string idOrPath) expandedPath = Path.Combine(home, expandedPath[1..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); } - // Resolve to absolute path var absolutePath = Path.GetFullPath(expandedPath); - // Check if we have an existing session for this path - var existing = _sessions.Values.FirstOrDefault(s => - s.SourcePath is not null && - string.Equals(s.SourcePath, absolutePath, StringComparison.OrdinalIgnoreCase)); + // Search the gRPC index for a session with this source_path + var sessionList = List(); + var existingId = sessionList.FirstOrDefault(s => + s.Path is not null && + string.Equals(s.Path, absolutePath, StringComparison.OrdinalIgnoreCase)).Id; - if (existing is not null) - return existing; + if (existingId is not null) + return Get(existingId); // Auto-open if file exists if (File.Exists(absolutePath)) @@ -137,34 +198,42 @@ s.SourcePath is not null && throw new KeyNotFoundException($"No session found for '{idOrPath}' and file does not exist."); } - public void Save(string id, string? path = null) + /// + /// Persist source path to the gRPC index and update the in-memory session. + /// + public void SetSourcePath(string id, string path) { - var session = Get(id); - session.Save(path); - // Note: WAL is intentionally preserved after save. - // Compaction should only be triggered explicitly via CLI. + var absolutePath = Path.GetFullPath(path); + + // Persist to gRPC index (stateless — no in-memory session to update) + _history.UpdateSessionInIndexAsync(TenantId, id, sourcePath: absolutePath) + .GetAwaiter().GetResult(); } public void Close(string id) { - if (_sessions.TryRemove(id, out var session)) - { - _cursors.TryRemove(id, out _); - session.Dispose(); - _store.DeleteSession(id); - - WithLockedIndex(index => { index.Sessions.RemoveAll(e => e.Id == id); }); - } - else - { + // Verify session exists in the index before deleting + var (exists, _) = _history.SessionExistsAsync(TenantId, id).GetAwaiter().GetResult(); + if (!exists) throw new KeyNotFoundException($"No document session with ID '{id}'."); - } + + _history.DeleteSessionAsync(TenantId, id).GetAwaiter().GetResult(); + _history.RemoveSessionFromIndexAsync(TenantId, id).GetAwaiter().GetResult(); } public IReadOnlyList<(string Id, string? Path)> List() { - return _sessions.Values - .Select(s => (s.Id, s.SourcePath)) + var (indexData, found) = _history.LoadIndexAsync(TenantId).GetAwaiter().GetResult(); + if (!found || indexData is null) + return Array.Empty<(string, string?)>(); + + var json = System.Text.Encoding.UTF8.GetString(indexData); + var index = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndex); + if (index is null) + return Array.Empty<(string, string?)>(); + + return index.Sessions + .Select(e => (e.Id, (string?)e.SourcePath)) .ToList() .AsReadOnly(); } @@ -174,69 +243,96 @@ public void Close(string id) /// /// Append a patch to the WAL after a successful mutation. /// If the cursor is behind the WAL tip (after undo), truncates future entries first. - /// Creates checkpoints at interval boundaries. - /// Triggers automatic compaction when WAL exceeds threshold (default 50 entries). + /// Always saves a checkpoint at the new position for stateless Get(). + /// Does NOT auto-save — caller is responsible for orchestrating sync. /// - public void AppendWal(string id, string patchesJson, string? description = null) + public void AppendWal(string id, string patchesJson, string? description, byte[] currentBytes) { try { - var cursor = _cursors.GetOrAdd(id, 0); - var walCount = _store.WalEntryCount(id); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var cursor = LoadCursorPosition(id, walCount); // If cursor < walCount, we're in an undo state — truncate future if (cursor < walCount) { - _store.TruncateWalAt(id, cursor); + TruncateWalAtAsync(id, cursor).GetAwaiter().GetResult(); - WithLockedIndex(index => + // Remove checkpoints above cursor position + var checkpointsToRemove = GetCheckpointPositionsAboveAsync(id, (ulong)cursor).GetAwaiter().GetResult(); + if (checkpointsToRemove.Count > 0) { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - _store.DeleteCheckpointsAfter(id, cursor, entry.CheckpointPositions); - entry.CheckpointPositions.RemoveAll(p => p > cursor); - } - }); + _history.UpdateSessionInIndexAsync(TenantId, id, + removeCheckpointPositions: checkpointsToRemove).GetAwaiter().GetResult(); + } } // Auto-generate description from patch ops if not provided description ??= GenerateDescription(patchesJson); - _store.AppendWal(id, patchesJson, description); - var newCursor = cursor + 1; - _cursors[id] = newCursor; - - // Create checkpoint if crossing an interval boundary - MaybeCreateCheckpoint(id, newCursor); - - // Update index and extract compaction decision BEFORE releasing lock - // to avoid recursive deadlock (AppendWal -> Compact -> WithLockedIndex) - bool shouldCompact = false; - WithLockedIndex(index => + // Create WAL entry + var walEntry = new WalEntry { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - entry.WalCount = _store.WalEntryCount(id); - entry.CursorPosition = newCursor; - entry.LastModifiedAt = DateTime.UtcNow; - shouldCompact = entry.WalCount >= _compactThreshold; - } - }); + Patches = patchesJson, + Timestamp = DateTime.UtcNow, + Description = description + }; - // Compact AFTER releasing the file lock to avoid deadlock - if (shouldCompact) - Compact(id); + AppendWalEntryAsync(id, walEntry).GetAwaiter().GetResult(); + var newCursor = cursor + 1; - MaybeAutoSave(id); + // Always save checkpoint at the new position (stateless pattern) + _history.SaveCheckpointAsync(TenantId, id, (ulong)newCursor, currentBytes) + .GetAwaiter().GetResult(); + + // Update index with new WAL position, cursor, and checkpoint + var newWalCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + _history.UpdateSessionInIndexAsync(TenantId, id, + modifiedAtUnix: now, + walPosition: (ulong)newWalCount, + cursorPosition: (ulong)newCursor, + addCheckpointPositions: new[] { (ulong)newCursor }).GetAwaiter().GetResult(); + + // Check if compaction is needed + if ((ulong)newWalCount >= (ulong)_compactThreshold) + Compact(id); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to append WAL for session {SessionId}.", id); + throw new McpException($"Failed to persist edit for session '{id}': {ex.Message}. The in-memory document was modified but the change was not saved to the write-ahead log.", ex); } } + private async Task> GetCheckpointPositionsAboveAsync(string id, ulong threshold) + { + var (indexData, found) = await _history.LoadIndexAsync(TenantId); + if (!found || indexData is null) + return new List(); + + var json = System.Text.Encoding.UTF8.GetString(indexData); + var index = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndex); + if (index is null || !index.TryGetValue(id, out var entry)) + return new List(); + + return entry!.CheckpointPositions.Where(p => (ulong)p > threshold).Select(p => (ulong)p).ToList(); + } + + private async Task> GetCheckpointPositionsAsync(string id) + { + var (indexData, found) = await _history.LoadIndexAsync(TenantId); + if (!found || indexData is null) + return new List(); + + var json = System.Text.Encoding.UTF8.GetString(indexData); + var index = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndex); + if (index is null || !index.TryGetValue(id, out var entry)) + return new List(); + + return entry!.CheckpointPositions; + } + /// /// Create a new baseline snapshot from the current in-memory state and truncate the WAL. /// Refuses if redo entries exist unless discardRedoHistory is true. @@ -245,35 +341,32 @@ public void Compact(string id, bool discardRedoHistory = false) { try { - var cursor = _cursors.GetOrAdd(id, _ => _store.WalEntryCount(id)); - var walCount = _store.WalEntryCount(id); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var cursor = LoadCursorPosition(id, walCount); if (cursor < walCount && !discardRedoHistory) { _logger.LogInformation( - "Skipping compaction for session {SessionId}: {RedoCount} redo entries exist. Use discardRedoHistory=true to force.", + "Skipping compaction for session {SessionId}: {RedoCount} redo entries exist.", id, walCount - cursor); return; } - var session = Get(id); + // Load current state from checkpoint + using var session = Get(id); var bytes = session.ToBytes(); - _store.PersistBaseline(id, bytes); - _store.TruncateWal(id); - _store.DeleteCheckpoints(id); - _cursors[id] = 0; - WithLockedIndex(index => - { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - entry.WalCount = 0; - entry.CursorPosition = 0; - entry.CheckpointPositions.Clear(); - entry.LastModifiedAt = DateTime.UtcNow; - } - }); + _history.SaveSessionAsync(TenantId, id, bytes).GetAwaiter().GetResult(); + _history.TruncateWalAsync(TenantId, id, 0).GetAwaiter().GetResult(); + + // Remove all checkpoints (baseline is now up-to-date) + var checkpointsToRemove = GetCheckpointPositionsAboveAsync(id, 0).GetAwaiter().GetResult(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + _history.UpdateSessionInIndexAsync(TenantId, id, + modifiedAtUnix: now, + walPosition: 0, + cursorPosition: 0, + removeCheckpointPositions: checkpointsToRemove).GetAwaiter().GetResult(); _logger.LogInformation("Compacted session {SessionId}.", id); } @@ -285,73 +378,45 @@ public void Compact(string id, bool discardRedoHistory = false) /// /// Append an external sync entry to the WAL. - /// Truncates future entries if in undo state, creates checkpoint from the sync's DocumentSnapshot, - /// and replaces the in-memory session. /// - /// Session ID. - /// The WAL entry with ExternalSync type and SyncMeta. - /// The new session to replace the current one. - /// The new WAL position after append. - public int AppendExternalSync(string id, WalEntry syncEntry, DocxSession newSession) + public int AppendExternalSync(string id, WalEntry syncEntry, byte[] newBytes) { try { - var cursor = _cursors.GetOrAdd(id, 0); - var walCount = _store.WalEntryCount(id); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var cursor = LoadCursorPosition(id, walCount); // If cursor < walCount, we're in an undo state — truncate future if (cursor < walCount) { - _store.TruncateWalAt(id, cursor); + TruncateWalAtAsync(id, cursor).GetAwaiter().GetResult(); - WithLockedIndex(index => + // Remove checkpoints above cursor position + var checkpointsToRemove = GetCheckpointPositionsAboveAsync(id, (ulong)cursor).GetAwaiter().GetResult(); + if (checkpointsToRemove.Count > 0) { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - _store.DeleteCheckpointsAfter(id, cursor, entry.CheckpointPositions); - entry.CheckpointPositions.RemoveAll(p => p > cursor); - } - }); + _history.UpdateSessionInIndexAsync(TenantId, id, + removeCheckpointPositions: checkpointsToRemove).GetAwaiter().GetResult(); + } } - // Serialize and append WAL entry - var walLine = System.Text.Json.JsonSerializer.Serialize(syncEntry, WalJsonContext.Default.WalEntry); - _store.GetOrCreateWal(id).Append(walLine); + AppendWalEntryAsync(id, syncEntry).GetAwaiter().GetResult(); var newCursor = cursor + 1; - _cursors[id] = newCursor; - // Create checkpoint using the stored DocumentSnapshot (sync always forces a checkpoint) - // Use import checkpoint path for Import entries, regular checkpoint path for ExternalSync - if (syncEntry.SyncMeta?.DocumentSnapshot is not null) - { - if (syncEntry.EntryType == WalEntryType.Import) - _store.PersistImportCheckpoint(id, newCursor, syncEntry.SyncMeta.DocumentSnapshot); - else - _store.PersistCheckpoint(id, newCursor, syncEntry.SyncMeta.DocumentSnapshot); - } + // Always save checkpoint with the new document bytes + var checkpointBytes = syncEntry.SyncMeta?.DocumentSnapshot ?? newBytes; + _history.SaveCheckpointAsync(TenantId, id, (ulong)newCursor, checkpointBytes) + .GetAwaiter().GetResult(); - // Replace in-memory session - var oldSession = _sessions[id]; - _sessions[id] = newSession; - oldSession.Dispose(); - - // Update index - WithLockedIndex(index => - { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - entry.WalCount = _store.WalEntryCount(id); - entry.CursorPosition = newCursor; - entry.LastModifiedAt = DateTime.UtcNow; - if (!entry.CheckpointPositions.Contains(newCursor)) - { - entry.CheckpointPositions.Add(newCursor); - } - } - }); + // Update index with new WAL position and checkpoint + var newWalCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + _history.UpdateSessionInIndexAsync(TenantId, id, + modifiedAtUnix: now, + walPosition: (ulong)newWalCount, + cursorPosition: (ulong)newCursor, + addCheckpointPositions: new[] { (ulong)newCursor }).GetAwaiter().GetResult(); _logger.LogInformation("Appended external sync entry at position {Position} for session {SessionId}.", newCursor, id); @@ -367,13 +432,10 @@ public int AppendExternalSync(string id, WalEntry syncEntry, DocxSession newSess // --- Undo / Redo / JumpTo / History --- - /// - /// Undo N steps by decrementing the cursor and rebuilding from the nearest checkpoint. - /// public UndoRedoResult Undo(string id, int steps = 1) { - var session = Get(id); // validate session exists - var cursor = _cursors.GetOrAdd(id, _ => _store.WalEntryCount(id)); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var cursor = LoadCursorPosition(id, walCount); if (cursor <= 0) return new UndoRedoResult { Position = 0, Steps = 0, Message = "Already at the beginning. Nothing to undo." }; @@ -381,26 +443,21 @@ public UndoRedoResult Undo(string id, int steps = 1) var actualSteps = Math.Min(steps, cursor); var newCursor = cursor - actualSteps; - RebuildDocumentAtPosition(id, newCursor); - MaybeAutoSave(id); + var bytes = RebuildAndCheckpoint(id, newCursor); return new UndoRedoResult { Position = newCursor, Steps = actualSteps, - Message = $"Undid {actualSteps} step(s). Now at position {newCursor}." + Message = $"Undid {actualSteps} step(s). Now at position {newCursor}.", + CurrentBytes = bytes }; } - /// - /// Redo N steps by incrementing the cursor and replaying patches on the current DOM. - /// For ExternalSync entries, uses checkpoint-based rebuild instead of patch replay. - /// public UndoRedoResult Redo(string id, int steps = 1) { - var session = Get(id); // validate session exists - var cursor = _cursors.GetOrAdd(id, _ => _store.WalEntryCount(id)); - var walCount = _store.WalEntryCount(id); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); + var cursor = LoadCursorPosition(id, walCount); if (cursor >= walCount) return new UndoRedoResult { Position = cursor, Steps = 0, Message = "Already at the latest state. Nothing to redo." }; @@ -408,97 +465,55 @@ public UndoRedoResult Redo(string id, int steps = 1) var actualSteps = Math.Min(steps, walCount - cursor); var newCursor = cursor + actualSteps; - // Check if any entries in the redo range are ExternalSync or Import - var walEntries = _store.ReadWalEntries(id); - var hasExternalSync = false; - for (int i = cursor; i < newCursor && i < walEntries.Count; i++) - { - if (walEntries[i].EntryType is WalEntryType.ExternalSync or WalEntryType.Import) - { - hasExternalSync = true; - break; - } - } - - if (hasExternalSync) - { - // ExternalSync entries have checkpoints, so rebuild from checkpoint - RebuildDocumentAtPosition(id, newCursor); - } - else - { - // Regular patches: replay on current DOM (fast, no rebuild) - var patches = _store.ReadWalRange(id, cursor, newCursor); - foreach (var patchJson in patches) - { - ReplayPatch(session, patchJson); - } - - _cursors[id] = newCursor; - - WithLockedIndex(index => - { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - entry.CursorPosition = newCursor; - } - }); - } - - MaybeAutoSave(id); + var bytes = RebuildAndCheckpoint(id, newCursor); return new UndoRedoResult { Position = newCursor, Steps = actualSteps, - Message = $"Redid {actualSteps} step(s). Now at position {newCursor}." + Message = $"Redid {actualSteps} step(s). Now at position {newCursor}.", + CurrentBytes = bytes }; } - /// - /// Jump to an arbitrary WAL position by rebuilding from the nearest checkpoint. - /// public UndoRedoResult JumpTo(string id, int position) { - var session = Get(id); // validate session exists - var walCount = _store.WalEntryCount(id); + var walCount = GetWalEntryCountAsync(id).GetAwaiter().GetResult(); if (position < 0) position = 0; if (position > walCount) + { + var currentCursor = LoadCursorPosition(id, walCount); return new UndoRedoResult { - Position = _cursors.GetOrAdd(id, _ => walCount), + Position = currentCursor, Steps = 0, Message = $"Position {position} is beyond the WAL (max {walCount}). No change." }; + } - var oldCursor = _cursors.GetOrAdd(id, _ => walCount); + var oldCursor = LoadCursorPosition(id, walCount); if (position == oldCursor) return new UndoRedoResult { Position = position, Steps = 0, Message = $"Already at position {position}." }; - RebuildDocumentAtPosition(id, position); - MaybeAutoSave(id); + var bytes = RebuildAndCheckpoint(id, position); var stepsFromOld = Math.Abs(position - oldCursor); return new UndoRedoResult { Position = position, Steps = stepsFromOld, - Message = $"Jumped to position {position}." + Message = $"Jumped to position {position}.", + CurrentBytes = bytes }; } - /// - /// Get the hash of the external file from the last ExternalSync WAL entry. - /// Used to detect if the external file has changed since the last sync. - /// public string? GetLastExternalSyncHash(string id) { try { - var walEntries = _store.ReadWalEntries(id); + var walEntries = ReadWalEntriesAsync(id).GetAwaiter().GetResult(); var lastSync = walEntries .Where(e => e.EntryType is WalEntryType.ExternalSync or WalEntryType.Import && e.SyncMeta?.NewHash is not null) .LastOrDefault(); @@ -510,27 +525,17 @@ public UndoRedoResult JumpTo(string id, int position) } } - /// - /// Get the edit history for a session with metadata. - /// public HistoryResult GetHistory(string id, int offset = 0, int limit = 20) { - Get(id); // validate session exists - var walEntries = _store.ReadWalEntries(id); - var cursor = _cursors.GetOrAdd(id, _ => walEntries.Count); + var walEntries = ReadWalEntriesAsync(id).GetAwaiter().GetResult(); var walCount = walEntries.Count; + var cursor = LoadCursorPosition(id, walCount); - var checkpointPositions = WithLockedIndex(index => - { - var entry = index.Sessions.Find(e => e.Id == id); - return entry?.CheckpointPositions.ToList() ?? new List(); - }); + var checkpointPositions = GetCheckpointPositionsAsync(id).GetAwaiter().GetResult(); var entries = new List(); - - // Include position 0 (baseline) as the first entry var startIdx = Math.Max(0, offset); - var endIdx = Math.Min(walCount + 1, offset + limit); // +1 for baseline + var endIdx = Math.Min(walCount + 1, offset + limit); for (int i = startIdx; i < endIdx; i++) { @@ -561,7 +566,6 @@ public HistoryResult GetHistory(string id, int offset = 0, int limit = 20) IsExternalSync = we.EntryType is WalEntryType.ExternalSync or WalEntryType.Import }; - // Populate sync summary for external sync / import entries if (we.EntryType is WalEntryType.ExternalSync or WalEntryType.Import && we.SyncMeta is not null) { historyEntry.SyncSummary = new ExternalSyncSummary @@ -585,7 +589,7 @@ public HistoryResult GetHistory(string id, int offset = 0, int limit = 20) return new HistoryResult { - TotalEntries = walCount + 1, // +1 for baseline + TotalEntries = walCount + 1, CursorPosition = cursor, CanUndo = cursor > 0, CanRedo = cursor < walCount, @@ -594,199 +598,201 @@ public HistoryResult GetHistory(string id, int offset = 0, int limit = 20) } /// - /// Restore all persisted sessions from disk on startup. - /// Acquires file lock for the entire duration to prevent mutations during startup replay. - /// Loads from the nearest checkpoint when available to properly restore ExternalSync state. - /// Note: Sessions are never auto-deleted. Use CLI to manually close/clean sessions. + /// No-op for backward compatibility. Sessions are now stateless (loaded on demand from gRPC). /// - public int RestoreSessions() + [Obsolete("Sessions are now stateless. This method is a no-op.")] + public int RestoreSessions() => 0; + + // --- gRPC Storage Helpers --- + + private async Task GetWalEntryCountAsync(string sessionId) { - _store.EnsureDirectory(); - using var fileLock = _store.AcquireLock(); + var (entries, _) = await _history.ReadWalAsync(TenantId, sessionId); + return entries.Count; + } - lock (_indexLock) + /// + /// Load the cursor position from the index, or default to WAL count. + /// + private int LoadCursorPosition(string sessionId, int walCount) + { + try { - _index = _store.LoadIndex(); + var (indexData, found) = _history.LoadIndexAsync(TenantId).GetAwaiter().GetResult(); + if (found && indexData is not null) + { + var json = System.Text.Encoding.UTF8.GetString(indexData); + var index = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndex); + if (index is not null && index.TryGetValue(sessionId, out var entry)) + { + // Return cursor if valid, otherwise default to walCount + if (entry!.CursorPosition >= 0 && entry.CursorPosition <= walCount) + { + return entry.CursorPosition; + } + } + } } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to load cursor position for session {SessionId}, using default.", sessionId); + } + return walCount; + } - int restored = 0; - - foreach (var entry in _index.Sessions.ToList()) + /// + /// Load source_path from the gRPC index for a session. + /// + private string? LoadSourcePath(string sessionId) + { + try { - try + var (indexData, found) = _history.LoadIndexAsync(TenantId).GetAwaiter().GetResult(); + if (found && indexData is not null) { - // Determine how many WAL entries to replay (up to cursor position) - var walCount = _store.WalEntryCount(entry.Id); - var cursorTarget = entry.CursorPosition; - - // Backward compat: old entries without cursor tracking (sentinel -1) - if (cursorTarget < 0) - cursorTarget = walCount; - - var replayCount = Math.Min(cursorTarget, walCount); - - // Load from nearest checkpoint instead of baseline + full replay. - // This is critical for ExternalSync entries which store document snapshots - // in checkpoints rather than as replayable patches. - var (ckptPos, ckptBytes) = _store.LoadNearestCheckpoint( - entry.Id, - replayCount, - entry.CheckpointPositions); + var json = System.Text.Encoding.UTF8.GetString(indexData); + var index = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndex); + if (index is not null && index.TryGetValue(sessionId, out var entry)) + return entry!.SourcePath; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to load source path for session {SessionId}.", sessionId); + } + return null; + } - var session = DocxSession.FromBytes(ckptBytes, entry.Id, entry.SourcePath); + private async Task> ReadWalEntriesAsync(string sessionId) + { + var (grpcEntries, _) = await _history.ReadWalAsync(TenantId, sessionId); + var entries = new List(); - // Only replay patches AFTER the checkpoint position - if (replayCount > ckptPos) + foreach (var grpcEntry in grpcEntries) + { + try + { + // The PatchJson field contains the serialized .NET WalEntry + if (grpcEntry.PatchJson.Length > 0) { - var patches = _store.ReadWalRange(entry.Id, ckptPos, replayCount); - foreach (var patchJson in patches) + var json = System.Text.Encoding.UTF8.GetString(grpcEntry.PatchJson); + var entry = JsonSerializer.Deserialize(json, WalJsonContext.Default.WalEntry); + if (entry is not null) { - try - { - ReplayPatch(session, patchJson); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to replay WAL entry for session {SessionId}; stopping replay.", - entry.Id); - break; - } + entries.Add(entry); } } - - if (_sessions.TryAdd(session.Id, session)) - { - _cursors[session.Id] = replayCount; - restored++; - } - else - session.Dispose(); } catch (Exception ex) { - // Log but don't delete — WAL history is preserved. - // Use CLI 'close' command to manually remove corrupt sessions. - _logger.LogWarning(ex, "Failed to restore session {SessionId}; skipping (WAL preserved).", entry.Id); + _logger.LogWarning(ex, "Failed to deserialize WAL entry for session {SessionId}.", sessionId); } } - return restored; + return entries; } - // --- Cross-process index helpers --- - - /// - /// Acquire cross-process file lock, reload index from disk, mutate, save. - /// Ensures no stale reads when multiple processes share the sessions directory. - /// - private void WithLockedIndex(Action mutate) + private async Task AppendWalEntryAsync(string sessionId, WalEntry entry) { - using var fileLock = _store.AcquireLock(); - lock (_indexLock) - { - _index = _store.LoadIndex(); - mutate(_index); - _store.SaveIndex(_index); - } + var json = JsonSerializer.Serialize(entry, WalJsonContext.Default.WalEntry); + var jsonBytes = System.Text.Encoding.UTF8.GetBytes(json); + + // GrpcWalEntry (WalEntryDto) is a positional record + var grpcEntry = new GrpcWalEntry( + Position: 0, // Server assigns position + Operation: entry.EntryType.ToString(), + Path: "", + PatchJson: jsonBytes, + Timestamp: entry.Timestamp + ); + + await _history.AppendWalAsync(TenantId, sessionId, new[] { grpcEntry }); } - /// - /// Acquire cross-process file lock, reload index from disk, read a value. - /// - private T WithLockedIndex(Func read) + private async Task TruncateWalAtAsync(string sessionId, int keepCount) { - using var fileLock = _store.AcquireLock(); - lock (_indexLock) - { - _index = _store.LoadIndex(); - return read(_index); - } + await _history.TruncateWalAsync(TenantId, sessionId, (ulong)keepCount); } // --- Private helpers --- + private async Task PersistNewSessionAsync(DocxSession session) + { + var bytes = session.ToBytes(); + await _history.SaveSessionAsync(TenantId, session.Id, bytes); + + var now = DateTime.UtcNow; + await _history.AddSessionToIndexAsync(TenantId, session.Id, + new Grpc.SessionIndexEntryDto( + session.SourcePath, + now, + now, + 0, + Array.Empty())); + } + /// - /// Auto-save the document to its source path after a user edit (best-effort). - /// Skipped for new documents (no SourcePath) or when auto-save is disabled. + /// Rebuild document at a given position, save checkpoint there, update cursor. + /// Returns the serialized bytes at that position. /// - private void MaybeAutoSave(string id) + private byte[] RebuildAndCheckpoint(string id, int targetPosition) { - if (!_autoSaveEnabled) - return; + using var session = RebuildDocumentAtPositionAsync(id, targetPosition).GetAwaiter().GetResult(); + var bytes = session.ToBytes(); - try - { - var session = Get(id); - if (session.SourcePath is null) - return; + // Save checkpoint at new position so future Get() is fast + _history.SaveCheckpointAsync(TenantId, id, (ulong)targetPosition, bytes) + .GetAwaiter().GetResult(); - session.Save(); - _externalChangeTracker?.UpdateSessionSnapshot(id); - _logger.LogDebug("Auto-saved session {SessionId} to {Path}.", id, session.SourcePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Auto-save failed for session {SessionId}.", id); - } + // Update cursor + checkpoint in index + _history.UpdateSessionInIndexAsync(TenantId, id, + cursorPosition: (ulong)targetPosition, + addCheckpointPositions: new[] { (ulong)targetPosition }).GetAwaiter().GetResult(); + + return bytes; } - private void PersistNewSession(DocxSession session) + private async Task RebuildDocumentAtPositionAsync(string id, int targetPosition) { - try - { - var bytes = session.ToBytes(); - _store.PersistBaseline(session.Id, bytes); - _store.GetOrCreateWal(session.Id); // create empty WAL mapping + // Try to load checkpoint + var (ckptData, ckptPos, ckptFound) = await _history.LoadCheckpointAsync( + TenantId, id, (ulong)targetPosition); - _cursors[session.Id] = 0; + byte[] baseBytes; + int checkpointPosition; - WithLockedIndex(index => - { - index.Sessions.Add(new SessionEntry - { - Id = session.Id, - SourcePath = session.SourcePath, - CreatedAt = DateTime.UtcNow, - LastModifiedAt = DateTime.UtcNow, - DocxFile = $"{session.Id}.docx", - WalCount = 0, - CursorPosition = 0 - }); - }); - } - catch (Exception ex) + if (ckptFound && ckptData is not null && (int)ckptPos <= targetPosition) { - _logger.LogWarning(ex, "Failed to persist new session {SessionId}.", session.Id); + baseBytes = ckptData; + checkpointPosition = (int)ckptPos; } - } - - /// - /// Rebuild the in-memory document at a specific WAL position. - /// Loads the nearest checkpoint, replays patches to the target position, - /// and replaces the in-memory session. - /// - private void RebuildDocumentAtPosition(string id, int targetPosition) - { - var checkpointPositions = WithLockedIndex(index => + else { - var indexEntry = index.Sessions.Find(e => e.Id == id); - return indexEntry?.CheckpointPositions.ToList() ?? new List(); - }); - - var (ckptPos, ckptBytes) = _store.LoadNearestCheckpoint(id, targetPosition, checkpointPositions); + // Fallback to baseline + var (baselineData, _) = await _history.LoadSessionAsync(TenantId, id); + baseBytes = baselineData ?? throw new InvalidOperationException($"No baseline found for session {id}"); + checkpointPosition = 0; + } - var oldSession = Get(id); - var newSession = DocxSession.FromBytes(ckptBytes, oldSession.Id, oldSession.SourcePath); + var sourcePath = LoadSourcePath(id); + var session = DocxSession.FromBytes(baseBytes, id, sourcePath); - // Replay patches from checkpoint position to target - if (targetPosition > ckptPos) + // Replay patches from checkpoint to target + if (targetPosition > checkpointPosition) { - var patches = _store.ReadWalRange(id, ckptPos, targetPosition); - foreach (var patchJson in patches) + var walEntries = await ReadWalEntriesAsync(id); + var patchesToReplay = walEntries + .Skip(checkpointPosition) + .Take(targetPosition - checkpointPosition) + .Where(e => e.Patches is not null) + .Select(e => e.Patches!) + .ToList(); + + foreach (var patchJson in patchesToReplay) { try { - ReplayPatch(newSession, patchJson); + ReplayPatch(session, patchJson); } catch (Exception ex) { @@ -796,55 +802,9 @@ private void RebuildDocumentAtPosition(string id, int targetPosition) } } - // Replace in-memory session - _sessions[id] = newSession; - _cursors[id] = targetPosition; - oldSession.Dispose(); - - WithLockedIndex(index => - { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null) - { - entry.CursorPosition = targetPosition; - } - }); - } - - /// - /// Create a checkpoint if the new cursor crosses a checkpoint interval boundary. - /// - private void MaybeCreateCheckpoint(string id, int newCursor) - { - if (newCursor > 0 && newCursor % _checkpointInterval == 0) - { - try - { - var session = Get(id); - var bytes = session.ToBytes(); - _store.PersistCheckpoint(id, newCursor, bytes); - - WithLockedIndex(index => - { - var entry = index.Sessions.Find(e => e.Id == id); - if (entry is not null && !entry.CheckpointPositions.Contains(newCursor)) - { - entry.CheckpointPositions.Add(newCursor); - } - }); - - _logger.LogInformation("Created checkpoint at position {Position} for session {SessionId}.", newCursor, id); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to create checkpoint at position {Position} for session {SessionId}.", newCursor, id); - } - } + return session; } - /// - /// Generate a description string from patch operations. - /// private static string GenerateDescription(string patchesJson) { try @@ -895,10 +855,6 @@ private static string GenerateDescription(string patchesJson) } } - /// - /// Replay a single patch operation against a session's document. - /// Uses the same logic as PatchTool.ApplyPatch but without MCP tool wiring. - /// private static void ReplayPatch(DocxSession session, string patchesJson) { var patchArray = JsonDocument.Parse(patchesJson).RootElement; diff --git a/src/DocxMcp/SessionManagerPool.cs b/src/DocxMcp/SessionManagerPool.cs new file mode 100644 index 0000000..f85fcda --- /dev/null +++ b/src/DocxMcp/SessionManagerPool.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using DocxMcp.Grpc; +using Microsoft.Extensions.Logging; + +namespace DocxMcp; + +/// +/// Thread-safe pool of SessionManagers, one per tenant. +/// Used only in HTTP mode for multi-tenant isolation. +/// Each SessionManager is created on first access (stateless — no session restore needed). +/// +public sealed class SessionManagerPool +{ + private readonly ConcurrentDictionary _pool = new(); + private readonly ConcurrentDictionary _locks = new(); + private readonly IHistoryStorage _history; + private readonly ILoggerFactory _loggerFactory; + + public SessionManagerPool(IHistoryStorage history, ILoggerFactory loggerFactory) + { + _history = history; + _loggerFactory = loggerFactory; + } + + public SessionManager GetForTenant(string tenantId) + { + if (_pool.TryGetValue(tenantId, out var existing)) + return existing; + + // Serialize creation per-tenant + var @lock = _locks.GetOrAdd(tenantId, _ => new SemaphoreSlim(1, 1)); + @lock.Wait(); + try + { + // Double-check after acquiring lock + if (_pool.TryGetValue(tenantId, out existing)) + return existing; + + var sm = new SessionManager(_history, _loggerFactory.CreateLogger(), tenantId); + _pool[tenantId] = sm; + return sm; + } + catch + { + // Don't cache failed managers — next request will retry + _pool.TryRemove(tenantId, out _); + throw; + } + finally + { + @lock.Release(); + } + } +} diff --git a/src/DocxMcp/SessionRestoreService.cs b/src/DocxMcp/SessionRestoreService.cs deleted file mode 100644 index 95a4b37..0000000 --- a/src/DocxMcp/SessionRestoreService.cs +++ /dev/null @@ -1,33 +0,0 @@ -using DocxMcp.ExternalChanges; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace DocxMcp; - -/// -/// Restores persisted sessions on server startup by loading baselines and replaying WALs. -/// -public sealed class SessionRestoreService : IHostedService -{ - private readonly SessionManager _sessions; - private readonly ExternalChangeTracker _externalChangeTracker; - private readonly ILogger _logger; - - public SessionRestoreService(SessionManager sessions, ExternalChangeTracker externalChangeTracker, ILogger logger) - { - _sessions = sessions; - _externalChangeTracker = externalChangeTracker; - _logger = logger; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - _sessions.SetExternalChangeTracker(_externalChangeTracker); - var restored = _sessions.RestoreSessions(); - if (restored > 0) - _logger.LogInformation("Restored {Count} session(s) from disk.", restored); - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} diff --git a/src/DocxMcp/SyncManager.cs b/src/DocxMcp/SyncManager.cs new file mode 100644 index 0000000..6e13d9b --- /dev/null +++ b/src/DocxMcp/SyncManager.cs @@ -0,0 +1,285 @@ +using DocxMcp.Grpc; +using Microsoft.Extensions.Logging; + +namespace DocxMcp; + +/// +/// Manages file synchronization and external watch lifecycle. +/// Independent from SessionManager — receives bytes from callers, not sessions. +/// +public sealed class SyncManager +{ + private readonly ISyncStorage _sync; + private readonly ILogger _logger; + private readonly bool _autoSaveEnabled; + + /// + /// True when sync goes through a remote backend (GDrive, etc.) — local is not available. + /// + public bool IsRemoteSync { get; } + + public SyncManager(ISyncStorage sync, ILogger logger) + { + _sync = sync; + _logger = logger; + + var autoSaveEnv = Environment.GetEnvironmentVariable("DOCX_AUTO_SAVE"); + _autoSaveEnabled = autoSaveEnv is null || !string.Equals(autoSaveEnv, "false", StringComparison.OrdinalIgnoreCase); + + IsRemoteSync = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SYNC_GRPC_URL")); + } + + /// + /// Set or update the source for a session with typed source descriptor. + /// Registers or updates the source, then starts watching for external changes. + /// + public void SetSource(string tenantId, string sessionId, + SourceType sourceType, string? connectionId, string path, string? fileId, bool autoSync) + { + var resolvedPath = sourceType == SourceType.LocalFile ? System.IO.Path.GetFullPath(path) : path; + + try + { + // Check if source is already registered + var status = _sync.GetSyncStatusAsync(tenantId, sessionId).GetAwaiter().GetResult(); + + if (status is not null) + { + // Update existing source + var (success, error) = _sync.UpdateSourceAsync( + tenantId, sessionId, + sourceType, connectionId, resolvedPath, fileId, autoSync + ).GetAwaiter().GetResult(); + + if (!success) + throw new InvalidOperationException($"Failed to update source: {error}"); + + _logger.LogInformation("Updated source for session {SessionId}: {Path} (auto_sync={AutoSync})", + sessionId, resolvedPath, autoSync); + } + else + { + // Register new source + var (success, error) = _sync.RegisterSourceAsync( + tenantId, sessionId, + sourceType, connectionId, resolvedPath, fileId, autoSync + ).GetAwaiter().GetResult(); + + if (!success) + throw new InvalidOperationException($"Failed to register source: {error}"); + + _logger.LogInformation("Registered source for session {SessionId}: {Path} (auto_sync={AutoSync})", + sessionId, resolvedPath, autoSync); + } + + // Start watching for external changes + try + { + var (watchSuccess, watchId, watchError) = _sync.StartWatchAsync( + tenantId, sessionId, + sourceType, connectionId, resolvedPath, fileId + ).GetAwaiter().GetResult(); + + if (watchSuccess) + _logger.LogDebug("Started external watch for session {SessionId}: watchId={WatchId}", sessionId, watchId); + else + _logger.LogWarning("Failed to start external watch for session {SessionId}: {Error}", sessionId, watchError); + } + catch (Exception watchEx) + { + _logger.LogWarning(watchEx, "Exception starting external watch for session {SessionId}", sessionId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to set source for session {SessionId}.", sessionId); + throw; + } + } + + /// + /// Set or update the source path for a session (local files only, backward compat). + /// Throws in cloud mode — use the full overload with explicit source type. + /// + public void SetSource(string tenantId, string sessionId, string path, bool autoSync) + { + if (IsRemoteSync) + throw new InvalidOperationException( + "Cannot set local source in cloud mode. Use SetSource with explicit source type."); + SetSource(tenantId, sessionId, SourceType.LocalFile, null, path, null, autoSync); + } + + /// + /// Register a source and start watching. Used during Open and RestoreSessions. + /// + public void RegisterAndWatch(string tenantId, string sessionId, string path, bool autoSync) + { + var absolutePath = System.IO.Path.GetFullPath(path); + + try + { + var (success, error) = _sync.RegisterSourceAsync( + tenantId, sessionId, + SourceType.LocalFile, null, absolutePath, null, autoSync + ).GetAwaiter().GetResult(); + + if (!success) + { + _logger.LogWarning("Failed to register source for session {SessionId}: {Error}", sessionId, error); + } + else + { + _logger.LogDebug("Registered source for session {SessionId}: {Path}", sessionId, absolutePath); + } + + // Start watching + try + { + var (watchSuccess, watchId, watchError) = _sync.StartWatchAsync( + tenantId, sessionId, + SourceType.LocalFile, null, absolutePath, null + ).GetAwaiter().GetResult(); + + if (watchSuccess) + _logger.LogDebug("Started external watch for session {SessionId}: watchId={WatchId}", sessionId, watchId); + else + _logger.LogWarning("Failed to start external watch for session {SessionId}: {Error}", sessionId, watchError); + } + catch (Exception watchEx) + { + _logger.LogWarning(watchEx, "Exception starting external watch for session {SessionId}", sessionId); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to register and watch for session {SessionId}.", sessionId); + } + } + + /// + /// Get the sync status for a session. + /// + public SyncStatusDto? GetSyncStatus(string tenantId, string sessionId) + { + return _sync.GetSyncStatusAsync(tenantId, sessionId).GetAwaiter().GetResult(); + } + + /// + /// Save session data to its registered source. + /// + public void Save(string tenantId, string sessionId, byte[] data) + { + var status = _sync.GetSyncStatusAsync(tenantId, sessionId).GetAwaiter().GetResult(); + if (status is null) + { + throw new InvalidOperationException( + $"No save target registered for session '{sessionId}'. Use document_set_source to set a path first."); + } + + var (success, error, _) = _sync.SyncToSourceAsync(tenantId, sessionId, data).GetAwaiter().GetResult(); + + if (!success) + { + throw new InvalidOperationException($"Failed to save session '{sessionId}': {error}"); + } + + _logger.LogDebug("Saved session {SessionId} to {Path}.", sessionId, status.Path); + } + + /// + /// Auto-save if enabled and source is registered with auto-sync. + /// Returns true if auto-save was performed. + /// + public bool MaybeAutoSave(string tenantId, string sessionId, byte[] data) + { + if (!_autoSaveEnabled) + return false; + + try + { + var status = _sync.GetSyncStatusAsync(tenantId, sessionId).GetAwaiter().GetResult(); + if (status is null || !status.AutoSyncEnabled) + return false; + + var (success, error, syncedAt) = _sync.SyncToSourceAsync(tenantId, sessionId, data).GetAwaiter().GetResult(); + + if (!success) + { + _logger.LogWarning("Auto-save failed for session {SessionId}: {Error}", sessionId, error); + return false; + } + + _logger.LogDebug("Auto-saved session {SessionId} to {Path} (synced_at={SyncedAt}).", + sessionId, status.Path, syncedAt); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Auto-save failed for session {SessionId}.", sessionId); + return false; + } + } + + /// + /// Stop watching a session's source file. + /// + public void StopWatch(string tenantId, string sessionId) + { + try + { + _sync.StopWatchAsync(tenantId, sessionId).GetAwaiter().GetResult(); + _logger.LogDebug("Stopped external watch for session {SessionId}", sessionId); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to stop external watch for session {SessionId} (may not have been watching)", sessionId); + } + } + + // ========================================================================= + // Browse Operations (delegation to ISyncStorage) + // ========================================================================= + + /// + /// List available storage connections for the tenant. + /// + public List ListConnections(string tenantId, SourceType? filterType = null) + { + return _sync.ListConnectionsAsync(tenantId, filterType).GetAwaiter().GetResult(); + } + + /// + /// List files in a connection folder. + /// + public FileListResultDto ListFiles(string tenantId, SourceType sourceType, string? connectionId, + string? path = null, string? pageToken = null, int pageSize = 50) + { + return _sync.ListConnectionFilesAsync(tenantId, sourceType, connectionId, path, pageToken, pageSize).GetAwaiter().GetResult(); + } + + /// + /// Download a file from a connection. + /// + public byte[] DownloadFile(string tenantId, SourceType sourceType, string? connectionId, + string path, string? fileId = null) + { + return _sync.DownloadFromSourceAsync(tenantId, sourceType, connectionId, path, fileId).GetAwaiter().GetResult(); + } + + /// + /// Read the current source file bytes — downloads from cloud or reads from local disk. + /// Returns null if no source is available. + /// + public byte[]? ReadSourceBytes(string tenantId, string sessionId, string? localSourcePath) + { + var status = GetSyncStatus(tenantId, sessionId); + if (status != null && status.SourceType != SourceType.LocalFile) + return DownloadFile(tenantId, status.SourceType, + status.ConnectionId, status.Path, status.FileId); + + if (localSourcePath != null && File.Exists(localSourcePath)) + return File.ReadAllBytes(localSourcePath); + + return null; + } +} diff --git a/src/DocxMcp/TenantScope.cs b/src/DocxMcp/TenantScope.cs new file mode 100644 index 0000000..8d77ae7 --- /dev/null +++ b/src/DocxMcp/TenantScope.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; + +namespace DocxMcp; + +/// +/// Scoped service that resolves the correct SessionManager for the current request. +/// In stdio mode: wraps the singleton SessionManager. +/// In HTTP mode: reads X-Tenant-Id header and resolves from SessionManagerPool. +/// The .NET server does NO auth — X-Tenant-Id is injected by the upstream proxy. +/// +public sealed class TenantScope +{ + public string TenantId { get; } + public SessionManager Sessions { get; } + + /// + /// HTTP mode: resolve tenant from X-Tenant-Id header via SessionManagerPool. + /// + public TenantScope(IHttpContextAccessor accessor, SessionManagerPool pool) + { + TenantId = accessor.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? ""; + Sessions = pool.GetForTenant(TenantId); + } + + /// + /// Stdio mode: wrap the singleton SessionManager directly. + /// + public TenantScope(SessionManager sessions) + { + TenantId = sessions.TenantId; + Sessions = sessions; + } + + /// + /// Implicit conversion from SessionManager (convenience for stdio mode and tests). + /// + public static implicit operator TenantScope(SessionManager sessions) => new(sessions); +} diff --git a/src/DocxMcp/Tools/CommentTools.cs b/src/DocxMcp/Tools/CommentTools.cs index cf673f8..1e20da5 100644 --- a/src/DocxMcp/Tools/CommentTools.cs +++ b/src/DocxMcp/Tools/CommentTools.cs @@ -4,6 +4,8 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; using DocxMcp.Paths; @@ -23,7 +25,8 @@ public sealed class CommentTools " comment_add(doc_id, \"/body/paragraph[0]\", \"Needs revision\")\n" + " comment_add(doc_id, \"/body/paragraph[id='1A2B3C4D']\", \"Fix this phrase\", anchor_text=\"specific words\")")] public static string CommentAdd( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("Typed path to the target element (must resolve to exactly 1 element).")] string path, [Description("Comment text. Use \\n for multi-paragraph comments.")] string text, @@ -31,66 +34,75 @@ public static string CommentAdd( [Description("Comment author name. Default: 'AI Assistant'.")] string? author = null, [Description("Author initials. Default: 'AI'.")] string? initials = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; - - List elements; try { - var parsed = DocxPath.Parse(path); - elements = PathResolver.Resolve(parsed, doc); - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + + List elements; + try + { + var parsed = DocxPath.Parse(path); + elements = PathResolver.Resolve(parsed, doc); + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } - if (elements.Count == 0) - return $"Error: Path '{path}' resolved to 0 elements."; - if (elements.Count > 1) - return $"Error: Path '{path}' resolved to {elements.Count} elements — must resolve to exactly 1."; + if (elements.Count == 0) + return $"Error: Path '{path}' resolved to 0 elements."; + if (elements.Count > 1) + return $"Error: Path '{path}' resolved to {elements.Count} elements — must resolve to exactly 1."; - var target = elements[0]; - var effectiveAuthor = author ?? "AI Assistant"; - var effectiveInitials = initials ?? "AI"; - var date = DateTime.UtcNow; - var commentId = CommentHelper.AllocateCommentId(doc); + var target = elements[0]; + var effectiveAuthor = author ?? "AI Assistant"; + var effectiveInitials = initials ?? "AI"; + var date = DateTime.UtcNow; + var commentId = CommentHelper.AllocateCommentId(doc); - try - { - if (anchor_text is not null) + try { - CommentHelper.AddCommentToText(doc, target, commentId, text, - effectiveAuthor, effectiveInitials, date, anchor_text); + if (anchor_text is not null) + { + CommentHelper.AddCommentToText(doc, target, commentId, text, + effectiveAuthor, effectiveInitials, date, anchor_text); + } + else + { + CommentHelper.AddCommentToElement(doc, target, commentId, text, + effectiveAuthor, effectiveInitials, date); + } } - else + catch (Exception ex) { - CommentHelper.AddCommentToElement(doc, target, commentId, text, - effectiveAuthor, effectiveInitials, date); + return $"Error: {ex.Message}"; } - } - catch (Exception ex) - { - return $"Error: {ex.Message}"; - } - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "add_comment", - ["comment_id"] = commentId, - ["path"] = path, - ["text"] = text, - ["author"] = effectiveAuthor, - ["initials"] = effectiveInitials, - ["date"] = date.ToString("o"), - ["anchor_text"] = anchor_text is not null ? JsonValue.Create(anchor_text) : null - }; - var walEntry = new JsonArray(); - walEntry.Add((JsonNode)walObj); - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - - return $"Comment {commentId} added by '{effectiveAuthor}' on {path}."; + // Append to WAL + var walObj = new JsonObject + { + ["op"] = "add_comment", + ["comment_id"] = commentId, + ["path"] = path, + ["text"] = text, + ["author"] = effectiveAuthor, + ["initials"] = effectiveInitials, + ["date"] = date.ToString("o"), + ["anchor_text"] = anchor_text is not null ? JsonValue.Create(anchor_text) : null + }; + var walEntry = new JsonArray(); + walEntry.Add((JsonNode)walObj); + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); + + return $"Comment {commentId} added by '{effectiveAuthor}' on {path}."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"adding comment to '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "comment_list"), Description( @@ -98,54 +110,61 @@ public static string CommentAdd( "Returns a JSON object with pagination envelope and array of comment objects " + "containing id, author, initials, date, text, and anchored_text.")] public static string CommentList( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Filter by author name (case-insensitive).")] string? author = null, [Description("Number of comments to skip. Default: 0.")] int? offset = null, [Description("Maximum number of comments to return (1-50). Default: 50.")] int? limit = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - var comments = CommentHelper.ListComments(doc, author); - var total = comments.Count; + var comments = CommentHelper.ListComments(doc, author); + var total = comments.Count; - var effectiveOffset = Math.Max(0, offset ?? 0); - var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); + var effectiveOffset = Math.Max(0, offset ?? 0); + var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); - var page = comments - .Skip(effectiveOffset) - .Take(effectiveLimit) - .ToList(); + var page = comments + .Skip(effectiveOffset) + .Take(effectiveLimit) + .ToList(); - var arr = new JsonArray(); - foreach (var c in page) - { - var obj = new JsonObject + var arr = new JsonArray(); + foreach (var c in page) { - ["id"] = c.Id, - ["author"] = c.Author, - ["initials"] = c.Initials, - ["date"] = c.Date?.ToString("o"), - ["text"] = c.Text, - }; + var obj = new JsonObject + { + ["id"] = c.Id, + ["author"] = c.Author, + ["initials"] = c.Initials, + ["date"] = c.Date?.ToString("o"), + ["text"] = c.Text, + }; - if (c.AnchoredText is not null) - obj["anchored_text"] = c.AnchoredText; + if (c.AnchoredText is not null) + obj["anchored_text"] = c.AnchoredText; - arr.Add((JsonNode)obj); - } + arr.Add((JsonNode)obj); + } - var result = new JsonObject - { - ["total"] = total, - ["offset"] = effectiveOffset, - ["limit"] = effectiveLimit, - ["count"] = page.Count, - ["comments"] = arr - }; - - return result.ToJsonString(JsonOpts); + var result = new JsonObject + { + ["total"] = total, + ["offset"] = effectiveOffset, + ["limit"] = effectiveLimit, + ["count"] = page.Count, + ["comments"] = arr + }; + + return result.ToJsonString(JsonOpts); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"listing comments in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "comment_delete"), Description( @@ -153,59 +172,75 @@ public static string CommentList( "At least one of comment_id or author must be provided.\n" + "When deleting by author, each comment generates its own WAL entry for deterministic replay.")] public static string CommentDelete( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("ID of the specific comment to delete.")] int? comment_id = null, [Description("Delete all comments by this author (case-insensitive).")] string? author = null) { - if (comment_id is null && author is null) - return "Error: At least one of comment_id or author must be provided."; - - var session = sessions.Get(doc_id); - var doc = session.Document; - - if (comment_id is not null) + try { - var deleted = CommentHelper.DeleteComment(doc, comment_id.Value); - if (!deleted) - return $"Error: Comment {comment_id.Value} not found."; + if (comment_id is null && author is null) + return "Error: At least one of comment_id or author must be provided."; - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "delete_comment", - ["comment_id"] = comment_id.Value - }; - var walEntry = new JsonArray(); - walEntry.Add((JsonNode)walObj); - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - - return "Deleted 1 comment(s)."; - } - - // Delete by author — expand to individual WAL entries - var comments = CommentHelper.ListComments(doc, author); - if (comments.Count == 0) - return $"Error: No comments found by author '{author}'."; + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - var deletedCount = 0; - foreach (var c in comments) - { - if (CommentHelper.DeleteComment(doc, c.Id)) + if (comment_id is not null) { + var deleted = CommentHelper.DeleteComment(doc, comment_id.Value); + if (!deleted) + return $"Error: Comment {comment_id.Value} not found."; + + // Append to WAL var walObj = new JsonObject { ["op"] = "delete_comment", - ["comment_id"] = c.Id + ["comment_id"] = comment_id.Value }; var walEntry = new JsonArray(); walEntry.Add((JsonNode)walObj); - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - deletedCount++; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); + + return "Deleted 1 comment(s)."; } - } - return $"Deleted {deletedCount} comment(s)."; + // Delete by author — expand to individual WAL entries + var comments = CommentHelper.ListComments(doc, author); + if (comments.Count == 0) + return $"Error: No comments found by author '{author}'."; + + var deletedCount = 0; + byte[]? lastBytes = null; + foreach (var c in comments) + { + if (CommentHelper.DeleteComment(doc, c.Id)) + { + var walObj = new JsonObject + { + ["op"] = "delete_comment", + ["comment_id"] = c.Id + }; + var walEntry = new JsonArray(); + walEntry.Add((JsonNode)walObj); + lastBytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, lastBytes); + deletedCount++; + } + } + + // Auto-save after all deletions + if (deletedCount > 0 && lastBytes is not null) + sync.MaybeAutoSave(tenant.TenantId, doc_id, lastBytes); + + return $"Deleted {deletedCount} comment(s)."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"deleting comment in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } /// diff --git a/src/DocxMcp/Tools/ConnectionTools.cs b/src/DocxMcp/Tools/ConnectionTools.cs new file mode 100644 index 0000000..2d3e5e0 --- /dev/null +++ b/src/DocxMcp/Tools/ConnectionTools.cs @@ -0,0 +1,130 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using DocxMcp.Grpc; +using Grpc.Core; +using ModelContextProtocol; +using ModelContextProtocol.Server; +using DocxMcp.Helpers; + +namespace DocxMcp.Tools; + +[McpServerToolType] +public sealed class ConnectionTools +{ + [McpServerTool(Name = "list_connections"), Description( + "List available storage connections for the current user. " + + "ALWAYS call this first to discover which source types are available before using list_connection_files or document_open. " + + "Returns only the connections actually configured for this deployment.")] + public static string ListConnections( + TenantScope tenant, + SyncManager sync, + [Description("Filter by source type: 'local', 'google_drive', 'onedrive'. Omit to list all.")] + string? source_type = null) + { + try + { + SourceType? filter = source_type switch + { + "local" => SourceType.LocalFile, + "google_drive" => SourceType.GoogleDrive, + "onedrive" => SourceType.Onedrive, + _ => null + }; + + var connections = sync.ListConnections(tenant.TenantId, filter); + + var arr = new JsonArray(); + foreach (var c in connections) + { + var obj = new JsonObject + { + ["connection_id"] = c.ConnectionId, + ["type"] = c.Type.ToString(), + ["display_name"] = c.DisplayName + }; + if (c.ProviderAccountId is not null) + obj["provider_account_id"] = c.ProviderAccountId; + arr.Add((JsonNode)obj); + } + + var result = new JsonObject + { + ["count"] = connections.Count, + ["connections"] = arr + }; + + return result.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, "listing connections"); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } + } + + [McpServerTool(Name = "list_connection_files"), Description( + "Browse files and folders in a storage connection. " + + "Call list_connections first to discover available source types and connection IDs. " + + "Supports folder navigation and pagination. " + + "Returns .docx files and all folders (for navigation).")] + public static string ListConnectionFiles( + TenantScope tenant, + SyncManager sync, + [Description("Source type from list_connections result (e.g. 'local', 'google_drive', 'onedrive').")] + string source_type, + [Description("Connection ID from list_connections result. Required for cloud sources.")] + string? connection_id = null, + [Description("Folder path to browse. Omit for root.")] + string? path = null, + [Description("Pagination token from previous response.")] + string? page_token = null, + [Description("Max results per page. Default 20.")] + int page_size = 20) + { + try + { + var type = source_type switch + { + "local" => SourceType.LocalFile, + "google_drive" => SourceType.GoogleDrive, + "onedrive" => SourceType.Onedrive, + _ => throw new ArgumentException($"Unknown source type: {source_type}. Use 'local', 'google_drive', or 'onedrive'.") + }; + + var result = sync.ListFiles(tenant.TenantId, type, connection_id, path, page_token, page_size); + + var filesArr = new JsonArray(); + foreach (var f in result.Files) + { + var obj = new JsonObject + { + ["name"] = f.Name, + ["is_folder"] = f.IsFolder, + }; + if (!f.IsFolder) + { + obj["size_bytes"] = f.SizeBytes; + if (f.ModifiedAtUnix > 0) + obj["modified_at"] = DateTimeOffset.FromUnixTimeSeconds(f.ModifiedAtUnix).ToString("o"); + } + if (f.FileId is not null) + obj["file_id"] = f.FileId; + obj["path"] = f.Path; + filesArr.Add((JsonNode)obj); + } + + var response = new JsonObject + { + ["count"] = result.Files.Count, + ["files"] = filesArr + }; + + if (result.NextPageToken is not null) + response["next_page_token"] = result.NextPageToken; + + return response.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, "listing files"); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } + } +} diff --git a/src/DocxMcp/Tools/CountTool.cs b/src/DocxMcp/Tools/CountTool.cs index fb66565..615f1a6 100644 --- a/src/DocxMcp/Tools/CountTool.cs +++ b/src/DocxMcp/Tools/CountTool.cs @@ -4,6 +4,8 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; using DocxMcp.Paths; @@ -24,41 +26,48 @@ public sealed class CountTool " /body/table[0]/row[*] — count rows in first table\n" + " /body/paragraph[text~='hello'] — count paragraphs containing 'hello'")] public static string CountElements( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Typed path with selector (e.g. /body/paragraph[*], /body/table[0]/row[*]).")] string path) { - var session = sessions.Get(doc_id); - var doc = session.Document; - - // Handle special paths with counts - if (path is "/body" or "body" or "/") + try { - var body = doc.MainDocumentPart?.Document?.Body; - if (body is null) - return """{"error": "Document has no body."}"""; + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - var result = new JsonObject + // Handle special paths with counts + if (path is "/body" or "body" or "/") { - ["paragraphs"] = body.Elements().Count(), - ["tables"] = body.Elements().Count(), - ["headings"] = body.Elements().Count(p => p.IsHeading()), - ["total_children"] = body.ChildElements.Count, - }; + var body = doc.MainDocumentPart?.Document?.Body; + if (body is null) + return """{"error": "Document has no body."}"""; - return result.ToJsonString(JsonOpts); - } + var result = new JsonObject + { + ["paragraphs"] = body.Elements().Count(), + ["tables"] = body.Elements
().Count(), + ["headings"] = body.Elements().Count(p => p.IsHeading()), + ["total_children"] = body.ChildElements.Count, + }; - var parsed = DocxPath.Parse(path); - var elements = PathResolver.Resolve(parsed, doc); + return result.ToJsonString(JsonOpts); + } - var countResult = new JsonObject - { - ["path"] = path, - ["count"] = elements.Count, - }; + var parsed = DocxPath.Parse(path); + var elements = PathResolver.Resolve(parsed, doc); - return countResult.ToJsonString(JsonOpts); + var countResult = new JsonObject + { + ["path"] = path, + ["count"] = elements.Count, + }; + + return countResult.ToJsonString(JsonOpts); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"counting elements in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } private static readonly JsonSerializerOptions JsonOpts = new() diff --git a/src/DocxMcp/Tools/DocumentTools.cs b/src/DocxMcp/Tools/DocumentTools.cs index e6c4ded..b56fc3a 100644 --- a/src/DocxMcp/Tools/DocumentTools.cs +++ b/src/DocxMcp/Tools/DocumentTools.cs @@ -1,9 +1,12 @@ using System.ComponentModel; using System.Text.Json; using System.Text.Json.Nodes; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; -using DocxMcp.ExternalChanges; +using DocxMcp.Grpc; namespace DocxMcp.Tools; @@ -13,29 +16,121 @@ public sealed class DocumentTools [McpServerTool(Name = "document_open"), Description( "Open an existing DOCX file or create a new empty document. " + "Returns a session ID to use with other tools. " + - "If path is omitted, creates a new empty document. " + - "For existing files, external changes will be monitored automatically.")] + "If all parameters are omitted, creates a new empty document. " + + "Use list_connections and list_connection_files to discover available files before opening. " + + "For local files, provide path only. " + + "For cloud files (Google Drive), provide source_type, connection_id, file_id, and path.")] public static string DocumentOpen( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, - [Description("Absolute path to the .docx file to open. Omit to create a new empty document.")] - string? path = null) + ILogger logger, + TenantScope tenant, + SyncManager sync, + [Description("Absolute path for local files, or display path for cloud files.")] + string? path = null, + [Description("Source type: 'local', 'google_drive'. Omit for local or new document.")] + string? source_type = null, + [Description("Connection ID from list_connections (required for cloud sources).")] + string? connection_id = null, + [Description("Provider file ID from list_connection_files (required for cloud sources).")] + string? file_id = null) { - var session = path is not null - ? sessions.Open(path) - : sessions.Create(); - - // Start watching for external changes if we have a source file - if (session.SourcePath is not null && externalChangeTracker is not null) + try { - externalChangeTracker.StartWatching(session.Id); + logger.LogDebug("document_open: path={Path}, source_type={SourceType}, connection_id={ConnId}, file_id={FileId}", + path, source_type, connection_id, file_id); + + var sessions = tenant.Sessions; + + DocxSession session; + string sourceDescription; + + if (path is null && source_type is null && connection_id is null && file_id is null) + { + // New empty document — no sync needed, always allowed + session = sessions.Create(); + sourceDescription = " (new document)"; + } + else + { + // Resolve source type (infer from params, block local in cloud mode) + var type = ResolveSourceType(source_type, connection_id, sync); + + if (type != SourceType.LocalFile && file_id is not null) + { + // Cloud source: download bytes, create session, register source + var data = sync.DownloadFile(tenant.TenantId, type, connection_id, path ?? file_id, file_id); + session = sessions.OpenFromBytes(data, path ?? file_id); + + // Register typed source for sync-back + sync.SetSource(tenant.TenantId, session.Id, type, connection_id, path ?? file_id, file_id, autoSync: true); + sessions.SetSourcePath(session.Id, path ?? file_id); + + sourceDescription = $" from {source_type}://{path ?? file_id}"; + } + else if (path is not null) + { + // Local file + session = sessions.Open(path); + + if (session.SourcePath is not null) + sync.RegisterAndWatch(tenant.TenantId, session.Id, session.SourcePath, autoSync: true); + + sourceDescription = $" from '{session.SourcePath}'"; + } + else + { + throw new ArgumentException( + "Invalid parameters. To open a cloud file, provide source_type + connection_id + file_id. " + + "To create a new empty document, omit all parameters."); + } + } + + logger.LogDebug("document_open result: session={SessionId}, source={Source}", session.Id, sourceDescription); + return $"Opened document{sourceDescription}. Session ID: {session.Id}"; } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, "opening document"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(path ?? "new document"); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } + } + + [McpServerTool(Name = "document_set_source"), Description( + "Set or change where a document will be saved. " + + "Use this for 'Save As' operations or to set a save target for new documents. " + + "Use list_connections to discover available storage targets. " + + "If auto_sync is true (default), the document will be auto-saved after each edit.")] + public static string DocumentSetSource( + ILogger logger, + TenantScope tenant, + SyncManager sync, + [Description("Session ID of the document.")] + string doc_id, + [Description("Path (absolute for local, display path for cloud).")] + string path, + [Description("Source type: 'local', 'google_drive'. Default: local.")] + string? source_type = null, + [Description("Connection ID from list_connections (required for cloud sources).")] + string? connection_id = null, + [Description("Provider file ID (required for cloud sources).")] + string? file_id = null, + [Description("Enable auto-save after each edit. Default true.")] + bool auto_sync = true) + { + try + { + logger.LogDebug("document_set_source: doc_id={DocId}, path={Path}, source_type={SourceType}, connection_id={ConnId}, file_id={FileId}", + doc_id, path, source_type, connection_id, file_id); + + var type = ResolveSourceType(source_type, connection_id, sync); - var source = session.SourcePath is not null - ? $" from '{session.SourcePath}'" - : " (new document)"; + sync.SetSource(tenant.TenantId, doc_id, type, connection_id, path, file_id, auto_sync); + tenant.Sessions.SetSourcePath(doc_id, path); - return $"Opened document{source}. Session ID: {session.Id}"; + return $"Source set to '{path}' for session '{doc_id}'. Type: {type}. Auto-sync: {(auto_sync ? "enabled" : "disabled")}."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, "setting document source"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "document_save"), Description( @@ -44,54 +139,91 @@ public static string DocumentOpen( "Use this tool for 'Save As' (providing output_path) or to save new documents that have no source path. " + "Updates the external change tracker snapshot after saving.")] public static string DocumentSave( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + ILogger logger, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document to save.")] string doc_id, [Description("Path to save the file to. If omitted, saves to the original path.")] string? output_path = null) { - sessions.Save(doc_id, output_path); + try + { + logger.LogDebug("document_save: doc_id={DocId}, output_path={OutputPath}", doc_id, output_path); - // Update the external change tracker's snapshot after save - externalChangeTracker?.UpdateSessionSnapshot(doc_id); + var sessions = tenant.Sessions; + // If output_path is provided, update/register the source first + if (output_path is not null) + { + // Preserve existing source type if registered (don't force LocalFile) + var existing = sync.GetSyncStatus(tenant.TenantId, doc_id); + if (existing is not null) + { + logger.LogDebug("document_save: preserving existing source type {SourceType} for session {DocId}", + existing.SourceType, doc_id); + sync.SetSource(tenant.TenantId, doc_id, + existing.SourceType, existing.ConnectionId, output_path, + existing.FileId, autoSync: true); + } + else + { + sync.SetSource(tenant.TenantId, doc_id, output_path, autoSync: true); + } + sessions.SetSourcePath(doc_id, output_path); + } - var session = sessions.Get(doc_id); - var target = output_path ?? session.SourcePath ?? "(unknown)"; - return $"Document saved to '{target}'."; + var session = sessions.Get(doc_id); + sync.Save(tenant.TenantId, doc_id, session.ToBytes()); + + var target = output_path ?? session.SourcePath ?? "(unknown)"; + logger.LogDebug("document_save: saved to {Target}", target); + return $"Document saved to '{target}'."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, "saving document"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "document_list"), Description( "List all currently open document sessions with track changes status.")] - public static string DocumentList(SessionManager sessions) + public static string DocumentList(ILogger logger, TenantScope tenant) { - var list = sessions.List(); - if (list.Count == 0) - return "No open documents."; - - var arr = new JsonArray(); - foreach (var s in list) + try { - var session = sessions.Get(s.Id); - var stats = RevisionHelper.GetRevisionStats(session.Document); + logger.LogDebug("document_list: tenant={TenantId}", tenant.TenantId); + var sessions = tenant.Sessions; + var list = sessions.List(); + if (list.Count == 0) + return "No open documents."; - var obj = new JsonObject + var arr = new JsonArray(); + foreach (var s in list) { - ["id"] = s.Id, - ["path"] = s.Path, - ["track_changes_enabled"] = stats.TrackChangesEnabled, - ["pending_revisions"] = stats.TotalCount - }; - arr.Add((JsonNode)obj); - } + var session = sessions.Get(s.Id); + var stats = RevisionHelper.GetRevisionStats(session.Document); - var result = new JsonObject - { - ["count"] = list.Count, - ["sessions"] = arr - }; + var obj = new JsonObject + { + ["id"] = s.Id, + ["path"] = s.Path, + ["track_changes_enabled"] = stats.TrackChangesEnabled, + ["pending_revisions"] = stats.TotalCount + }; + arr.Add((JsonNode)obj); + } - return result.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + var result = new JsonObject + { + ["count"] = list.Count, + ["sessions"] = arr + }; + + return result.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, "listing documents"); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } /// @@ -101,14 +233,13 @@ public static string DocumentList(SessionManager sessions) /// This will delete all persisted data (baseline, WAL, checkpoints). /// public static string DocumentClose( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager? sync, string doc_id) { - // Stop watching for external changes before closing - externalChangeTracker?.StopWatching(doc_id); + sync?.StopWatch(tenant.TenantId, doc_id); - sessions.Close(doc_id); + tenant.Sessions.Close(doc_id); return $"Document session '{doc_id}' closed."; } @@ -119,13 +250,49 @@ public static string DocumentClose( /// WAL compaction should only be performed via the CLI for administrative purposes. /// public static string DocumentSnapshot( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document to snapshot.")] string doc_id, [Description("If true, discard redo history when compacting. Default false.")] bool discard_redo = false) { - sessions.Compact(doc_id, discard_redo); + tenant.Sessions.Compact(doc_id, discard_redo); return $"Snapshot created for session '{doc_id}'. WAL compacted."; } + + /// + /// Resolve source_type from explicit param or infer from connection_id. + /// Blocks local sources in cloud mode (SYNC_GRPC_URL set). + /// + private static SourceType ResolveSourceType(string? source_type, string? connection_id, SyncManager sync) + { + if (source_type is not null) + { + var resolved = source_type switch + { + "google_drive" => SourceType.GoogleDrive, + "onedrive" => SourceType.Onedrive, + "local" => SourceType.LocalFile, + _ => throw new ArgumentException($"Unknown source_type: {source_type}") + }; + + // Block local in cloud mode + if (resolved == SourceType.LocalFile && sync.IsRemoteSync) + throw new ArgumentException( + "Local file storage is not available in this deployment. Use a cloud source (google_drive, onedrive)."); + + return resolved; + } + + // Infer: connection_id provided → cloud source (google_drive by default) + if (connection_id is not null) + return SourceType.GoogleDrive; + + // No connection_id, no source_type → local only if local is available + if (sync.IsRemoteSync) + throw new ArgumentException( + "source_type is required. This deployment uses cloud storage. Call list_connections to discover available sources."); + + return SourceType.LocalFile; + } } diff --git a/src/DocxMcp/Tools/ElementTools.cs b/src/DocxMcp/Tools/ElementTools.cs index e35de37..3c08b1f 100644 --- a/src/DocxMcp/Tools/ElementTools.cs +++ b/src/DocxMcp/Tools/ElementTools.cs @@ -4,10 +4,10 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using ModelContextProtocol.Server; +using DocxMcp.ExternalChanges; using DocxMcp.Helpers; using DocxMcp.Models; using DocxMcp.Paths; -using DocxMcp.ExternalChanges; using static DocxMcp.Helpers.ElementIdManager; namespace DocxMcp.Tools; @@ -92,8 +92,9 @@ public sealed class ElementTools " 3. Use /body/children/999 to append (avoids counting elements)\n" + " 4. For tables: add the table first, then add rows using the table's ID")] public static string AddElement( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path where to add the element (e.g., /body/children/0, /body/table[0]/row).")] string path, [Description("JSON object describing the element to add.")] string value, @@ -101,7 +102,7 @@ public static string AddElement( { var patches = new[] { new AddPatchInput { Path = path, Value = JsonDocument.Parse(value).RootElement } }; var patchJson = JsonSerializer.Serialize(patches, DocxJsonContext.Default.AddPatchInputArray); - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } [McpServerTool(Name = "replace_element"), Description( @@ -142,8 +143,9 @@ public static string AddElement( " 2. Use dry_run=true to validate before replacing\n" + " 3. For partial text changes, use replace_text instead (preserves formatting)")] public static string ReplaceElement( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path to the element to replace.")] string path, [Description("JSON object describing the new element.")] string value, @@ -151,7 +153,7 @@ public static string ReplaceElement( { var patches = new[] { new ReplacePatchInput { Path = path, Value = JsonDocument.Parse(value).RootElement } }; var patchJson = JsonSerializer.Serialize(patches, DocxJsonContext.Default.ReplacePatchInputArray); - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } [McpServerTool(Name = "remove_element"), Description( @@ -196,14 +198,15 @@ public static string ReplaceElement( " 3. Be cautious with [*] wildcard—it removes ALL matching elements\n" + " 4. Remember you can undo with undo_patch if needed")] public static string RemoveElement( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path to the element to remove.")] string path, [Description("If true, simulates the operation without applying changes.")] bool dry_run = false) { var patchJson = $$"""[{"op": "remove", "path": "{{EscapeJson(path)}}"}]"""; - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } [McpServerTool(Name = "move_element"), Description( @@ -251,15 +254,16 @@ public static string RemoveElement( " 3. Use dry_run=true to verify the operation first\n" + " 4. For duplicating (not moving), use copy_element instead")] public static string MoveElement( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path to the element to move.")] string from, [Description("Destination path (use /body/children/N for position).")] string to, [Description("If true, simulates the operation without applying changes.")] bool dry_run = false) { var patchJson = $$"""[{"op": "move", "from": "{{EscapeJson(from)}}", "path": "{{EscapeJson(to)}}"}]"""; - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } [McpServerTool(Name = "copy_element"), Description( @@ -309,15 +313,16 @@ public static string MoveElement( " 3. Use dry_run=true to verify the operation first\n" + " 4. For moving (not copying), use move_element instead")] public static string CopyElement( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path to the element to copy.")] string from, [Description("Destination path for the copy.")] string to, [Description("If true, simulates the operation without applying changes.")] bool dry_run = false) { var patchJson = $$"""[{"op": "copy", "from": "{{EscapeJson(from)}}", "path": "{{EscapeJson(to)}}"}]"""; - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } private static string EscapeJson(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); @@ -379,8 +384,9 @@ public sealed class TextTools " 3. Target specific elements with IDs for precise control\n" + " 4. For structural changes (add/remove paragraphs), use other tools")] public static string ReplaceText( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path to element(s) to search in.")] string path, [Description("Text to find (case-sensitive).")] string find, @@ -390,7 +396,7 @@ public static string ReplaceText( { var patches = new[] { new ReplaceTextPatchInput { Path = path, Find = find, Replace = replace, MaxCount = max_count } }; var patchJson = JsonSerializer.Serialize(patches, DocxJsonContext.Default.ReplaceTextPatchInputArray); - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } } @@ -433,14 +439,15 @@ public sealed class TableTools " 3. Use dry_run=true to see rows_affected before committing\n" + " 4. For removing specific cells only, use remove_element on individual cells")] public static string RemoveTableColumn( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("Path to the table.")] string path, [Description("Column index to remove (0-based).")] int column, [Description("If true, simulates the operation without applying changes.")] bool dry_run = false) { var patchJson = $$"""[{"op": "remove_column", "path": "{{path.Replace("\\", "\\\\").Replace("\"", "\\\"")}}", "column": {{column}}}]"""; - return PatchTool.ApplyPatch(sessions, externalChangeTracker, doc_id, patchJson, dry_run); + return PatchTool.ApplyPatch(tenant, sync, gate, doc_id, patchJson, dry_run); } } diff --git a/src/DocxMcp/Tools/ExportTools.cs b/src/DocxMcp/Tools/ExportTools.cs index 9b1eb4d..bd7be87 100644 --- a/src/DocxMcp/Tools/ExportTools.cs +++ b/src/DocxMcp/Tools/ExportTools.cs @@ -3,6 +3,8 @@ using System.Text; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; @@ -11,77 +13,41 @@ namespace DocxMcp.Tools; [McpServerToolType] public sealed class ExportTools { - [McpServerTool(Name = "export_pdf"), Description( - "Export a document to PDF using LibreOffice CLI (soffice). " + - "LibreOffice must be installed on the system.")] - public static async Task ExportPdf( - SessionManager sessions, + [McpServerTool(Name = "export"), Description( + "Export a document to another format. Returns the content as text (html, markdown) " + + "or as base64-encoded binary (pdf, docx).\n\n" + + "Formats:\n" + + " html — returns HTML string\n" + + " markdown — returns Markdown string\n" + + " pdf — returns base64-encoded PDF (requires LibreOffice on the server)\n" + + " docx — returns base64-encoded DOCX bytes")] + public static async Task Export( + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, - [Description("Output path for the PDF file.")] string output_path) + [Description("Export format: html, markdown, pdf, docx.")] string format) { - var session = sessions.Get(doc_id); - - // Save to a temp .docx first - var tempDocx = Path.Combine(Path.GetTempPath(), $"docx-mcp-{session.Id}.docx"); try { - session.Save(tempDocx); - - // Find LibreOffice - var soffice = FindLibreOffice(); - if (soffice is null) - return "Error: LibreOffice not found. Install it for PDF export. " + - "macOS: brew install --cask libreoffice"; - - var outputDir = Path.GetDirectoryName(output_path) ?? Path.GetTempPath(); + var session = tenant.Sessions.Get(doc_id); - var psi = new ProcessStartInfo + return format.ToLowerInvariant() switch { - FileName = soffice, - Arguments = $"--headless --convert-to pdf --outdir \"{outputDir}\" \"{tempDocx}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true + "html" => ExportHtml(session), + "markdown" or "md" => ExportMarkdown(session), + "pdf" => await ExportPdf(session), + "docx" => ExportDocx(session), + _ => throw new McpException( + $"Unknown export format '{format}'. Supported: html, markdown, pdf, docx."), }; - - using var process = Process.Start(psi) - ?? throw new InvalidOperationException("Failed to start LibreOffice."); - - await process.WaitForExitAsync(); - - if (process.ExitCode != 0) - { - var stderr = await process.StandardError.ReadToEndAsync(); - return $"Error: LibreOffice failed (exit {process.ExitCode}): {stderr}"; - } - - // LibreOffice outputs to outputDir with the same base name - var generatedPdf = Path.Combine(outputDir, - Path.GetFileNameWithoutExtension(tempDocx) + ".pdf"); - - if (File.Exists(generatedPdf) && generatedPdf != output_path) - { - File.Move(generatedPdf, output_path, overwrite: true); - } - - return $"PDF exported to '{output_path}'."; - } - finally - { - if (File.Exists(tempDocx)) - File.Delete(tempDocx); } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"exporting '{doc_id}' to {format}"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } - [McpServerTool(Name = "export_html"), Description( - "Export a document to HTML format.")] - public static string ExportHtml( - SessionManager sessions, - [Description("Session ID of the document.")] string doc_id, - [Description("Output path for the HTML file.")] string output_path) + private static string ExportHtml(DocxSession session) { - var session = sessions.Get(doc_id); var body = session.GetBody(); var sb = new StringBuilder(); @@ -107,19 +73,11 @@ public static string ExportHtml( } sb.AppendLine(""); - - File.WriteAllText(output_path, sb.ToString(), Encoding.UTF8); - return $"HTML exported to '{output_path}'."; + return sb.ToString(); } - [McpServerTool(Name = "export_markdown"), Description( - "Export a document to Markdown format.")] - public static string ExportMarkdown( - SessionManager sessions, - [Description("Session ID of the document.")] string doc_id, - [Description("Output path for the Markdown file.")] string output_path) + private static string ExportMarkdown(DocxSession session) { - var session = sessions.Get(doc_id); var body = session.GetBody(); var sb = new StringBuilder(); @@ -137,8 +95,65 @@ public static string ExportMarkdown( } } - File.WriteAllText(output_path, sb.ToString(), Encoding.UTF8); - return $"Markdown exported to '{output_path}'."; + return sb.ToString(); + } + + private static async Task ExportPdf(DocxSession session) + { + var tempDocx = Path.Combine(Path.GetTempPath(), $"docx-mcp-{session.Id}.docx"); + var tempDir = Path.GetTempPath(); + try + { + session.Save(tempDocx); + + var soffice = FindLibreOffice() + ?? throw new McpException( + "LibreOffice not found. PDF export requires LibreOffice. " + + "macOS: brew install --cask libreoffice"); + + var psi = new ProcessStartInfo + { + FileName = soffice, + Arguments = $"--headless --convert-to pdf --outdir \"{tempDir}\" \"{tempDocx}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(psi) + ?? throw new McpException("Failed to start LibreOffice."); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync(); + throw new McpException($"LibreOffice failed (exit {process.ExitCode}): {stderr}"); + } + + var generatedPdf = Path.Combine(tempDir, + Path.GetFileNameWithoutExtension(tempDocx) + ".pdf"); + + if (!File.Exists(generatedPdf)) + throw new McpException("LibreOffice did not produce a PDF file."); + + var pdfBytes = await File.ReadAllBytesAsync(generatedPdf); + File.Delete(generatedPdf); + + return Convert.ToBase64String(pdfBytes); + } + finally + { + if (File.Exists(tempDocx)) + File.Delete(tempDocx); + } + } + + private static string ExportDocx(DocxSession session) + { + var bytes = session.ToBytes(); + return Convert.ToBase64String(bytes); } private static void RenderParagraphHtml(Paragraph p, StringBuilder sb) @@ -157,7 +172,6 @@ private static void RenderParagraphHtml(Paragraph p, StringBuilder sb) var style = p.GetStyleId(); if (style is "ListBullet" or "ListNumber") { - // Simple list rendering sb.AppendLine($"
  • {Escape(text)}
  • "); } else @@ -248,12 +262,10 @@ private static void RenderTableMarkdown(Table t, StringBuilder sb) var rows = t.Elements().ToList(); if (rows.Count == 0) return; - // Header var headerCells = rows[0].Elements().Select(c => c.InnerText).ToList(); sb.AppendLine("| " + string.Join(" | ", headerCells) + " |"); sb.AppendLine("| " + string.Join(" | ", headerCells.Select(_ => "---")) + " |"); - // Data rows foreach (var row in rows.Skip(1)) { var cells = row.Elements().Select(c => c.InnerText).ToList(); diff --git a/src/DocxMcp/Tools/ExternalChangeTools.cs b/src/DocxMcp/Tools/ExternalChangeTools.cs index 7e6f7e6..8dc208e 100644 --- a/src/DocxMcp/Tools/ExternalChangeTools.cs +++ b/src/DocxMcp/Tools/ExternalChangeTools.cs @@ -1,171 +1,261 @@ using System.ComponentModel; +using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Nodes; +using DocxMcp.Diff; using DocxMcp.ExternalChanges; +using DocxMcp.Helpers; +using DocxMcp.Persistence; +using DocumentFormat.OpenXml.Packaging; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; namespace DocxMcp.Tools; /// -/// MCP tool for handling external document changes. -/// Single unified tool that detects, displays, and acknowledges external modifications. +/// MCP tools for detecting and syncing external document changes. +/// Uses ExternalChangeGate for pending state tracking (blocks edits until acknowledged). /// [McpServerToolType] public sealed class ExternalChangeTools { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - /// - /// Check for external changes, get details, and optionally acknowledge them. - /// This is the single tool for all external change operations. - /// [McpServerTool(Name = "get_external_changes"), Description( "Check if the source file has been modified externally and get change details.\n\n" + - "This tool:\n" + - "1. Detects if the source file was modified outside this session\n" + - "2. Shows detailed diff (what was added, removed, modified, moved)\n" + - "3. Can acknowledge changes to allow continued editing\n\n" + + "Compares the current in-memory session with the source file (local or cloud).\n" + + "Returns a diff summary showing what was added, removed, modified, or moved.\n\n" + "IMPORTANT: If external changes are detected, you MUST acknowledge them " + - "(set acknowledge=true) before you can continue editing this document.")] + "(set acknowledge=true) before you can continue editing this document.\n\n" + + "Use sync_external_changes to reload the document from the source if changes are detected.")] public static string GetExternalChanges( - ExternalChangeTracker tracker, - [Description("Session ID to check for external changes")] + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, + [Description("Session ID to check for external changes.")] string doc_id, - [Description("Set to true to acknowledge the changes and allow editing to continue")] + [Description("Set to true to acknowledge the changes and allow editing to continue.")] bool acknowledge = false) { - // First check for any already-detected pending changes - var pending = tracker.GetLatestUnacknowledgedChange(doc_id); - - // If no pending, check for new changes - if (pending is null) + try { - pending = tracker.CheckForChanges(doc_id); - } + var pending = gate.CheckForChanges(tenant.TenantId, tenant.Sessions, doc_id, sync); - // No changes detected - if (pending is null) - { - var noChangesResult = new JsonObject + // No changes detected + if (pending is null) { - ["has_changes"] = false, - ["can_edit"] = true, - ["message"] = "No external changes detected. The document is in sync with the source file." - }; - return noChangesResult.ToJsonString(JsonOptions); - } + return new JsonObject + { + ["has_changes"] = false, + ["can_edit"] = true, + ["message"] = "No external changes detected. The document is in sync with the source file." + }.ToJsonString(JsonOptions); + } - // Acknowledge if requested - if (acknowledge) - { - tracker.AcknowledgeChange(doc_id, pending.Id); + // Acknowledge if requested + if (acknowledge) + { + gate.Acknowledge(tenant.TenantId, doc_id); - var ackResult = new JsonObject + var ackResult = new JsonObject + { + ["has_changes"] = true, + ["acknowledged"] = true, + ["can_edit"] = true, + ["change_id"] = pending.Id, + ["detected_at"] = pending.DetectedAt.ToString("o"), + ["source_path"] = pending.SourcePath, + ["summary"] = BuildSummaryJson(pending.Summary), + ["changes"] = BuildChangesJson(pending.Changes), + ["message"] = $"External changes acknowledged. You may now continue editing.\n\n" + + $"Summary: {pending.Summary.TotalChanges} change(s) were made externally:\n" + + $" - {pending.Summary.Added} added\n" + + $" - {pending.Summary.Removed} removed\n" + + $" - {pending.Summary.Modified} modified\n" + + $" - {pending.Summary.Moved} moved" + }; + return ackResult.ToJsonString(JsonOptions); + } + + // Return details without acknowledging — editing is blocked + var result = new JsonObject { ["has_changes"] = true, - ["acknowledged"] = true, - ["can_edit"] = true, + ["acknowledged"] = false, + ["can_edit"] = false, ["change_id"] = pending.Id, ["detected_at"] = pending.DetectedAt.ToString("o"), ["source_path"] = pending.SourcePath, ["summary"] = BuildSummaryJson(pending.Summary), ["changes"] = BuildChangesJson(pending.Changes), - ["message"] = $"External changes acknowledged. You may now continue editing.\n\n" + - $"Summary: {pending.Summary.TotalChanges} change(s) were made externally:\n" + - $" • {pending.Summary.Added} added\n" + - $" • {pending.Summary.Removed} removed\n" + - $" • {pending.Summary.Modified} modified\n" + - $" • {pending.Summary.Moved} moved" + ["message"] = BuildChangeMessage(pending) }; - return ackResult.ToJsonString(JsonOptions); + return result.ToJsonString(JsonOptions); } - - // Return details without acknowledging - var result = new JsonObject - { - ["has_changes"] = true, - ["acknowledged"] = false, - ["can_edit"] = false, - ["change_id"] = pending.Id, - ["detected_at"] = pending.DetectedAt.ToString("o"), - ["source_path"] = pending.SourcePath, - ["summary"] = BuildSummaryJson(pending.Summary), - ["changes"] = BuildChangesJson(pending.Changes), - ["patches"] = new JsonArray(pending.Patches.Select(p => (JsonNode)p.ToJsonString()).ToArray()), - ["message"] = BuildChangeMessage(pending) - }; - return result.ToJsonString(JsonOptions); + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"checking external changes for '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } - /// - /// Synchronize the session with external file changes. - /// Reloads the document from disk, re-assigns all element IDs, detects uncovered changes, - /// and records the sync in the WAL for undo/redo support. - /// [McpServerTool(Name = "sync_external_changes"), Description( - "Synchronize session with external file changes. This is the recommended way to handle " + - "external modifications as it:\n\n" + - "1. Reloads the document from disk\n" + + "Synchronize session with external file changes. This:\n\n" + + "1. Reloads the document from the source (local file or cloud)\n" + "2. Re-assigns all element IDs for consistency\n" + "3. Detects uncovered changes (headers, footers, images, styles, etc.)\n" + - "4. Records the sync in the edit history (supports undo)\n" + - "5. Optionally acknowledges a pending change\n\n" + - "Use this tool when you want to accept external changes and continue editing.")] + "4. Records the sync in the edit history (supports undo)\n\n" + + "Use this tool when you want to accept external changes and continue editing.\n" + + "This also clears any pending change gate, allowing edits to resume.")] public static string SyncExternalChanges( - ExternalChangeTracker tracker, - [Description("Session ID to sync")] - string doc_id, - [Description("Optional change ID to acknowledge (from get_external_changes)")] - string? change_id = null) + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, + [Description("Session ID to sync.")] + string doc_id) { - var syncResult = tracker.SyncExternalChanges(doc_id, change_id); - - var result = new JsonObject + try { - ["success"] = syncResult.Success, - ["has_changes"] = syncResult.HasChanges, - ["message"] = syncResult.Message - }; + var syncResult = PerformSync(tenant.Sessions, doc_id, isImport: false, + tenantId: tenant.TenantId, sync: sync); - if (syncResult.Summary is not null) - { - result["summary"] = BuildSummaryJson(syncResult.Summary); - } + // Clear pending state after sync (whether successful or not for "no changes") + if (syncResult.Success) + gate.ClearPending(tenant.TenantId, doc_id); - if (syncResult.UncoveredChanges is { Count: > 0 }) - { - var uncoveredArr = new JsonArray(); - foreach (var u in syncResult.UncoveredChanges) + var result = new JsonObject { - var uObj = new JsonObject - { - ["type"] = u.Type.ToString(), - ["description"] = u.Description, - ["change_kind"] = u.ChangeKind - }; - if (u.PartUri is not null) + ["success"] = syncResult.Success, + ["has_changes"] = syncResult.HasChanges, + ["message"] = syncResult.Message + }; + + if (syncResult.Summary is not null) + { + result["summary"] = BuildSummaryJson(syncResult.Summary); + } + + if (syncResult.UncoveredChanges is { Count: > 0 }) + { + var uncoveredArr = new JsonArray(); + foreach (var u in syncResult.UncoveredChanges) { - uObj["part_uri"] = u.PartUri; + var uObj = new JsonObject + { + ["type"] = u.Type.ToString(), + ["description"] = u.Description, + ["change_kind"] = u.ChangeKind + }; + if (u.PartUri is not null) + uObj["part_uri"] = u.PartUri; + uncoveredArr.Add((JsonNode?)uObj); } - uncoveredArr.Add((JsonNode?)uObj); + result["uncovered_changes"] = uncoveredArr; + } + + if (syncResult.WalPosition.HasValue) + result["wal_position"] = syncResult.WalPosition.Value; + + // Auto-save after sync + if (syncResult.Success && syncResult.HasChanges) + { + var session = tenant.Sessions.Get(doc_id); + sync.MaybeAutoSave(tenant.TenantId, doc_id, session.ToBytes()); } - result["uncovered_changes"] = uncoveredArr; + + return result.ToJsonString(JsonOptions); } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"syncing external changes for '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } + } - if (syncResult.WalPosition.HasValue) + /// + /// Core sync logic: reload from source (local or cloud), diff, re-assign IDs, create WAL entry. + /// + internal static SyncResult PerformSync(SessionManager sessions, string sessionId, bool isImport, + string? tenantId = null, SyncManager? sync = null) + { + try { - result["wal_position"] = syncResult.WalPosition.Value; - } + var session = sessions.Get(sessionId); + + // 1. Read external file — cloud or local + byte[]? newBytes = null; + if (sync != null && tenantId != null) + newBytes = sync.ReadSourceBytes(tenantId, sessionId, session.SourcePath); + else if (session.SourcePath != null && File.Exists(session.SourcePath)) + newBytes = File.ReadAllBytes(session.SourcePath); + + if (newBytes is null) + return SyncResult.Failure(session.SourcePath is null + ? "Session has no source path. Cannot sync." + : $"Source file not found: {session.SourcePath}"); + + var previousBytes = session.ToBytes(); + + // 2. Compute content hashes (ignoring IDs) for change detection + var previousContentHash = ContentHasher.ComputeContentHash(previousBytes); + var newContentHash = ContentHasher.ComputeContentHash(newBytes); + + if (previousContentHash == newContentHash) + return SyncResult.NoChanges(); + + // 3. Compute full byte hashes for WAL metadata + var previousHash = ComputeBytesHash(previousBytes); + var newHash = ComputeBytesHash(newBytes); + + // 4. Open new document and detect changes + List uncoveredChanges; + DiffResult diff; + + using (var newStream = new MemoryStream(newBytes)) + using (var newDoc = WordprocessingDocument.Open(newStream, isEditable: false)) + { + uncoveredChanges = DiffEngine.DetectUncoveredChanges(session.Document, newDoc); + diff = DiffEngine.Compare(previousBytes, newBytes); + } + + // 5. Create temporary session to re-assign IDs, then serialize + byte[] finalBytes; + using (var tempSession = DocxSession.FromBytes(newBytes, session.Id, session.SourcePath)) + { + ElementIdManager.EnsureNamespace(tempSession.Document); + ElementIdManager.EnsureAllIds(tempSession.Document); + finalBytes = tempSession.ToBytes(); + } + + // 6. Build WAL entry with full document snapshot + var walEntry = new WalEntry + { + EntryType = isImport ? WalEntryType.Import : WalEntryType.ExternalSync, + Timestamp = DateTime.UtcNow, + Patches = JsonSerializer.Serialize(diff.ToPatches(), DocxMcp.Models.DocxJsonContext.Default.ListJsonObject), + Description = BuildSyncDescription(diff.Summary, uncoveredChanges), + SyncMeta = new ExternalSyncMeta + { + SourcePath = session.SourcePath ?? "(cloud)", + PreviousHash = previousHash, + NewHash = newHash, + Summary = diff.Summary, + UncoveredChanges = uncoveredChanges, + DocumentSnapshot = finalBytes + } + }; + + // 7. Append to WAL + checkpoint + replace session + var walPosition = sessions.AppendExternalSync(sessionId, walEntry, finalBytes); - if (syncResult.AcknowledgedChangeId is not null) + return SyncResult.Synced(diff.Summary, uncoveredChanges, diff.ToPatches(), null, walPosition); + } + catch (Exception ex) { - result["acknowledged_change_id"] = syncResult.AcknowledgedChangeId; + return SyncResult.Failure($"Sync failed: {ex.Message}"); } - - return result.ToJsonString(JsonOptions); } - private static JsonObject BuildSummaryJson(Diff.DiffSummary summary) + private static JsonObject BuildSummaryJson(DiffSummary summary) { return new JsonObject { @@ -180,7 +270,7 @@ private static JsonObject BuildSummaryJson(Diff.DiffSummary summary) private static JsonArray BuildChangesJson(IReadOnlyList changes) { var arr = new JsonArray(); - foreach (var c in changes) + foreach (var c in changes.Take(20)) { var obj = new JsonObject { @@ -189,54 +279,48 @@ private static JsonArray BuildChangesJson(IReadOnlyList c ["description"] = c.Description }; if (c.OldText is not null) - { obj["old_text"] = c.OldText; - } if (c.NewText is not null) - { obj["new_text"] = c.NewText; - } arr.Add((JsonNode?)obj); } return arr; } - private static string BuildChangeMessage(ExternalChangePatch patch) + private static string BuildChangeMessage(PendingExternalChange pending) { - var lines = new List - { - "EXTERNAL CHANGES DETECTED", - "", - $"The file '{Path.GetFileName(patch.SourcePath)}' was modified externally.", - $"Detected at: {patch.DetectedAt:yyyy-MM-dd HH:mm:ss UTC}", - "", - "## Summary", - $" • Added: {patch.Summary.Added}", - $" • Removed: {patch.Summary.Removed}", - $" • Modified: {patch.Summary.Modified}", - $" • Moved: {patch.Summary.Moved}", - $" • Total: {patch.Summary.TotalChanges}", - "" - }; + return $"EXTERNAL CHANGES DETECTED\n\n" + + $"The file '{Path.GetFileName(pending.SourcePath)}' was modified externally.\n" + + $"Detected at: {pending.DetectedAt:yyyy-MM-dd HH:mm:ss UTC}\n\n" + + $"Summary: +{pending.Summary.Added} -{pending.Summary.Removed} ~{pending.Summary.Modified}\n\n" + + "Call get_external_changes with acknowledge=true to continue editing,\n" + + "or use sync_external_changes to reload the document and record in history."; + } + + private static string BuildSyncDescription(DiffSummary summary, List uncovered) + { + var parts = new List { "[EXTERNAL SYNC]" }; - if (patch.Changes.Count > 0) + if (summary.TotalChanges > 0) + parts.Add($"+{summary.Added} -{summary.Removed} ~{summary.Modified}"); + else + parts.Add("no body changes"); + + if (uncovered.Count > 0) { - lines.Add("## Changes"); - foreach (var change in patch.Changes.Take(15)) - { - lines.Add($" • {change.Description}"); - } - if (patch.Changes.Count > 15) - { - lines.Add($" • ... and {patch.Changes.Count - 15} more"); - } - lines.Add(""); + var types = uncovered + .Select(u => u.Type.ToString().ToLowerInvariant()) + .Distinct() + .Take(3); + parts.Add($"({uncovered.Count} uncovered: {string.Join(", ", types)})"); } - lines.Add("## Action Required"); - lines.Add("Call `get_external_changes` with `acknowledge=true` to continue editing,"); - lines.Add("or use `sync_external_changes` to reload the document and record in history."); + return string.Join(" ", parts); + } - return string.Join("\n", lines); + private static string ComputeBytesHash(byte[] bytes) + { + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); } } diff --git a/src/DocxMcp/Tools/HistoryTools.cs b/src/DocxMcp/Tools/HistoryTools.cs index a26a085..f8d27bc 100644 --- a/src/DocxMcp/Tools/HistoryTools.cs +++ b/src/DocxMcp/Tools/HistoryTools.cs @@ -1,5 +1,8 @@ using System.ComponentModel; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; +using DocxMcp.Helpers; namespace DocxMcp.Tools; @@ -11,12 +14,23 @@ public sealed class HistoryTools "Rebuilds the document from the nearest checkpoint. " + "The undone operations remain in history and can be redone.")] public static string DocumentUndo( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("Number of steps to undo (default 1).")] int steps = 1) { - var result = sessions.Undo(doc_id, steps); - return $"{result.Message}\nPosition: {result.Position}, Steps: {result.Steps}"; + try + { + var sessions = tenant.Sessions; + var result = sessions.Undo(doc_id, steps); + if (result.Steps > 0 && result.CurrentBytes is not null) + sync.MaybeAutoSave(tenant.TenantId, doc_id, result.CurrentBytes); + return $"{result.Message}\nPosition: {result.Position}, Steps: {result.Steps}"; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"undoing changes for '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "document_redo"), Description( @@ -24,12 +38,23 @@ public static string DocumentUndo( "Replays patches forward from the current position. " + "Only available after undo — new edits after undo discard redo history.")] public static string DocumentRedo( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("Number of steps to redo (default 1).")] int steps = 1) { - var result = sessions.Redo(doc_id, steps); - return $"{result.Message}\nPosition: {result.Position}, Steps: {result.Steps}"; + try + { + var sessions = tenant.Sessions; + var result = sessions.Redo(doc_id, steps); + if (result.Steps > 0 && result.CurrentBytes is not null) + sync.MaybeAutoSave(tenant.TenantId, doc_id, result.CurrentBytes); + return $"{result.Message}\nPosition: {result.Position}, Steps: {result.Steps}"; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"redoing changes for '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "document_history"), Description( @@ -38,42 +63,49 @@ public static string DocumentRedo( "Position 0 is the baseline (original document). " + "Supports pagination with offset and limit.")] public static string DocumentHistory( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Start offset for pagination (default 0).")] int offset = 0, [Description("Maximum number of entries to return (default 20).")] int limit = 20) { - var result = sessions.GetHistory(doc_id, offset, limit); - - var lines = new List - { - $"History for document '{doc_id}':", - $" Total entries: {result.TotalEntries}, Cursor: {result.CursorPosition}", - $" Can undo: {result.CanUndo}, Can redo: {result.CanRedo}", - "" - }; - - foreach (var entry in result.Entries) + try { - var marker = entry.IsCurrent ? " <-- current" : ""; - var ckpt = entry.IsCheckpoint ? " [checkpoint]" : ""; - var ts = entry.Timestamp != default ? entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss UTC") : "—"; + var result = tenant.Sessions.GetHistory(doc_id, offset, limit); - if (entry.IsExternalSync && entry.SyncSummary is not null) + var lines = new List { - var sync = entry.SyncSummary; - var uncoveredInfo = sync.UncoveredCount > 0 - ? $" ({sync.UncoveredCount} uncovered: {string.Join(", ", sync.UncoveredTypes.Take(3))})" - : ""; - lines.Add($" [{entry.Position}] {ts} | [EXTERNAL SYNC] +{sync.Added} -{sync.Removed} ~{sync.Modified}{uncoveredInfo}{ckpt}{marker}"); - } - else + $"History for document '{doc_id}':", + $" Total entries: {result.TotalEntries}, Cursor: {result.CursorPosition}", + $" Can undo: {result.CanUndo}, Can redo: {result.CanRedo}", + "" + }; + + foreach (var entry in result.Entries) { - lines.Add($" [{entry.Position}] {ts} | {entry.Description}{ckpt}{marker}"); + var marker = entry.IsCurrent ? " <-- current" : ""; + var ckpt = entry.IsCheckpoint ? " [checkpoint]" : ""; + var ts = entry.Timestamp != default ? entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss UTC") : "—"; + + if (entry.IsExternalSync && entry.SyncSummary is not null) + { + var sync = entry.SyncSummary; + var uncoveredInfo = sync.UncoveredCount > 0 + ? $" ({sync.UncoveredCount} uncovered: {string.Join(", ", sync.UncoveredTypes.Take(3))})" + : ""; + lines.Add($" [{entry.Position}] {ts} | [EXTERNAL SYNC] +{sync.Added} -{sync.Removed} ~{sync.Modified}{uncoveredInfo}{ckpt}{marker}"); + } + else + { + lines.Add($" [{entry.Position}] {ts} | {entry.Description}{ckpt}{marker}"); + } } - } - return string.Join("\n", lines); + return string.Join("\n", lines); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"loading history for '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "document_jump_to"), Description( @@ -81,11 +113,22 @@ public static string DocumentHistory( "Rebuilds the document from the nearest checkpoint. " + "Position 0 is the baseline, position N is after N patches applied.")] public static string DocumentJumpTo( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("WAL position to jump to (0 = baseline).")] int position) { - var result = sessions.JumpTo(doc_id, position); - return $"{result.Message}\nPosition: {result.Position}, Steps: {result.Steps}"; + try + { + var sessions = tenant.Sessions; + var result = sessions.JumpTo(doc_id, position); + if (result.Steps > 0 && result.CurrentBytes is not null) + sync.MaybeAutoSave(tenant.TenantId, doc_id, result.CurrentBytes); + return $"{result.Message}\nPosition: {result.Position}, Steps: {result.Steps}"; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"jumping to position for '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } } diff --git a/src/DocxMcp/Tools/PatchTool.cs b/src/DocxMcp/Tools/PatchTool.cs index 225ca11..1555d95 100644 --- a/src/DocxMcp/Tools/PatchTool.cs +++ b/src/DocxMcp/Tools/PatchTool.cs @@ -3,11 +3,13 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; +using DocxMcp.ExternalChanges; using DocxMcp.Helpers; using DocxMcp.Models; using DocxMcp.Paths; -using DocxMcp.ExternalChanges; using static DocxMcp.Helpers.ElementIdManager; namespace DocxMcp.Tools; @@ -22,29 +24,28 @@ public sealed class PatchTool /// Apply JSON patches to a document. Used internally by element tools and CLI. /// public static string ApplyPatch( - SessionManager sessions, - ExternalChangeTracker? externalChangeTracker, + TenantScope tenant, + SyncManager sync, + ExternalChangeGate gate, [Description("Session ID of the document.")] string doc_id, [Description("JSON array of patch operations (max 10 per call).")] string patches, [Description("If true, simulates operations without applying changes.")] bool dry_run = false) { - // Check for pending external changes that must be acknowledged first - if (externalChangeTracker is not null) + try { - var pendingChange = externalChangeTracker.GetLatestUnacknowledgedChange(doc_id); - if (pendingChange is not null) + // Check for pending external changes — block edits until acknowledged + if (!dry_run && gate.HasPendingChanges(tenant.TenantId, doc_id)) + { + return new PatchResult { - return new PatchResult - { - Success = false, - Error = $"External changes detected. {pendingChange.Summary.TotalChanges} change(s) " + - $"(+{pendingChange.Summary.Added} -{pendingChange.Summary.Removed} " + - $"~{pendingChange.Summary.Modified} ↔{pendingChange.Summary.Moved}). " + - $"Call get_external_changes with acknowledge=true to proceed." - }.ToJson(); - } + Success = false, + Error = "External changes detected. " + + "Call get_external_changes to review and acknowledge before editing, " + + "or use sync_external_changes to reload the document." + }.ToJson(); } + var sessions = tenant.Sessions; var session = sessions.Get(doc_id); var wpDoc = session.Document; var mainPart = wpDoc.MainDocumentPart @@ -155,7 +156,9 @@ public static string ApplyPatch( try { var walPatches = $"[{string.Join(",", succeededPatches)}]"; - sessions.AppendWal(doc_id, walPatches); + var bytes = session.ToBytes(); + sessions.AppendWal(doc_id, walPatches, null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); } catch { /* persistence is best-effort */ } } @@ -165,6 +168,11 @@ public static string ApplyPatch( : result.Applied == result.Total; return result.ToJson(); + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"applying patch to '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } private static string GetOpString(PatchOperation? operation, JsonElement element) diff --git a/src/DocxMcp/Tools/QueryTool.cs b/src/DocxMcp/Tools/QueryTool.cs index 978b715..65f47f9 100644 --- a/src/DocxMcp/Tools/QueryTool.cs +++ b/src/DocxMcp/Tools/QueryTool.cs @@ -5,6 +5,8 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; using DocxMcp.Paths; @@ -35,69 +37,76 @@ public sealed class QueryTool " /styles — style definitions\n\n" + "Every element has a stable 'id' field in JSON output. Use [id='...'] selectors for precise targeting.")] public static string Query( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Typed path to query (e.g. /body/paragraph[0], /body/table[0]). Prefer direct indexed access.")] string path, [Description("Output format: json, text, or summary. Default: json.")] string? format = "json", [Description("Number of elements to skip. Negative values count from the end (e.g. -10 = last 10 elements). Default: 0.")] int? offset = null, [Description("Maximum number of elements to return (1-50). Default: 50.")] int? limit = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; - - // Handle special paths - if (path is "/metadata" or "metadata") - return QueryMetadata(doc); - if (path is "/styles" or "styles") - return QueryStyles(doc); - if (path is "/body" or "body" or "/") - return QueryBodySummary(doc); - - var parsed = DocxPath.Parse(path); - var elements = PathResolver.Resolve(parsed, doc); - - // Apply pagination when multiple elements are returned - var totalCount = elements.Count; - if (totalCount > 1) + try { - var rawOffset = offset ?? 0; - // Negative offset counts from the end: -10 means start at (total - 10) - var effectiveOffset = rawOffset < 0 ? Math.Max(0, totalCount + rawOffset) : rawOffset; - var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + + // Handle special paths + if (path is "/metadata" or "metadata") + return QueryMetadata(doc); + if (path is "/styles" or "styles") + return QueryStyles(doc); + if (path is "/body" or "body" or "/") + return QueryBodySummary(doc); + + var parsed = DocxPath.Parse(path); + var elements = PathResolver.Resolve(parsed, doc); + + // Apply pagination when multiple elements are returned + var totalCount = elements.Count; + if (totalCount > 1) + { + var rawOffset = offset ?? 0; + // Negative offset counts from the end: -10 means start at (total - 10) + var effectiveOffset = rawOffset < 0 ? Math.Max(0, totalCount + rawOffset) : rawOffset; + var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); + + if (effectiveOffset >= totalCount) + return $"{{\"total\": {totalCount}, \"offset\": {effectiveOffset}, \"limit\": {effectiveLimit}, \"items\": []}}"; + + elements = elements + .Skip(effectiveOffset) + .Take(effectiveLimit) + .ToList(); + + // Wrap result with pagination metadata + var formatted = (format?.ToLowerInvariant() ?? "json") switch + { + "json" => FormatJsonArray(elements, doc), + "text" => FormatText(elements), + "summary" => FormatSummary(elements), + _ => FormatJsonArray(elements, doc) + }; - if (effectiveOffset >= totalCount) - return $"{{\"total\": {totalCount}, \"offset\": {effectiveOffset}, \"limit\": {effectiveLimit}, \"items\": []}}"; + if ((format?.ToLowerInvariant() ?? "json") == "json") + { + return $"{{\"total\": {totalCount}, \"offset\": {effectiveOffset}, \"limit\": {effectiveLimit}, " + + $"\"count\": {elements.Count}, \"items\": {formatted}}}"; + } - elements = elements - .Skip(effectiveOffset) - .Take(effectiveLimit) - .ToList(); + return $"[{elements.Count}/{totalCount} elements, offset {effectiveOffset}]\n{formatted}"; + } - // Wrap result with pagination metadata - var formatted = (format?.ToLowerInvariant() ?? "json") switch + return (format?.ToLowerInvariant() ?? "json") switch { - "json" => FormatJsonArray(elements, doc), + "json" => FormatJson(elements, doc), "text" => FormatText(elements), "summary" => FormatSummary(elements), - _ => FormatJsonArray(elements, doc) + _ => FormatJson(elements, doc) }; - - if ((format?.ToLowerInvariant() ?? "json") == "json") - { - return $"{{\"total\": {totalCount}, \"offset\": {effectiveOffset}, \"limit\": {effectiveLimit}, " + - $"\"count\": {elements.Count}, \"items\": {formatted}}}"; - } - - return $"[{elements.Count}/{totalCount} elements, offset {effectiveOffset}]\n{formatted}"; } - - return (format?.ToLowerInvariant() ?? "json") switch - { - "json" => FormatJson(elements, doc), - "text" => FormatText(elements), - "summary" => FormatSummary(elements), - _ => FormatJson(elements, doc) - }; + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"querying document '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } private static string QueryMetadata(WordprocessingDocument doc) diff --git a/src/DocxMcp/Tools/ReadHeadingContentTool.cs b/src/DocxMcp/Tools/ReadHeadingContentTool.cs index 44e1b82..838da71 100644 --- a/src/DocxMcp/Tools/ReadHeadingContentTool.cs +++ b/src/DocxMcp/Tools/ReadHeadingContentTool.cs @@ -4,6 +4,8 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; @@ -21,7 +23,7 @@ public sealed class ReadHeadingContentTool "and content element counts. Then call again targeting a specific heading.\n\n" + "Results are paginated: max 50 elements per call. Use offset to paginate within large heading blocks.")] public static string ReadHeadingContent( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Text to search for in heading content (case-insensitive partial match). " + "Omit to list all headings.")] string? heading_text = null, @@ -34,76 +36,83 @@ public static string ReadHeadingContent( [Description("Number of elements to skip. Negative values count from the end. Default: 0.")] int? offset = null, [Description("Maximum number of elements to return (1-50). Default: 50.")] int? limit = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; - var body = session.GetBody(); + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + var body = session.GetBody(); - var allChildren = body.ChildElements.Cast().ToList(); + var allChildren = body.ChildElements.Cast().ToList(); - // List mode: return heading hierarchy - if (heading_text is null && heading_index is null) - { - return ListHeadings(allChildren, heading_level); - } + // List mode: return heading hierarchy + if (heading_text is null && heading_index is null) + { + return ListHeadings(allChildren, heading_level); + } - // Find the target heading - var headingParagraph = FindHeading(allChildren, heading_text, heading_index, heading_level); - if (headingParagraph is null) - { - return heading_text is not null - ? $"Error: No heading found matching text '{heading_text}'" + - (heading_level.HasValue ? $" at level {heading_level.Value}" : "") + "." - : $"Error: Heading index {heading_index} out of range" + - (heading_level.HasValue ? $" at level {heading_level.Value}" : "") + "."; - } + // Find the target heading + var headingParagraph = FindHeading(allChildren, heading_text, heading_index, heading_level); + if (headingParagraph is null) + { + return heading_text is not null + ? $"Error: No heading found matching text '{heading_text}'" + + (heading_level.HasValue ? $" at level {heading_level.Value}" : "") + "." + : $"Error: Heading index {heading_index} out of range" + + (heading_level.HasValue ? $" at level {heading_level.Value}" : "") + "."; + } - // Collect content under this heading - var elements = CollectHeadingContent(allChildren, headingParagraph, include_sub_headings); - var totalCount = elements.Count; + // Collect content under this heading + var elements = CollectHeadingContent(allChildren, headingParagraph, include_sub_headings); + var totalCount = elements.Count; - // Apply pagination - var rawOffset = offset ?? 0; - var effectiveOffset = rawOffset < 0 ? Math.Max(0, totalCount + rawOffset) : rawOffset; - var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); + // Apply pagination + var rawOffset = offset ?? 0; + var effectiveOffset = rawOffset < 0 ? Math.Max(0, totalCount + rawOffset) : rawOffset; + var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); - if (effectiveOffset >= totalCount) - { - var headingInfo = BuildHeadingInfo(headingParagraph); - headingInfo["total"] = totalCount; - headingInfo["offset"] = effectiveOffset; - headingInfo["limit"] = effectiveLimit; - headingInfo["items"] = new JsonArray(); - return headingInfo.ToJsonString(JsonOpts); - } + if (effectiveOffset >= totalCount) + { + var headingInfo = BuildHeadingInfo(headingParagraph); + headingInfo["total"] = totalCount; + headingInfo["offset"] = effectiveOffset; + headingInfo["limit"] = effectiveLimit; + headingInfo["items"] = new JsonArray(); + return headingInfo.ToJsonString(JsonOpts); + } - var page = elements - .Skip(effectiveOffset) - .Take(effectiveLimit) - .ToList(); + var page = elements + .Skip(effectiveOffset) + .Take(effectiveLimit) + .ToList(); - var fmt = format?.ToLowerInvariant() ?? "json"; - var formatted = fmt switch - { - "json" => FormatJson(page, doc), - "text" => FormatText(page), - "summary" => FormatSummary(page), - _ => FormatJson(page, doc) - }; + var fmt = format?.ToLowerInvariant() ?? "json"; + var formatted = fmt switch + { + "json" => FormatJson(page, doc), + "text" => FormatText(page), + "summary" => FormatSummary(page), + _ => FormatJson(page, doc) + }; - if (fmt == "json") - { - var result = BuildHeadingInfo(headingParagraph); - result["total"] = totalCount; - result["offset"] = effectiveOffset; - result["limit"] = effectiveLimit; - result["count"] = page.Count; - result["items"] = JsonNode.Parse(formatted); - return result.ToJsonString(JsonOpts); - } + if (fmt == "json") + { + var result = BuildHeadingInfo(headingParagraph); + result["total"] = totalCount; + result["offset"] = effectiveOffset; + result["limit"] = effectiveLimit; + result["count"] = page.Count; + result["items"] = JsonNode.Parse(formatted); + return result.ToJsonString(JsonOpts); + } - var headingLevel = headingParagraph.GetHeadingLevel(); - var headingTextValue = headingParagraph.InnerText; - return $"[Heading {headingLevel}: \"{headingTextValue}\" — {page.Count}/{totalCount} elements, offset {effectiveOffset}]\n{formatted}"; + var headingLevel = headingParagraph.GetHeadingLevel(); + var headingTextValue = headingParagraph.InnerText; + return $"[Heading {headingLevel}: \"{headingTextValue}\" — {page.Count}/{totalCount} elements, offset {effectiveOffset}]\n{formatted}"; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"reading heading content in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } private static JsonObject BuildHeadingInfo(Paragraph heading) diff --git a/src/DocxMcp/Tools/ReadSectionTool.cs b/src/DocxMcp/Tools/ReadSectionTool.cs index 8aa43ae..bdc8f6a 100644 --- a/src/DocxMcp/Tools/ReadSectionTool.cs +++ b/src/DocxMcp/Tools/ReadSectionTool.cs @@ -4,6 +4,8 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; @@ -21,62 +23,69 @@ public sealed class ReadSectionTool "Then call again with a specific section_index to read its content.\n\n" + "Results are paginated: max 50 elements per call. Use offset to paginate within large sections.")] public static string ReadSection( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Zero-based section index. Omit or use -1 to list all sections.")] int? section_index = null, [Description("Output format: json, text, or summary. Default: json.")] string? format = "json", [Description("Number of elements to skip. Negative values count from the end (e.g. -10 = last 10 elements). Default: 0.")] int? offset = null, [Description("Maximum number of elements to return (1-50). Default: 50.")] int? limit = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; - var body = doc.MainDocumentPart?.Document?.Body - ?? throw new InvalidOperationException("Document has no body."); + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + var body = doc.MainDocumentPart?.Document?.Body + ?? throw new InvalidOperationException("Document has no body."); - var sections = BuildSections(body); + var sections = BuildSections(body); - // List mode: return section overview - if (section_index is null or -1) - { - return ListSections(sections); - } + // List mode: return section overview + if (section_index is null or -1) + { + return ListSections(sections); + } - var idx = section_index.Value; - if (idx < 0 || idx >= sections.Count) - return $"Error: Section index {idx} out of range. Document has {sections.Count} section(s) (0..{sections.Count - 1})."; + var idx = section_index.Value; + if (idx < 0 || idx >= sections.Count) + return $"Error: Section index {idx} out of range. Document has {sections.Count} section(s) (0..{sections.Count - 1})."; - var sectionElements = sections[idx].Elements; - var totalCount = sectionElements.Count; + var sectionElements = sections[idx].Elements; + var totalCount = sectionElements.Count; - var rawOffset = offset ?? 0; - // Negative offset counts from the end: -10 means start at (total - 10) - var effectiveOffset = rawOffset < 0 ? Math.Max(0, totalCount + rawOffset) : rawOffset; - var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); + var rawOffset = offset ?? 0; + // Negative offset counts from the end: -10 means start at (total - 10) + var effectiveOffset = rawOffset < 0 ? Math.Max(0, totalCount + rawOffset) : rawOffset; + var effectiveLimit = Math.Clamp(limit ?? 50, 1, 50); - if (effectiveOffset >= totalCount) - return $"{{\"section\": {idx}, \"total\": {totalCount}, \"offset\": {effectiveOffset}, \"limit\": {effectiveLimit}, \"items\": []}}"; + if (effectiveOffset >= totalCount) + return $"{{\"section\": {idx}, \"total\": {totalCount}, \"offset\": {effectiveOffset}, \"limit\": {effectiveLimit}, \"items\": []}}"; - var page = sectionElements - .Skip(effectiveOffset) - .Take(effectiveLimit) - .ToList(); + var page = sectionElements + .Skip(effectiveOffset) + .Take(effectiveLimit) + .ToList(); - var fmt = format?.ToLowerInvariant() ?? "json"; - var formatted = fmt switch - { - "json" => FormatJson(page, doc), - "text" => FormatText(page), - "summary" => FormatSummary(page), - _ => FormatJson(page, doc) - }; + var fmt = format?.ToLowerInvariant() ?? "json"; + var formatted = fmt switch + { + "json" => FormatJson(page, doc), + "text" => FormatText(page), + "summary" => FormatSummary(page), + _ => FormatJson(page, doc) + }; - if (fmt == "json") - { - return $"{{\"section\": {idx}, \"total\": {totalCount}, \"offset\": {effectiveOffset}, " + - $"\"limit\": {effectiveLimit}, \"count\": {page.Count}, \"items\": {formatted}}}"; - } + if (fmt == "json") + { + return $"{{\"section\": {idx}, \"total\": {totalCount}, \"offset\": {effectiveOffset}, " + + $"\"limit\": {effectiveLimit}, \"count\": {page.Count}, \"items\": {formatted}}}"; + } - return $"[Section {idx}: {page.Count}/{totalCount} elements, offset {effectiveOffset}]\n{formatted}"; + return $"[Section {idx}: {page.Count}/{totalCount} elements, offset {effectiveOffset}]\n{formatted}"; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"reading section in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } private record SectionInfo(int Index, List Elements, string? FirstHeading); diff --git a/src/DocxMcp/Tools/RevisionTools.cs b/src/DocxMcp/Tools/RevisionTools.cs index 8275ba9..3312d86 100644 --- a/src/DocxMcp/Tools/RevisionTools.cs +++ b/src/DocxMcp/Tools/RevisionTools.cs @@ -2,6 +2,8 @@ using System.Text.Json; using System.Text.Json.Nodes; using DocumentFormat.OpenXml.Packaging; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; @@ -17,57 +19,64 @@ public sealed class RevisionTools "Revision types: insertion, deletion, move_from, move_to, format_change, " + "paragraph_insertion, section_change, table_change, row_change, cell_change")] public static string RevisionList( - SessionManager sessions, + TenantScope tenant, [Description("Session ID of the document.")] string doc_id, [Description("Filter by author name (case-insensitive).")] string? author = null, [Description("Filter by revision type.")] string? type = null, [Description("Number of revisions to skip. Default: 0.")] int? offset = null, [Description("Maximum number of revisions to return (1-100). Default: 50.")] int? limit = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - var stats = RevisionHelper.GetRevisionStats(doc); - var revisions = RevisionHelper.ListRevisions(doc, author, type); - var total = revisions.Count; + var stats = RevisionHelper.GetRevisionStats(doc); + var revisions = RevisionHelper.ListRevisions(doc, author, type); + var total = revisions.Count; - var effectiveOffset = Math.Max(0, offset ?? 0); - var effectiveLimit = Math.Clamp(limit ?? 50, 1, 100); + var effectiveOffset = Math.Max(0, offset ?? 0); + var effectiveLimit = Math.Clamp(limit ?? 50, 1, 100); - var page = revisions - .Skip(effectiveOffset) - .Take(effectiveLimit) - .ToList(); + var page = revisions + .Skip(effectiveOffset) + .Take(effectiveLimit) + .ToList(); - var arr = new JsonArray(); - foreach (var r in page) - { - var obj = new JsonObject + var arr = new JsonArray(); + foreach (var r in page) + { + var obj = new JsonObject + { + ["id"] = r.Id, + ["type"] = r.Type, + ["author"] = r.Author, + ["date"] = r.Date?.ToString("o"), + ["content"] = r.Content + }; + + if (r.ElementId is not null) + obj["element_id"] = r.ElementId; + + arr.Add((JsonNode)obj); + } + + var result = new JsonObject { - ["id"] = r.Id, - ["type"] = r.Type, - ["author"] = r.Author, - ["date"] = r.Date?.ToString("o"), - ["content"] = r.Content + ["track_changes_enabled"] = stats.TrackChangesEnabled, + ["total"] = total, + ["offset"] = effectiveOffset, + ["limit"] = effectiveLimit, + ["count"] = page.Count, + ["revisions"] = arr }; - if (r.ElementId is not null) - obj["element_id"] = r.ElementId; - - arr.Add((JsonNode)obj); + return result.ToJsonString(JsonOpts); } - - var result = new JsonObject - { - ["track_changes_enabled"] = stats.TrackChangesEnabled, - ["total"] = total, - ["offset"] = effectiveOffset, - ["limit"] = effectiveLimit, - ["count"] = page.Count, - ["revisions"] = arr - }; - - return result.ToJsonString(JsonOpts); + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"listing revisions in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "revision_accept"), Description( @@ -78,26 +87,36 @@ public static string RevisionList( "- Format changes: new formatting is kept\n" + "- Moves: content stays at new location")] public static string RevisionAccept( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("Revision ID to accept.")] int revision_id) { - var session = sessions.Get(doc_id); - var doc = session.Document; + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - if (!RevisionHelper.AcceptRevision(doc, revision_id)) - return $"Error: Revision {revision_id} not found."; + if (!RevisionHelper.AcceptRevision(doc, revision_id)) + return $"Error: Revision {revision_id} not found."; - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "accept_revision", - ["revision_id"] = revision_id - }; - var walEntry = new JsonArray { (JsonNode)walObj }; - sessions.AppendWal(doc_id, walEntry.ToJsonString()); + // Append to WAL + var walObj = new JsonObject + { + ["op"] = "accept_revision", + ["revision_id"] = revision_id + }; + var walEntry = new JsonArray { (JsonNode)walObj }; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); - return $"Accepted revision {revision_id}."; + return $"Accepted revision {revision_id}."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"accepting revision in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "revision_reject"), Description( @@ -108,26 +127,36 @@ public static string RevisionAccept( "- Format changes: previous formatting is restored\n" + "- Moves: content returns to original location")] public static string RevisionReject( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("Revision ID to reject.")] int revision_id) { - var session = sessions.Get(doc_id); - var doc = session.Document; + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - if (!RevisionHelper.RejectRevision(doc, revision_id)) - return $"Error: Revision {revision_id} not found."; + if (!RevisionHelper.RejectRevision(doc, revision_id)) + return $"Error: Revision {revision_id} not found."; - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "reject_revision", - ["revision_id"] = revision_id - }; - var walEntry = new JsonArray { (JsonNode)walObj }; - sessions.AppendWal(doc_id, walEntry.ToJsonString()); + // Append to WAL + var walObj = new JsonObject + { + ["op"] = "reject_revision", + ["revision_id"] = revision_id + }; + var walEntry = new JsonArray { (JsonNode)walObj }; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); - return $"Rejected revision {revision_id}."; + return $"Rejected revision {revision_id}."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"rejecting revision in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "track_changes_enable"), Description( @@ -135,27 +164,37 @@ public static string RevisionReject( "When enabled, subsequent edits made in Word will be tracked.\n" + "Note: Edits made through this MCP server are not automatically tracked.")] public static string TrackChangesEnable( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("True to enable, false to disable Track Changes.")] bool enabled) { - var session = sessions.Get(doc_id); - var doc = session.Document; + try + { + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; - RevisionHelper.SetTrackChangesEnabled(doc, enabled); + RevisionHelper.SetTrackChangesEnabled(doc, enabled); - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "track_changes_enable", - ["enabled"] = enabled - }; - var walEntry = new JsonArray { (JsonNode)walObj }; - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - - return enabled - ? "Track Changes enabled. Edits made in Word will be tracked." - : "Track Changes disabled."; + // Append to WAL + var walObj = new JsonObject + { + ["op"] = "track_changes_enable", + ["enabled"] = enabled + }; + var walEntry = new JsonArray { (JsonNode)walObj }; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); + + return enabled + ? "Track Changes enabled. Edits made in Word will be tracked." + : "Track Changes disabled."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"toggling track changes in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } // --- WAL Replay Methods --- diff --git a/src/DocxMcp/Tools/StyleTools.cs b/src/DocxMcp/Tools/StyleTools.cs index 3c65342..dd5f288 100644 --- a/src/DocxMcp/Tools/StyleTools.cs +++ b/src/DocxMcp/Tools/StyleTools.cs @@ -4,6 +4,8 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using Grpc.Core; +using ModelContextProtocol; using ModelContextProtocol.Server; using DocxMcp.Helpers; using DocxMcp.Paths; @@ -27,84 +29,94 @@ public sealed class StyleTools "Use [id='...'] for stable targeting (e.g. /body/paragraph[id='1A2B3C4D']/run[id='5E6F7A8B']).\n" + "Use [*] wildcards for batch operations (e.g. /body/paragraph[*]).")] public static string StyleElement( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("JSON object of run-level style properties to merge.")] string style, [Description("Optional typed path. Omit to style all runs in the document.")] string? path = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; - var body = doc.MainDocumentPart?.Document?.Body - ?? throw new InvalidOperationException("Document has no body."); - - JsonElement styleEl; try { - styleEl = JsonDocument.Parse(style).RootElement; - } - catch (JsonException ex) - { - return $"Error: Invalid style JSON — {ex.Message}"; - } - - if (styleEl.ValueKind != JsonValueKind.Object) - return "Error: style must be a JSON object."; + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + var body = doc.MainDocumentPart?.Document?.Body + ?? throw new InvalidOperationException("Document has no body."); - List runs; - if (path is null) - { - runs = body.Descendants().ToList(); - } - else - { - List elements; + JsonElement styleEl; try { - var parsed = DocxPath.Parse(path); - elements = PathResolver.Resolve(parsed, doc); + styleEl = JsonDocument.Parse(style).RootElement; } - catch (Exception ex) + catch (JsonException ex) { - return $"Error: {ex.Message}"; + return $"Error: Invalid style JSON — {ex.Message}"; } - runs = new List(); - foreach (var el in elements) + if (styleEl.ValueKind != JsonValueKind.Object) + return "Error: style must be a JSON object."; + + List runs; + if (path is null) { - runs.AddRange(StyleHelper.CollectRuns(el)); + runs = body.Descendants().ToList(); } - } + else + { + List elements; + try + { + var parsed = DocxPath.Parse(path); + elements = PathResolver.Resolve(parsed, doc); + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } - if (runs.Count == 0) - return "No runs found to style."; + runs = new List(); + foreach (var el in elements) + { + runs.AddRange(StyleHelper.CollectRuns(el)); + } + } - var trackChanges = RevisionHelper.IsTrackChangesEnabled(doc); + if (runs.Count == 0) + return "No runs found to style."; - foreach (var run in runs) - { - if (trackChanges) + var trackChanges = RevisionHelper.IsTrackChangesEnabled(doc); + + foreach (var run in runs) { - // Create RunProperties from style JSON and apply with tracking - var newProps = ElementFactory.CreateRunProperties(styleEl); - RevisionHelper.ApplyRunPropertiesWithTracking(doc, run, newProps); + if (trackChanges) + { + // Create RunProperties from style JSON and apply with tracking + var newProps = ElementFactory.CreateRunProperties(styleEl); + RevisionHelper.ApplyRunPropertiesWithTracking(doc, run, newProps); + } + else + { + StyleHelper.MergeRunProperties(run, styleEl); + } } - else + + // Append to WAL + var walObj = new JsonObject { - StyleHelper.MergeRunProperties(run, styleEl); - } + ["op"] = "style_element", + ["path"] = path, + ["style"] = JsonNode.Parse(style) + }; + var walEntry = new JsonArray { (JsonNode)walObj }; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); + + return $"Styled {runs.Count} run(s)."; } - - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "style_element", - ["path"] = path, - ["style"] = JsonNode.Parse(style) - }; - var walEntry = new JsonArray { (JsonNode)walObj }; - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - - return $"Styled {runs.Count} run(s)."; + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"styling element in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "style_paragraph"), Description( @@ -122,84 +134,94 @@ public static string StyleElement( "Use [id='...'] for stable targeting (e.g. /body/paragraph[id='1A2B3C4D']).\n" + "Use [*] wildcards for batch operations.")] public static string StyleParagraph( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("JSON object of paragraph-level style properties to merge.")] string style, [Description("Optional typed path. Omit to style all paragraphs in the document.")] string? path = null) { - var session = sessions.Get(doc_id); - var doc = session.Document; - var body = doc.MainDocumentPart?.Document?.Body - ?? throw new InvalidOperationException("Document has no body."); - - JsonElement styleEl; try { - styleEl = JsonDocument.Parse(style).RootElement; - } - catch (JsonException ex) - { - return $"Error: Invalid style JSON — {ex.Message}"; - } + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + var body = doc.MainDocumentPart?.Document?.Body + ?? throw new InvalidOperationException("Document has no body."); - if (styleEl.ValueKind != JsonValueKind.Object) - return "Error: style must be a JSON object."; - - List paragraphs; - if (path is null) - { - paragraphs = body.Descendants().ToList(); - } - else - { - List elements; + JsonElement styleEl; try { - var parsed = DocxPath.Parse(path); - elements = PathResolver.Resolve(parsed, doc); + styleEl = JsonDocument.Parse(style).RootElement; } - catch (Exception ex) + catch (JsonException ex) { - return $"Error: {ex.Message}"; + return $"Error: Invalid style JSON — {ex.Message}"; } - paragraphs = new List(); - foreach (var el in elements) + if (styleEl.ValueKind != JsonValueKind.Object) + return "Error: style must be a JSON object."; + + List paragraphs; + if (path is null) { - paragraphs.AddRange(StyleHelper.CollectParagraphs(el)); + paragraphs = body.Descendants().ToList(); } - } + else + { + List elements; + try + { + var parsed = DocxPath.Parse(path); + elements = PathResolver.Resolve(parsed, doc); + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } - if (paragraphs.Count == 0) - return "No paragraphs found to style."; + paragraphs = new List(); + foreach (var el in elements) + { + paragraphs.AddRange(StyleHelper.CollectParagraphs(el)); + } + } - var trackChanges = RevisionHelper.IsTrackChangesEnabled(doc); + if (paragraphs.Count == 0) + return "No paragraphs found to style."; - foreach (var para in paragraphs) - { - if (trackChanges) + var trackChanges = RevisionHelper.IsTrackChangesEnabled(doc); + + foreach (var para in paragraphs) { - // Create ParagraphProperties from style JSON and apply with tracking - var newProps = ElementFactory.CreateParagraphProperties(styleEl); - RevisionHelper.ApplyParagraphPropertiesWithTracking(doc, para, newProps); + if (trackChanges) + { + // Create ParagraphProperties from style JSON and apply with tracking + var newProps = ElementFactory.CreateParagraphProperties(styleEl); + RevisionHelper.ApplyParagraphPropertiesWithTracking(doc, para, newProps); + } + else + { + StyleHelper.MergeParagraphProperties(para, styleEl); + } } - else + + // Append to WAL + var walObj = new JsonObject { - StyleHelper.MergeParagraphProperties(para, styleEl); - } + ["op"] = "style_paragraph", + ["path"] = path, + ["style"] = JsonNode.Parse(style) + }; + var walEntry = new JsonArray { (JsonNode)walObj }; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); + + return $"Styled {paragraphs.Count} paragraph(s)."; } - - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "style_paragraph", - ["path"] = path, - ["style"] = JsonNode.Parse(style) - }; - var walEntry = new JsonArray { (JsonNode)walObj }; - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - - return $"Styled {paragraphs.Count} paragraph(s)."; + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"styling paragraph in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } [McpServerTool(Name = "style_table"), Description( @@ -219,118 +241,128 @@ public static string StyleParagraph( "Omit path to style ALL tables in the document.\n" + "Use [id='...'] for stable targeting (e.g. /body/table[id='1A2B3C4D']).")] public static string StyleTable( - SessionManager sessions, + TenantScope tenant, + SyncManager sync, [Description("Session ID of the document.")] string doc_id, [Description("JSON object of table-level style properties to merge.")] string? style = null, [Description("JSON object of cell-level style properties to merge (applied to ALL cells).")] string? cell_style = null, [Description("JSON object of row-level style properties to merge (applied to ALL rows).")] string? row_style = null, [Description("Optional typed path. Omit to style all tables in the document.")] string? path = null) { - if (style is null && cell_style is null && row_style is null) - return "Error: At least one of style, cell_style, or row_style must be provided."; - - var session = sessions.Get(doc_id); - var doc = session.Document; - var body = doc.MainDocumentPart?.Document?.Body - ?? throw new InvalidOperationException("Document has no body."); - - JsonElement? styleEl = null, cellStyleEl = null, rowStyleEl = null; try { - if (style is not null) - { - var parsed = JsonDocument.Parse(style).RootElement; - if (parsed.ValueKind != JsonValueKind.Object) - return "Error: style must be a JSON object."; - styleEl = parsed; - } - if (cell_style is not null) - { - var parsed = JsonDocument.Parse(cell_style).RootElement; - if (parsed.ValueKind != JsonValueKind.Object) - return "Error: cell_style must be a JSON object."; - cellStyleEl = parsed; - } - if (row_style is not null) - { - var parsed = JsonDocument.Parse(row_style).RootElement; - if (parsed.ValueKind != JsonValueKind.Object) - return "Error: row_style must be a JSON object."; - rowStyleEl = parsed; - } - } - catch (JsonException ex) - { - return $"Error: Invalid JSON — {ex.Message}"; - } + if (style is null && cell_style is null && row_style is null) + return "Error: At least one of style, cell_style, or row_style must be provided."; - List
    tables; - if (path is null) - { - tables = body.Descendants
    ().ToList(); - } - else - { - List elements; + var session = tenant.Sessions.Get(doc_id); + var doc = session.Document; + var body = doc.MainDocumentPart?.Document?.Body + ?? throw new InvalidOperationException("Document has no body."); + + JsonElement? styleEl = null, cellStyleEl = null, rowStyleEl = null; try { - var parsed = DocxPath.Parse(path); - elements = PathResolver.Resolve(parsed, doc); + if (style is not null) + { + var parsed = JsonDocument.Parse(style).RootElement; + if (parsed.ValueKind != JsonValueKind.Object) + return "Error: style must be a JSON object."; + styleEl = parsed; + } + if (cell_style is not null) + { + var parsed = JsonDocument.Parse(cell_style).RootElement; + if (parsed.ValueKind != JsonValueKind.Object) + return "Error: cell_style must be a JSON object."; + cellStyleEl = parsed; + } + if (row_style is not null) + { + var parsed = JsonDocument.Parse(row_style).RootElement; + if (parsed.ValueKind != JsonValueKind.Object) + return "Error: row_style must be a JSON object."; + rowStyleEl = parsed; + } } - catch (Exception ex) + catch (JsonException ex) { - return $"Error: {ex.Message}"; + return $"Error: Invalid JSON — {ex.Message}"; } - tables = new List
    (); - foreach (var el in elements) + List
    tables; + if (path is null) { - tables.AddRange(StyleHelper.CollectTables(el)); + tables = body.Descendants
    ().ToList(); } - } + else + { + List elements; + try + { + var parsed = DocxPath.Parse(path); + elements = PathResolver.Resolve(parsed, doc); + } + catch (Exception ex) + { + return $"Error: {ex.Message}"; + } - if (tables.Count == 0) - return "No tables found to style."; + tables = new List
    (); + foreach (var el in elements) + { + tables.AddRange(StyleHelper.CollectTables(el)); + } + } - foreach (var table in tables) - { - if (styleEl.HasValue) - StyleHelper.MergeTableProperties(table, styleEl.Value); + if (tables.Count == 0) + return "No tables found to style."; - if (cellStyleEl.HasValue) + foreach (var table in tables) { - foreach (var cell in table.Descendants()) + if (styleEl.HasValue) + StyleHelper.MergeTableProperties(table, styleEl.Value); + + if (cellStyleEl.HasValue) { - StyleHelper.MergeTableCellProperties(cell, cellStyleEl.Value); + foreach (var cell in table.Descendants()) + { + StyleHelper.MergeTableCellProperties(cell, cellStyleEl.Value); + } } - } - if (rowStyleEl.HasValue) - { - foreach (var row in table.Elements()) + if (rowStyleEl.HasValue) { - StyleHelper.MergeTableRowProperties(row, rowStyleEl.Value); + foreach (var row in table.Elements()) + { + StyleHelper.MergeTableRowProperties(row, rowStyleEl.Value); + } } } - } - // Append to WAL - var walObj = new JsonObject - { - ["op"] = "style_table", - ["path"] = path - }; - if (style is not null) - walObj["style"] = JsonNode.Parse(style); - if (cell_style is not null) - walObj["cell_style"] = JsonNode.Parse(cell_style); - if (row_style is not null) - walObj["row_style"] = JsonNode.Parse(row_style); - - var walEntry = new JsonArray { (JsonNode)walObj }; - sessions.AppendWal(doc_id, walEntry.ToJsonString()); - - return $"Styled {tables.Count} table(s)."; + // Append to WAL + var walObj = new JsonObject + { + ["op"] = "style_table", + ["path"] = path + }; + if (style is not null) + walObj["style"] = JsonNode.Parse(style); + if (cell_style is not null) + walObj["cell_style"] = JsonNode.Parse(cell_style); + if (row_style is not null) + walObj["row_style"] = JsonNode.Parse(row_style); + + var walEntry = new JsonArray { (JsonNode)walObj }; + var bytes = session.ToBytes(); + tenant.Sessions.AppendWal(doc_id, walEntry.ToJsonString(), null, bytes); + sync.MaybeAutoSave(tenant.TenantId, doc_id, bytes); + + return $"Styled {tables.Count} table(s)."; + } + catch (RpcException ex) { throw GrpcErrorHelper.Wrap(ex, $"styling table in '{doc_id}'"); } + catch (KeyNotFoundException) { throw GrpcErrorHelper.WrapNotFound(doc_id); } + catch (McpException) { throw; } + catch (Exception ex) { throw new McpException(ex.Message, ex); } } // --- Replay methods for WAL --- diff --git a/tests/DocxMcp.Tests/AutoSaveTests.cs b/tests/DocxMcp.Tests/AutoSaveTests.cs index 3c2a3f2..0d12dbe 100644 --- a/tests/DocxMcp.Tests/AutoSaveTests.cs +++ b/tests/DocxMcp.Tests/AutoSaveTests.cs @@ -2,7 +2,6 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocxMcp.ExternalChanges; -using DocxMcp.Persistence; using DocxMcp.Tools; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -13,7 +12,6 @@ public class AutoSaveTests : IDisposable { private readonly string _tempDir; private readonly string _tempFile; - private readonly SessionStore _store; public AutoSaveTests() { @@ -22,32 +20,27 @@ public AutoSaveTests() _tempFile = Path.Combine(_tempDir, "test.docx"); CreateTestDocx(_tempFile, "Original content"); - - var sessionsDir = Path.Combine(_tempDir, "sessions"); - _store = new SessionStore(NullLogger.Instance, sessionsDir); } public void Dispose() { - _store.Dispose(); if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } - private SessionManager CreateManager() - { - var mgr = new SessionManager(_store, NullLogger.Instance); - var tracker = new ExternalChangeTracker(mgr, NullLogger.Instance); - mgr.SetExternalChangeTracker(tracker); - return mgr; - } + private SessionManager CreateManager() => TestHelpers.CreateSessionManager(); + private SyncManager CreateSyncManager() => TestHelpers.CreateSyncManager(); [Fact] - public void AppendWal_AutoSavesFileOnDisk() + public void AppendWal_WithAutoSave_SavesFileOnDisk() { var mgr = CreateManager(); + var sync = CreateSyncManager(); var session = mgr.Open(_tempFile); + // Register source for auto-save (caller-orchestrated) + sync.RegisterAndWatch(mgr.TenantId, session.Id, _tempFile, autoSync: true); + // Record original file bytes var originalBytes = File.ReadAllBytes(_tempFile); @@ -55,9 +48,11 @@ public void AppendWal_AutoSavesFileOnDisk() var body = session.Document.MainDocumentPart!.Document!.Body!; body.AppendChild(new Paragraph(new Run(new Text("Added paragraph")))); - // Append WAL triggers auto-save + // Append WAL then auto-save (caller-orchestrated pattern) + var currentBytes = session.ToBytes(); mgr.AppendWal(session.Id, - "[{\"op\":\"add\",\"path\":\"/body/children/-1\",\"value\":{\"type\":\"paragraph\",\"text\":\"Added paragraph\"}}]"); + "[{\"op\":\"add\",\"path\":\"/body/children/-1\",\"value\":{\"type\":\"paragraph\",\"text\":\"Added paragraph\"}}]", null, currentBytes); + sync.MaybeAutoSave(mgr.TenantId, session.Id, currentBytes); // File on disk should have changed var newBytes = File.ReadAllBytes(_tempFile); @@ -75,12 +70,13 @@ public void AppendWal_AutoSavesFileOnDisk() public void DryRun_DoesNotTriggerAutoSave() { var mgr = CreateManager(); + var sync = CreateSyncManager(); var session = mgr.Open(_tempFile); var originalBytes = File.ReadAllBytes(_tempFile); // Apply patch with dry_run — this skips AppendWal entirely - PatchTool.ApplyPatch(mgr, null, session.Id, + PatchTool.ApplyPatch(mgr, sync, TestHelpers.CreateExternalChangeGate(), session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/-1\",\"value\":{\"type\":\"paragraph\",\"text\":\"Dry run\"}}]", dry_run: true); @@ -92,16 +88,21 @@ public void DryRun_DoesNotTriggerAutoSave() public void NewDocument_NoSourcePath_NoException() { var mgr = CreateManager(); + var sync = CreateSyncManager(); var session = mgr.Create(); // Mutate in-memory var body = session.Document.MainDocumentPart!.Document!.Body!; body.AppendChild(new Paragraph(new Run(new Text("New content")))); - // AppendWal should not throw even though there's no source path + // AppendWal + MaybeAutoSave should not throw even though there's no source path var ex = Record.Exception(() => + { + var currentBytes = session.ToBytes(); mgr.AppendWal(session.Id, - "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"New content\"}}]")); + "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"New content\"}}]", null, currentBytes); + sync.MaybeAutoSave(mgr.TenantId, session.Id, currentBytes); + }); Assert.Null(ex); } @@ -115,25 +116,25 @@ public void AutoSaveDisabled_FileUnchanged() { Environment.SetEnvironmentVariable("DOCX_AUTO_SAVE", "false"); - var store2 = new SessionStore(NullLogger.Instance, - Path.Combine(_tempDir, "sessions-disabled")); - var mgr = new SessionManager(store2, NullLogger.Instance); - var tracker = new ExternalChangeTracker(mgr, NullLogger.Instance); - mgr.SetExternalChangeTracker(tracker); - + var mgr = CreateManager(); + var sync = CreateSyncManager(); var session = mgr.Open(_tempFile); + + // Register source + sync.RegisterAndWatch(mgr.TenantId, session.Id, _tempFile, autoSync: true); + var originalBytes = File.ReadAllBytes(_tempFile); - // Mutate and append WAL + // Mutate and append WAL + try auto-save var body = session.Document.MainDocumentPart!.Document!.Body!; body.AppendChild(new Paragraph(new Run(new Text("Should not save")))); + var currentBytes = session.ToBytes(); mgr.AppendWal(session.Id, - "[{\"op\":\"add\",\"path\":\"/body/children/-1\",\"value\":{\"type\":\"paragraph\",\"text\":\"Should not save\"}}]"); + "[{\"op\":\"add\",\"path\":\"/body/children/-1\",\"value\":{\"type\":\"paragraph\",\"text\":\"Should not save\"}}]", null, currentBytes); + sync.MaybeAutoSave(mgr.TenantId, session.Id, currentBytes); var afterBytes = File.ReadAllBytes(_tempFile); Assert.Equal(originalBytes, afterBytes); - - store2.Dispose(); } finally { @@ -145,12 +146,16 @@ public void AutoSaveDisabled_FileUnchanged() public void StyleOperation_TriggersAutoSave() { var mgr = CreateManager(); + var sync = CreateSyncManager(); var session = mgr.Open(_tempFile); + // Register source for auto-save + sync.RegisterAndWatch(mgr.TenantId, session.Id, _tempFile, autoSync: true); + var originalBytes = File.ReadAllBytes(_tempFile); - // Apply style (this calls AppendWal internally) - StyleTools.StyleElement(mgr, session.Id, "{\"bold\": true}", "/body/paragraph[0]"); + // Apply style (tool calls sync.MaybeAutoSave internally) + StyleTools.StyleElement(mgr, sync, session.Id, "{\"bold\": true}", "/body/paragraph[0]"); var afterBytes = File.ReadAllBytes(_tempFile); Assert.NotEqual(originalBytes, afterBytes); @@ -160,12 +165,16 @@ public void StyleOperation_TriggersAutoSave() public void CommentAdd_TriggersAutoSave() { var mgr = CreateManager(); + var sync = CreateSyncManager(); var session = mgr.Open(_tempFile); + // Register source for auto-save + sync.RegisterAndWatch(mgr.TenantId, session.Id, _tempFile, autoSync: true); + var originalBytes = File.ReadAllBytes(_tempFile); - // Add comment (this calls AppendWal internally) - CommentTools.CommentAdd(mgr, session.Id, "/body/paragraph[0]", "Test comment"); + // Add comment (tool calls sync.MaybeAutoSave internally) + CommentTools.CommentAdd(mgr, sync, session.Id, "/body/paragraph[0]", "Test comment"); var afterBytes = File.ReadAllBytes(_tempFile); Assert.NotEqual(originalBytes, afterBytes); diff --git a/tests/DocxMcp.Tests/CommentTests.cs b/tests/DocxMcp.Tests/CommentTests.cs index a2dead1..e509806 100644 --- a/tests/DocxMcp.Tests/CommentTests.cs +++ b/tests/DocxMcp.Tests/CommentTests.cs @@ -1,10 +1,9 @@ using System.Text.Json; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using DocxMcp.ExternalChanges; using DocxMcp.Helpers; -using DocxMcp.Persistence; using DocxMcp.Tools; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace DocxMcp.Tests; @@ -12,23 +11,22 @@ namespace DocxMcp.Tests; public class CommentTests : IDisposable { private readonly string _tempDir; - private readonly SessionStore _store; public CommentTests() { _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - _store = new SessionStore(NullLogger.Instance, _tempDir); + Directory.CreateDirectory(_tempDir); } public void Dispose() { - _store.Dispose(); if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } - private SessionManager CreateManager() => - new SessionManager(_store, NullLogger.Instance); + private SessionManager CreateManager() => TestHelpers.CreateSessionManager(); + + private SyncManager CreateSyncManager() => TestHelpers.CreateSyncManager(); private static string AddParagraphPatch(string text) => $"[{{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{{\"type\":\"paragraph\",\"text\":\"{text}\"}}}}]"; @@ -42,9 +40,9 @@ public void AddComment_ParagraphLevel_CreatesAllElements() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Needs revision"); + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Needs revision"); Assert.Contains("Comment 0 added", result); var doc = mgr.Get(id).Document; @@ -76,9 +74,9 @@ public void AddComment_TextLevel_AnchorsCorrectly() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello beautiful world")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello beautiful world")); - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Nice word", + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Nice word", anchor_text: "beautiful"); Assert.Contains("Comment 0 added", result); @@ -106,10 +104,10 @@ public void AddComment_CrossRun_SplitsRunsCorrectly() // Create paragraph with two runs: "Hello " and "world today" var patches = "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"runs\":[{\"text\":\"Hello \"},{\"text\":\"world today\"}]}}]"; - PatchTool.ApplyPatch(mgr, null, id, patches); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, patches); // Anchor to text that crosses the run boundary - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Spans runs", + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Spans runs", anchor_text: "lo world"); Assert.Contains("Comment 0 added", result); @@ -128,9 +126,9 @@ public void AddComment_MultiParagraphText_CreatesMultipleParagraphs() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Line 1\nLine 2"); + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Line 1\nLine 2"); Assert.Contains("Comment 0 added", result); var doc = mgr.Get(id).Document; @@ -150,9 +148,9 @@ public void AddComment_CustomAuthorAndInitials() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Review this", + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Review this", author: "John Doe", initials: "JD"); Assert.Contains("'John Doe'", result); @@ -170,9 +168,9 @@ public void AddComment_DefaultAuthorAndInitials() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Default author test"); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Default author test"); var doc = mgr.Get(id).Document; var comment = doc.MainDocumentPart!.WordprocessingCommentsPart! @@ -190,8 +188,8 @@ public void ListComments_ReturnsAllMetadata() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Test comment", + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Test comment", anchor_text: "world", author: "Tester", initials: "T"); var result = CommentTools.CommentList(mgr, id); @@ -216,10 +214,10 @@ public void ListComments_AuthorFilter_CaseInsensitive() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Text A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Text B")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "By Alice", author: "Alice"); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[1]", "By Bob", author: "Bob"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Text A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Text B")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "By Alice", author: "Alice"); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[1]", "By Bob", author: "Bob"); var result = CommentTools.CommentList(mgr, id, author: "alice"); var json = JsonDocument.Parse(result).RootElement; @@ -237,8 +235,8 @@ public void ListComments_Pagination() for (int i = 0; i < 5; i++) { - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch($"Para {i}")); - CommentTools.CommentAdd(mgr, id, $"/body/paragraph[{i}]", $"Comment {i}"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch($"Para {i}")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, $"/body/paragraph[{i}]", $"Comment {i}"); } var result = CommentTools.CommentList(mgr, id, offset: 2, limit: 2); @@ -258,10 +256,10 @@ public void DeleteComment_ById_RemovesAllElements() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Test"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Test"); - var deleteResult = CommentTools.CommentDelete(mgr, id, comment_id: 0); + var deleteResult = CommentTools.CommentDelete(mgr, CreateSyncManager(), id, comment_id: 0); Assert.Contains("Deleted 1", deleteResult); var doc = mgr.Get(id).Document; @@ -284,12 +282,12 @@ public void DeleteComment_ByAuthor_RemovesOnlyMatching() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Text A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Text B")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "By Alice", author: "Alice"); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[1]", "By Bob", author: "Bob"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Text A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Text B")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "By Alice", author: "Alice"); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[1]", "By Bob", author: "Bob"); - var result = CommentTools.CommentDelete(mgr, id, author: "Alice"); + var result = CommentTools.CommentDelete(mgr, CreateSyncManager(), id, author: "Alice"); Assert.Contains("Deleted 1", result); // Bob's comment should remain @@ -306,7 +304,7 @@ public void DeleteComment_NonExistent_ReturnsError() var session = mgr.Create(); var id = session.Id; - var result = CommentTools.CommentDelete(mgr, id, comment_id: 999); + var result = CommentTools.CommentDelete(mgr, CreateSyncManager(), id, comment_id: 999); Assert.Contains("Error", result); Assert.Contains("not found", result); } @@ -318,7 +316,7 @@ public void DeleteComment_NoParams_ReturnsError() var session = mgr.Create(); var id = session.Id; - var result = CommentTools.CommentDelete(mgr, id); + var result = CommentTools.CommentDelete(mgr, CreateSyncManager(), id); Assert.Contains("Error", result); Assert.Contains("At least one", result); } @@ -332,8 +330,8 @@ public void AddComment_Undo_RemovesComment_Redo_RestoresIt() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Test comment"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Test comment"); // Verify comment exists var listResult1 = CommentTools.CommentList(mgr, id); @@ -364,9 +362,9 @@ public void DeleteComment_Undo_RestoresComment() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Test comment"); - CommentTools.CommentDelete(mgr, id, comment_id: 0); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Test comment"); + CommentTools.CommentDelete(mgr, CreateSyncManager(), id, comment_id: 0); // Comment should be gone var doc1 = mgr.Get(id).Document; @@ -391,8 +389,8 @@ public void Query_ParagraphWithComment_HasCommentsArray() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Some text with feedback")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Needs revision"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Some text with feedback")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Needs revision"); var result = QueryTool.Query(mgr, id, "/body/paragraph[0]"); var json = JsonDocument.Parse(result).RootElement; @@ -411,7 +409,7 @@ public void Query_ParagraphWithoutComment_NoCommentsField() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Clean paragraph")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Clean paragraph")); var result = QueryTool.Query(mgr, id, "/body/paragraph[0]"); var json = JsonDocument.Parse(result).RootElement; @@ -428,13 +426,13 @@ public void CommentIds_AreSequential() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Para 0")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Para 1")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Para 2")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Para 0")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Para 1")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Para 2")); - var r0 = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "C0"); - var r1 = CommentTools.CommentAdd(mgr, id, "/body/paragraph[1]", "C1"); - var r2 = CommentTools.CommentAdd(mgr, id, "/body/paragraph[2]", "C2"); + var r0 = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "C0"); + var r1 = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[1]", "C1"); + var r2 = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[2]", "C2"); Assert.Contains("Comment 0", r0); Assert.Contains("Comment 1", r1); @@ -448,18 +446,18 @@ public void CommentIds_AfterDeletion_NoReuse() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Para 0")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Para 1")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Para 0")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Para 1")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "C0"); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[1]", "C1"); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "C0"); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[1]", "C1"); // Delete comment 0 - CommentTools.CommentDelete(mgr, id, comment_id: 0); + CommentTools.CommentDelete(mgr, CreateSyncManager(), id, comment_id: 0); // Next ID should be 2 (max existing=1, +1=2), not 0 - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Para 2")); - var r = CommentTools.CommentAdd(mgr, id, "/body/paragraph[2]", "C2"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Para 2")); + var r = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[2]", "C2"); Assert.Contains("Comment 2", r); } @@ -472,7 +470,7 @@ public void AddComment_PathResolvesToZero_ReturnsError() var session = mgr.Create(); var id = session.Id; - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Test"); + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Test"); Assert.Contains("Error", result); } @@ -483,10 +481,10 @@ public void AddComment_PathResolvesToMultiple_ReturnsError() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[*]", "Test"); + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[*]", "Test"); Assert.Contains("Error", result); Assert.Contains("must resolve to exactly 1", result); } @@ -498,37 +496,33 @@ public void AddComment_AnchorTextNotFound_ReturnsError() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); - var result = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Test", + var result = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Test", anchor_text: "nonexistent"); Assert.Contains("Error", result); Assert.Contains("not found", result); } // --- WAL replay across restart --- + // Note: These tests verify persistence via gRPC storage server [Fact] public void AddComment_SurvivesRestart_ThenUndo() { - var mgr = CreateManager(); + // Use explicit tenant so second manager can find the session + var tenantId = $"test-comment-restart-{Guid.NewGuid():N}"; + var mgr = TestHelpers.CreateSessionManager(tenantId); var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello world")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Persisted comment"); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello world")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Persisted comment"); - // Simulate server restart - _store.Dispose(); - var store2 = new SessionStore( - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + // Simulating a restart: create new manager with same tenant (stateless, no RestoreSessions needed) + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - var restored = mgr2.RestoreSessions(); - Assert.Equal(1, restored); - - // Comment should be present after restore + // Comment should be present (stateless Get loads from gRPC checkpoint) var listResult = CommentTools.CommentList(mgr2, id); Assert.Contains("Persisted comment", listResult); Assert.Contains("\"total\": 1", listResult); @@ -540,44 +534,38 @@ public void AddComment_SurvivesRestart_ThenUndo() // Comment should be gone var listResult2 = CommentTools.CommentList(mgr2, id); Assert.Contains("\"total\": 0", listResult2); - - store2.Dispose(); } [Fact] public void AddComment_OnOpenedFile_SurvivesRestart_ThenUndo() { + // Use explicit tenant so second manager can find the session + var tenantId = $"test-comment-file-restart-{Guid.NewGuid():N}"; + // Create a temp docx file with content, then open it (simulates real file usage) var tempFile = Path.Combine(_tempDir, "test.docx"); - Directory.CreateDirectory(_tempDir); - // Create file via a session, save, close + // Create file via a session, save, close (this session is intentionally discarded) var mgr0 = CreateManager(); var s0 = mgr0.Create(); - PatchTool.ApplyPatch(mgr0, null, s0.Id, AddParagraphPatch("Paragraph one")); - PatchTool.ApplyPatch(mgr0, null, s0.Id, AddParagraphPatch("Paragraph two")); - mgr0.Save(s0.Id, tempFile); + PatchTool.ApplyPatch(mgr0, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), s0.Id, AddParagraphPatch("Paragraph one")); + PatchTool.ApplyPatch(mgr0, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), s0.Id, AddParagraphPatch("Paragraph two")); + using (var exported = mgr0.Get(s0.Id)) + File.WriteAllBytes(tempFile, exported.ToBytes()); mgr0.Close(s0.Id); // Open the file (like mcptools document_open) - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(tenantId); var session = mgr.Open(tempFile); var id = session.Id; - var addResult = CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Review this paragraph"); + var addResult = CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Review this paragraph"); Assert.Contains("Comment 0 added", addResult); - // Simulate restart - _store.Dispose(); - var store2 = new SessionStore( - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, - Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + // Simulating a restart: create new manager with same tenant (stateless, no RestoreSessions needed) + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - var restored = mgr2.RestoreSessions(); - Assert.Equal(1, restored); - - // Comment should be present + // Comment should be present (stateless Get loads from gRPC checkpoint) var list1 = CommentTools.CommentList(mgr2, id); Assert.Contains("\"total\": 1", list1); Assert.Contains("Review this paragraph", list1); @@ -589,8 +577,6 @@ public void AddComment_OnOpenedFile_SurvivesRestart_ThenUndo() // Comment should be gone var list2 = CommentTools.CommentList(mgr2, id); Assert.Contains("\"total\": 0", list2); - - store2.Dispose(); } // --- Query enrichment with anchored text --- @@ -602,8 +588,8 @@ public void Query_TextLevelComment_HasAnchoredText() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Some text with feedback")); - CommentTools.CommentAdd(mgr, id, "/body/paragraph[0]", "Fix this", + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Some text with feedback")); + CommentTools.CommentAdd(mgr, CreateSyncManager(), id, "/body/paragraph[0]", "Fix this", anchor_text: "with feedback"); var result = QueryTool.Query(mgr, id, "/body/paragraph[0]"); diff --git a/tests/DocxMcp.Tests/ConcurrentPersistenceTests.cs b/tests/DocxMcp.Tests/ConcurrentPersistenceTests.cs index 6079d9e..03904c4 100644 --- a/tests/DocxMcp.Tests/ConcurrentPersistenceTests.cs +++ b/tests/DocxMcp.Tests/ConcurrentPersistenceTests.cs @@ -1,225 +1,207 @@ -using DocxMcp.Persistence; -using Microsoft.Extensions.Logging.Abstractions; +using DocxMcp.Grpc; using Xunit; +using Xunit.Abstractions; namespace DocxMcp.Tests; -public class ConcurrentPersistenceTests : IDisposable +/// +/// Tests for concurrent access via gRPC storage. +/// These tests verify that multiple SessionManager instances can safely access +/// the same tenant's data through gRPC storage locks. +/// +public class ConcurrentPersistenceTests { - private readonly string _tempDir; - - public ConcurrentPersistenceTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - } - - public void Dispose() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } - - private SessionStore CreateStore() => - new SessionStore(NullLogger.Instance, _tempDir); - - private SessionManager CreateManager(SessionStore store) => - new SessionManager(store, NullLogger.Instance); - [Fact] - public void AcquireLock_ReturnsDisposableLock() + public void TwoManagers_SameTenant_BothSeeSessions() { - using var store = CreateStore(); - store.EnsureDirectory(); + // Two managers with the same tenant should see each other's sessions + var tenantId = $"test-concurrent-{Guid.NewGuid():N}"; - using var sessionLock = store.AcquireLock(); - // Lock acquired successfully; verify it's IDisposable and non-null - Assert.NotNull(sessionLock); - } - - [Fact] - public void AcquireLock_ReleasedOnDispose() - { - using var store = CreateStore(); - store.EnsureDirectory(); - - var lock1 = store.AcquireLock(); - lock1.Dispose(); + var mgr1 = TestHelpers.CreateSessionManager(tenantId); + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - // Should succeed now that lock1 is released - using var lock2 = store.AcquireLock(maxRetries: 1, initialDelayMs: 10); - Assert.NotNull(lock2); - } - - [Fact] - public void AcquireLock_DoubleDispose_DoesNotThrow() - { - using var store = CreateStore(); - store.EnsureDirectory(); + var s1 = mgr1.Create(); - var sessionLock = store.AcquireLock(); - sessionLock.Dispose(); - sessionLock.Dispose(); // Should not throw + // Manager 2 should see the session via List() (stateless, shared gRPC storage) + var list = mgr2.List().ToList(); + Assert.Single(list); + Assert.Equal(s1.Id, list[0].Id); } [Fact] - public void TwoManagers_BothCreateSessions_IndexContainsBoth() + public void TwoManagers_DifferentTenants_IsolatedSessions() { - // Simulates two processes sharing the same sessions directory. - // Each manager creates a session; both should be in the index. - using var store1 = CreateStore(); - using var store2 = CreateStore(); - - var mgr1 = CreateManager(store1); - var mgr2 = CreateManager(store2); + // Two managers with different tenants should have isolated sessions + var mgr1 = TestHelpers.CreateSessionManager(); // unique tenant + var mgr2 = TestHelpers.CreateSessionManager(); // different unique tenant var s1 = mgr1.Create(); var s2 = mgr2.Create(); - // Reload index from disk to see the merged result - var index = store1.LoadIndex(); - var ids = index.Sessions.Select(e => e.Id).ToHashSet(); + // Each should only see their own session + var list1 = mgr1.List().ToList(); + var list2 = mgr2.List().ToList(); - Assert.Contains(s1.Id, ids); - Assert.Contains(s2.Id, ids); - Assert.Equal(2, index.Sessions.Count); + Assert.Single(list1); + Assert.Single(list2); + Assert.Equal(s1.Id, list1[0].Id); + Assert.Equal(s2.Id, list2[0].Id); + Assert.NotEqual(s1.Id, s2.Id); } [Fact] - public void TwoManagers_ParallelCreation_NoLostSessions() + public void ParallelCreation_NoLostSessions() { const int sessionsPerManager = 5; + var tenantId = $"test-parallel-{Guid.NewGuid():N}"; - using var store1 = CreateStore(); - using var store2 = CreateStore(); + var mgr1 = TestHelpers.CreateSessionManager(tenantId); + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - var mgr1 = CreateManager(store1); - var mgr2 = CreateManager(store2); + // Verify both managers have the same tenant ID (captured at construction) + Assert.Equal(tenantId, mgr1.TenantId); + Assert.Equal(tenantId, mgr2.TenantId); var ids1 = new List(); var ids2 = new List(); + var errors = new List(); Parallel.Invoke( () => { for (int i = 0; i < sessionsPerManager; i++) { - var s = mgr1.Create(); - lock (ids1) ids1.Add(s.Id); + try + { + var s = mgr1.Create(); + lock (ids1) ids1.Add(s.Id); + } + catch (Exception ex) + { + lock (errors) errors.Add(ex); + } } }, () => { for (int i = 0; i < sessionsPerManager; i++) { - var s = mgr2.Create(); - lock (ids2) ids2.Add(s.Id); + try + { + var s = mgr2.Create(); + lock (ids2) ids2.Add(s.Id); + } + catch (Exception ex) + { + lock (errors) errors.Add(ex); + } } } ); - // Verify all sessions present in the index - var index = store1.LoadIndex(); - var indexIds = index.Sessions.Select(e => e.Id).ToHashSet(); + // If any errors occurred, fail with the first one + if (errors.Count > 0) + { + throw new AggregateException($"Errors during parallel creation: {errors.Count}", errors); + } + + // Verify we got all the IDs + Assert.Equal(sessionsPerManager, ids1.Count); + Assert.Equal(sessionsPerManager, ids2.Count); + + // Verify all sessions are present + var mgr3 = TestHelpers.CreateSessionManager(tenantId); + var allIds = mgr3.List().Select(s => s.Id).ToHashSet(); + + // Debug output + var allExpectedIds = ids1.Concat(ids2).ToHashSet(); + var missing = allExpectedIds.Except(allIds).ToList(); + var extra = allIds.Except(allExpectedIds).ToList(); - foreach (var id in ids1.Concat(ids2)) - Assert.Contains(id, indexIds); + Assert.True(missing.Count == 0, + $"Missing sessions: [{string.Join(", ", missing)}]. " + + $"Found {allIds.Count} sessions, expected {allExpectedIds.Count}. " + + $"ids1: [{string.Join(", ", ids1)}], ids2: [{string.Join(", ", ids2)}]"); - Assert.Equal(sessionsPerManager * 2, index.Sessions.Count); + Assert.Equal(sessionsPerManager * 2, allIds.Count); } [Fact] - public void WithLockedIndex_ReloadsFromDisk() + public void CloseSession_UnderConcurrency_PreservesOtherSessions() { - // Verifies that WithLockedIndex always reloads from disk, - // so external writes are not lost. - using var store1 = CreateStore(); - using var store2 = CreateStore(); + var tenantId = $"test-close-concurrent-{Guid.NewGuid():N}"; - var mgr1 = CreateManager(store1); - var mgr2 = CreateManager(store2); + var mgr1 = TestHelpers.CreateSessionManager(tenantId); + var mgr2 = TestHelpers.CreateSessionManager(tenantId); // Manager 1 creates a session var s1 = mgr1.Create(); - // Manager 2 creates a session (its WithLockedIndex should reload and see s1) + // Manager 2 creates another session (stateless, no restore needed) var s2 = mgr2.Create(); - // Now manager 1 creates another session — should still see s2 - var s3 = mgr1.Create(); + // Manager 1 closes its session + mgr1.Close(s1.Id); - var index = store1.LoadIndex(); - Assert.Equal(3, index.Sessions.Count); + // A third manager should see only s2 (stateless) + var mgr3 = TestHelpers.CreateSessionManager(tenantId); + var list = mgr3.List().ToList(); - var ids = index.Sessions.Select(e => e.Id).ToHashSet(); - Assert.Contains(s1.Id, ids); - Assert.Contains(s2.Id, ids); - Assert.Contains(s3.Id, ids); + Assert.Single(list); + Assert.Equal(s2.Id, list[0].Id); } [Fact] - public void MappedWal_Refresh_SeesExternalAppend() + public void ConcurrentWrites_SameSession_AllPersist() { - using var store = CreateStore(); - store.EnsureDirectory(); - - // Open the WAL via the store (simulating process A) - var walA = store.GetOrCreateWal("shared"); - walA.Append("{\"patches\":\"first\"}"); - Assert.Equal(1, walA.EntryCount); - - // Simulate process B writing directly to the same WAL file - // by using a second MappedWal instance on the same path - var walPath = store.WalPath("shared"); - using var walB = new MappedWal(walPath); - walB.Append("{\"patches\":\"second\"}"); - - // walA doesn't see it yet (stale in-memory offset) - Assert.Equal(1, walA.EntryCount); - - // After Refresh(), walA should see both entries - walA.Refresh(); - Assert.Equal(2, walA.EntryCount); - - var all = walA.ReadAll(); - Assert.Equal(2, all.Count); - Assert.Contains("first", all[0]); - Assert.Contains("second", all[1]); + var tenantId = $"test-concurrent-writes-{Guid.NewGuid():N}"; + var mgr = TestHelpers.CreateSessionManager(tenantId); + var session = mgr.Create(); + var id = session.Id; + + // Apply multiple patches concurrently (simulating rapid edits) + var patches = Enumerable.Range(0, 5) + .Select(i => $"[{{\"op\":\"add\",\"path\":\"/body/children/{i}\",\"value\":{{\"type\":\"paragraph\",\"text\":\"Paragraph {i}\"}}}}]") + .ToList(); + + foreach (var patch in patches) + { + session.GetBody().AppendChild(new DocumentFormat.OpenXml.Wordprocessing.Paragraph( + new DocumentFormat.OpenXml.Wordprocessing.Run( + new DocumentFormat.OpenXml.Wordprocessing.Text($"Paragraph")))); + var bytes = session.ToBytes(); + mgr.AppendWal(id, patch, null, bytes); + } + + // All patches should be in history + var history = mgr.GetHistory(id); + Assert.True(history.Entries.Count >= patches.Count + 1); // +1 for baseline } - [Fact] - public void CloseSession_RemovesFromIndex() - { - using var store = CreateStore(); - var mgr = CreateManager(store); - - var s = mgr.Create(); - var id = s.Id; - - mgr.Close(id); - - var idx = store.LoadIndex(); - Assert.Empty(idx.Sessions); - } + // NOTE: DistributedLock_PreventsConcurrentAccess test removed. + // Locking is now internal to the gRPC server and handled during atomic index operations. + // The client no longer has direct access to lock operations. [Fact] - public void CloseSession_UnderConcurrency_PreservesOtherSessions() + public void TenantIsolation_NoDataLeakage() { - using var store1 = CreateStore(); - using var store2 = CreateStore(); + // Ensure tenants cannot access each other's data + var tenant1 = $"test-isolation-1-{Guid.NewGuid():N}"; + var tenant2 = $"test-isolation-2-{Guid.NewGuid():N}"; - var mgr1 = CreateManager(store1); - var mgr2 = CreateManager(store2); + var mgr1 = TestHelpers.CreateSessionManager(tenant1); + var mgr2 = TestHelpers.CreateSessionManager(tenant2); - // Both managers create sessions + // Create sessions in both tenants var s1 = mgr1.Create(); var s2 = mgr2.Create(); - // Manager 1 closes its session - mgr1.Close(s1.Id); + // Each manager should only see its own session + Assert.Single(mgr1.List()); + Assert.Single(mgr2.List()); - // Index should still contain s2 - var index = store1.LoadIndex(); - Assert.Single(index.Sessions); - Assert.Equal(s2.Id, index.Sessions[0].Id); + // Trying to get the other tenant's session should fail + Assert.Throws(() => mgr1.Get(s2.Id)); + Assert.Throws(() => mgr2.Get(s1.Id)); } } diff --git a/tests/DocxMcp.Tests/CountToolTests.cs b/tests/DocxMcp.Tests/CountToolTests.cs index b0dbdc4..a9b73d8 100644 --- a/tests/DocxMcp.Tests/CountToolTests.cs +++ b/tests/DocxMcp.Tests/CountToolTests.cs @@ -35,6 +35,8 @@ public CountToolTests() new TableRow( new TableCell(new Paragraph(new Run(new Text("C")))), new TableCell(new Paragraph(new Run(new Text("D"))))))); + + TestHelpers.PersistBaseline(_sessions, _session); } [Fact] diff --git a/tests/DocxMcp.Tests/DocxMcp.Tests.csproj b/tests/DocxMcp.Tests/DocxMcp.Tests.csproj index 4dce251..145eea2 100644 --- a/tests/DocxMcp.Tests/DocxMcp.Tests.csproj +++ b/tests/DocxMcp.Tests/DocxMcp.Tests.csproj @@ -11,10 +11,12 @@ + + diff --git a/tests/DocxMcp.Tests/ExternalChangeTrackerTests.cs b/tests/DocxMcp.Tests/ExternalChangeTrackerTests.cs index 0c19d02..b811373 100644 --- a/tests/DocxMcp.Tests/ExternalChangeTrackerTests.cs +++ b/tests/DocxMcp.Tests/ExternalChangeTrackerTests.cs @@ -1,59 +1,47 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Wordprocessing; using DocxMcp.ExternalChanges; -using Microsoft.Extensions.Logging.Abstractions; +using DocxMcp.Grpc; +using DocxMcp.Helpers; +using DocxMcp.Tools; using Xunit; namespace DocxMcp.Tests; /// -/// Tests for external change detection and tracking. +/// Tests for external change detection via ExternalChangeTools and ExternalChangeGate. /// public class ExternalChangeTrackerTests : IDisposable { private readonly string _tempDir; private readonly List _sessions = []; private readonly SessionManager _sessionManager; - private readonly ExternalChangeTracker _tracker; + private readonly ExternalChangeGate _gate = TestHelpers.CreateExternalChangeGate(); public ExternalChangeTrackerTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"docx-mcp-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); - var store = new Persistence.SessionStore(NullLogger.Instance, _tempDir); - _sessionManager = new SessionManager(store, NullLogger.Instance); - _tracker = new ExternalChangeTracker(_sessionManager, NullLogger.Instance); + _sessionManager = TestHelpers.CreateSessionManager(); } [Fact] - public void StartWatching_WithValidSession_StartsTracking() + public void CheckForChanges_WhenNoChanges_ReturnsNoChanges() { // Arrange var filePath = CreateTempDocx("Test content"); var session = OpenSession(filePath); - // Act - _tracker.StartWatching(session.Id); - - // Assert - no exception means success - Assert.False(_tracker.HasPendingChanges(session.Id)); - } - - [Fact] - public void CheckForChanges_WhenNoChanges_ReturnsNull() - { - // Arrange - var filePath = CreateTempDocx("Test content"); - var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); + // Save the session back to disk to match (opening assigns IDs) + File.WriteAllBytes(filePath, _sessionManager.Get(session.Id).ToBytes()); // Act - var patch = _tracker.CheckForChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert - Assert.Null(patch); - Assert.False(_tracker.HasPendingChanges(session.Id)); + Assert.True(result.Success); + Assert.False(result.HasChanges); } [Fact] @@ -62,215 +50,158 @@ public void CheckForChanges_WhenFileModified_DetectsChanges() // Arrange var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); // Modify the file externally ModifyDocx(filePath, "Modified content"); // Act - var patch = _tracker.CheckForChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert - Assert.NotNull(patch); - Assert.True(patch.Summary.TotalChanges > 0); - Assert.Equal(session.Id, patch.SessionId); - Assert.Equal(filePath, patch.SourcePath); - Assert.False(patch.Acknowledged); + Assert.True(result.Success); + Assert.True(result.HasChanges); + Assert.NotNull(result.Summary); + Assert.True(result.Summary.TotalChanges > 0); } [Fact] - public void HasPendingChanges_AfterDetection_ReturnsTrue() - { - // Arrange - var filePath = CreateTempDocx("Original"); - var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - ModifyDocx(filePath, "Changed"); - _tracker.CheckForChanges(session.Id); - - // Act & Assert - Assert.True(_tracker.HasPendingChanges(session.Id)); - } - - [Fact] - public void AcknowledgeChange_MarksPatchAsAcknowledged() + public void PerformSync_WhenNoSourcePath_ReturnsFailure() { - // Arrange - var filePath = CreateTempDocx("Original"); - var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - ModifyDocx(filePath, "Changed"); - var patch = _tracker.CheckForChanges(session.Id)!; + // Arrange — create a new empty session (no source path) + var session = _sessionManager.Create(); + _sessions.Add(session); // Act - var result = _tracker.AcknowledgeChange(session.Id, patch.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert - Assert.True(result); - Assert.False(_tracker.HasPendingChanges(session.Id)); - - var pending = _tracker.GetPendingChanges(session.Id); - Assert.True(pending.Changes[0].Acknowledged); + Assert.False(result.Success); + Assert.Contains("no source path", result.Message); } [Fact] - public void AcknowledgeAllChanges_AcknowledgesMultipleChanges() + public void PerformSync_WhenSourceFileDeleted_ReturnsFailure() { // Arrange - var filePath = CreateTempDocx("Original"); + var filePath = CreateTempDocx("Test"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - // First change - ModifyDocx(filePath, "Change 1"); - _tracker.CheckForChanges(session.Id); - - // Second change - ModifyDocx(filePath, "Change 1 and 2"); - _tracker.CheckForChanges(session.Id); + File.Delete(filePath); // Act - var count = _tracker.AcknowledgeAllChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert - Assert.Equal(2, count); - Assert.False(_tracker.HasPendingChanges(session.Id)); + Assert.False(result.Success); + Assert.Contains("not found", result.Message); } [Fact] - public void GetPendingChanges_ReturnsAllPendingChanges() + public void Patch_ContainsValidPatches() { // Arrange - var filePath = CreateTempDocx("Original"); + var filePath = CreateTempDocx("Original paragraph"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - ModifyDocx(filePath, "Change 1"); - _tracker.CheckForChanges(session.Id); - ModifyDocx(filePath, "Change 1 and Change 2"); - _tracker.CheckForChanges(session.Id); + ModifyDocx(filePath, "Completely different content here"); // Act - var pending = _tracker.GetPendingChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert - Assert.Equal(2, pending.Changes.Count); - Assert.True(pending.HasPendingChanges); - Assert.NotNull(pending.MostRecentPending); + Assert.True(result.HasChanges); + Assert.NotNull(result.Patches); + Assert.NotEmpty(result.Patches); + + // Each patch should have an 'op' field + foreach (var p in result.Patches) + { + Assert.True(p.ContainsKey("op")); + } } [Fact] - public void GetLatestUnacknowledgedChange_ReturnsCorrectChange() + public void HasPendingChanges_AfterDetection_ReturnsTrue() { // Arrange - var filePath = CreateTempDocx("Original"); + var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - ModifyDocx(filePath, "First change"); - var first = _tracker.CheckForChanges(session.Id)!; - - ModifyDocx(filePath, "Second change is here"); - var second = _tracker.CheckForChanges(session.Id)!; - // Acknowledge the first one - _tracker.AcknowledgeChange(session.Id, first.Id); + // Modify the file externally + ModifyDocx(filePath, "Modified content"); - // Act - var latest = _tracker.GetLatestUnacknowledgedChange(session.Id); + // Act — gate detects changes + var pending = _gate.CheckForChanges(_sessionManager.TenantId, _sessionManager, session.Id); // Assert - Assert.NotNull(latest); - Assert.Equal(second.Id, latest.Id); + Assert.NotNull(pending); + Assert.True(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); } [Fact] - public void UpdateSessionSnapshot_ResetsChangeDetection() + public void AcknowledgeChange_MarksPatchAsAcknowledged() { // Arrange - var filePath = CreateTempDocx("Original"); + var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - // Make an external change - ModifyDocx(filePath, "External change"); - - // Simulate saving the document (which updates the snapshot) - _sessionManager.Save(session.Id, filePath); - _tracker.UpdateSessionSnapshot(session.Id); + ModifyDocx(filePath, "Modified content"); + _gate.CheckForChanges(_sessionManager.TenantId, _sessionManager, session.Id); - // Act - check for changes again - var patch = _tracker.CheckForChanges(session.Id); + // Act + var acknowledged = _gate.Acknowledge(_sessionManager.TenantId, session.Id); - // Assert - should be no changes because snapshot was updated - Assert.Null(patch); + // Assert + Assert.True(acknowledged); + Assert.False(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); } [Fact] - public void ExternalChangePatch_ToLlmSummary_ProducesReadableOutput() + public void CheckForChanges_ReturnsCorrectChangeDetails() { // Arrange - var filePath = CreateTempDocx("Original paragraph"); + var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); - - ModifyDocx(filePath, "Modified paragraph with more content"); - var patch = _tracker.CheckForChanges(session.Id)!; + ModifyDocx(filePath, "Modified content"); // Act - var summary = patch.ToLlmSummary(); + var pending = _gate.CheckForChanges(_sessionManager.TenantId, _sessionManager, session.Id); // Assert - Assert.Contains("External Document Change Detected", summary); - Assert.Contains(session.Id, summary); - Assert.Contains("acknowledge_external_change", summary); + Assert.NotNull(pending); + Assert.Equal(session.Id, pending.SessionId); + Assert.Equal(filePath, pending.SourcePath); + Assert.True(pending.Summary.TotalChanges > 0); } [Fact] - public void StopWatching_StopsTrackingSession() + public void ClearPending_RemovesPendingState() { // Arrange - var filePath = CreateTempDocx("Test"); + var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); + ModifyDocx(filePath, "Modified content"); + _gate.CheckForChanges(_sessionManager.TenantId, _sessionManager, session.Id); + Assert.True(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); // Act - _tracker.StopWatching(session.Id); - - // Modify file after stopping - ModifyDocx(filePath, "Changed after stop"); - - // Check for changes (should start fresh) - var patch = _tracker.CheckForChanges(session.Id); + _gate.ClearPending(_sessionManager.TenantId, session.Id); - // Assert - checking creates a new watch, so it depends on implementation - // At minimum, no pending changes from before StopWatching - Assert.False(_tracker.HasPendingChanges(session.Id) && patch is null); + // Assert + Assert.False(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); } [Fact] - public void Patch_ContainsValidPatches() + public void NotifyExternalChange_SetsPendingState() { // Arrange - var filePath = CreateTempDocx("Original paragraph"); + var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); + ModifyDocx(filePath, "Modified content"); - ModifyDocx(filePath, "Completely different content here"); - var patch = _tracker.CheckForChanges(session.Id)!; + // Act — simulate gRPC notification + _gate.NotifyExternalChange(_sessionManager.TenantId, _sessionManager, session.Id); // Assert - Assert.NotEmpty(patch.Patches); - Assert.NotEmpty(patch.Changes); - - // Each patch should have an 'op' field - foreach (var p in patch.Patches) - { - Assert.True(p.ContainsKey("op")); - } + Assert.True(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); } #region Helpers @@ -326,15 +257,16 @@ private DocxSession OpenSession(string filePath) public void Dispose() { - _tracker.Dispose(); - foreach (var session in _sessions) { try { _sessionManager.Close(session.Id); } catch { /* ignore */ } } - try { Directory.Delete(_tempDir, true); } - catch { /* ignore */ } + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, true); } + catch { /* ignore */ } + } } } diff --git a/tests/DocxMcp.Tests/ExternalSyncTests.cs b/tests/DocxMcp.Tests/ExternalSyncTests.cs index ca3e917..a6a9309 100644 --- a/tests/DocxMcp.Tests/ExternalSyncTests.cs +++ b/tests/DocxMcp.Tests/ExternalSyncTests.cs @@ -3,31 +3,30 @@ using DocumentFormat.OpenXml.Wordprocessing; using DocxMcp.Diff; using DocxMcp.ExternalChanges; +using DocxMcp.Grpc; using DocxMcp.Persistence; -using Microsoft.Extensions.Logging.Abstractions; +using DocxMcp.Tools; using Xunit; namespace DocxMcp.Tests; /// /// Tests for external sync WAL integration. +/// Uses ExternalChangeTools.PerformSync (replaces ExternalChangeTracker). /// public class ExternalSyncTests : IDisposable { private readonly string _tempDir; private readonly List _sessions = []; - private readonly SessionStore _store; private readonly SessionManager _sessionManager; - private readonly ExternalChangeTracker _tracker; + private readonly ExternalChangeGate _gate = TestHelpers.CreateExternalChangeGate(); public ExternalSyncTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"docx-mcp-sync-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); - _store = new SessionStore(NullLogger.Instance, _tempDir); - _sessionManager = new SessionManager(_store, NullLogger.Instance); - _tracker = new ExternalChangeTracker(_sessionManager, NullLogger.Instance); + _sessionManager = TestHelpers.CreateSessionManager(); } #region SyncExternalChanges Tests @@ -41,15 +40,14 @@ public void SyncExternalChanges_WhenNoChanges_ReturnsNoChanges() // Save the session back to disk to ensure file hash matches // (opening a session assigns IDs which changes the bytes) - _sessionManager.Save(session.Id, filePath); + File.WriteAllBytes(filePath, _sessionManager.Get(session.Id).ToBytes()); // Act - var result = _tracker.SyncExternalChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.True(result.Success); Assert.False(result.HasChanges); - Assert.Contains("No external changes", result.Message); } [Fact] @@ -58,13 +56,12 @@ public void SyncExternalChanges_WhenFileModified_SyncsAndRecordsInWal() // Arrange var filePath = CreateTempDocx("Original content"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); // Modify the file externally ModifyDocx(filePath, "Modified content"); // Act - var result = _tracker.SyncExternalChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.True(result.Success); @@ -84,12 +81,16 @@ public void SyncExternalChanges_CreatesCheckpoint() ModifyDocx(filePath, "Changed"); // Act - var result = _tracker.SyncExternalChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); - // Assert - var walPosition = result.WalPosition!.Value; - var checkpointPath = _store.CheckpointPath(session.Id, walPosition); - Assert.True(File.Exists(checkpointPath), "Checkpoint should be created for sync"); + // Assert - checkpoint is created at the WAL position + Assert.NotNull(result.WalPosition); + Assert.True(result.WalPosition > 0, "Checkpoint should be created for sync"); + + // Verify checkpoint exists by checking that we can jump to that position + var history = _sessionManager.GetHistory(session.Id); + var syncEntry = history.Entries.FirstOrDefault(e => e.IsExternalSync); + Assert.NotNull(syncEntry); } [Fact] @@ -100,7 +101,7 @@ public void SyncExternalChanges_RecordsExternalSyncEntryType() var session = OpenSession(filePath); ModifyDocx(filePath, "Changed"); - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Act var history = _sessionManager.GetHistory(session.Id); @@ -112,23 +113,25 @@ public void SyncExternalChanges_RecordsExternalSyncEntryType() } [Fact] - public void SyncExternalChanges_AcknowledgesChangeIdIfProvided() + public void SyncExternalChanges_ClearsGatePendingState() { // Arrange var filePath = CreateTempDocx("Original"); var session = OpenSession(filePath); - _tracker.StartWatching(session.Id); ModifyDocx(filePath, "Changed"); - var patch = _tracker.CheckForChanges(session.Id)!; + // Gate detects the change + _gate.CheckForChanges(_sessionManager.TenantId, _sessionManager, session.Id); + Assert.True(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); - // Act - var result = _tracker.SyncExternalChanges(session.Id, patch.Id); + // Act — sync clears the gate + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); + if (result.Success) + _gate.ClearPending(_sessionManager.TenantId, session.Id); // Assert Assert.True(result.Success); - Assert.Equal(patch.Id, result.AcknowledgedChangeId); - Assert.False(_tracker.HasPendingChanges(session.Id)); + Assert.False(_gate.HasPendingChanges(_sessionManager.TenantId, session.Id)); } [Fact] @@ -145,7 +148,7 @@ public void SyncExternalChanges_ReloadsDocumentFromDisk() ModifyDocx(filePath, "Externally modified paragraph"); // Act - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert - session should now have the new content var updatedSession = _sessionManager.Get(session.Id); @@ -170,7 +173,7 @@ public void Undo_AfterExternalSync_RestoresPreSyncState() // Modify and sync ModifyDocx(filePath, "Synced content"); - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Verify sync worked var syncedText = GetFirstParagraphText(_sessionManager.Get(session.Id)); @@ -193,7 +196,7 @@ public void Redo_AfterUndoingExternalSync_ReappliesSyncedState() var session = OpenSession(filePath); ModifyDocx(filePath, "Synced content here"); - var syncResult = _tracker.SyncExternalChanges(session.Id); + var syncResult = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); Assert.True(syncResult.HasChanges, "Sync should detect changes"); // Get synced state text @@ -221,20 +224,28 @@ public void JumpTo_ExternalSyncPosition_LoadsFromCheckpoint() var session = OpenSession(filePath); // Make a regular change - var body = _sessionManager.Get(session.Id).GetBody(); - var newPara = new Paragraph(new Run(new Text("Regular change"))); - body.AppendChild(newPara); - _sessionManager.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/paragraph[-1]\",\"value\":{\"type\":\"paragraph\"}}]"); + byte[] walBytes; + using (var s = _sessionManager.Get(session.Id)) + { + var body = s.GetBody(); + var newPara = new Paragraph(new Run(new Text("Regular change"))); + body.AppendChild(newPara); + walBytes = s.ToBytes(); + } + _sessionManager.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/paragraph[-1]\",\"value\":{\"type\":\"paragraph\"}}]", null, walBytes); // External sync ModifyDocx(filePath, "External sync content"); - var syncResult = _tracker.SyncExternalChanges(session.Id); + var syncResult = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); var syncPosition = syncResult.WalPosition!.Value; // Make another change after sync - body = _sessionManager.Get(session.Id).GetBody(); - body.AppendChild(new Paragraph(new Run(new Text("After sync")))); - _sessionManager.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/paragraph[-1]\",\"value\":{\"type\":\"paragraph\"}}]"); + using (var s2 = _sessionManager.Get(session.Id)) + { + s2.GetBody().AppendChild(new Paragraph(new Run(new Text("After sync")))); + walBytes = s2.ToBytes(); + } + _sessionManager.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/paragraph[-1]\",\"value\":{\"type\":\"paragraph\"}}]", null, walBytes); // Act - jump back to sync position _sessionManager.JumpTo(session.Id, syncPosition); @@ -293,7 +304,7 @@ public void SyncExternalChanges_IncludesUncoveredChanges() CreateTempDocxWithHeader("Modified", "New Header", filePath); // Act - var result = _tracker.SyncExternalChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.True(result.HasChanges); @@ -313,13 +324,17 @@ public void GetHistory_ShowsExternalSyncEntriesDistinctly() var session = OpenSession(filePath); // Regular change - var body = _sessionManager.Get(session.Id).GetBody(); - body.AppendChild(new Paragraph(new Run(new Text("Regular")))); - _sessionManager.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/paragraph[-1]\",\"value\":{\"type\":\"paragraph\"}}]"); + byte[] walBytes; + using (var s = _sessionManager.Get(session.Id)) + { + s.GetBody().AppendChild(new Paragraph(new Run(new Text("Regular")))); + walBytes = s.ToBytes(); + } + _sessionManager.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/paragraph[-1]\",\"value\":{\"type\":\"paragraph\"}}]", null, walBytes); // External sync ModifyDocx(filePath, "External"); - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Act var history = _sessionManager.GetHistory(session.Id); @@ -342,7 +357,7 @@ public void ExternalSyncSummary_ContainsExpectedFields() var session = OpenSession(filePath); ModifyDocxMultipleParagraphs(filePath, new[] { "New 1", "New 2", "New 3" }); - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Act var history = _sessionManager.GetHistory(session.Id); @@ -363,7 +378,7 @@ public void ExternalSyncSummary_ContainsExpectedFields() public void WalEntry_ExternalSync_SerializesAndDeserializesCorrectly() { // Arrange - var entry = new WalEntry + var entry = new DocxMcp.Persistence.WalEntry { EntryType = WalEntryType.ExternalSync, Timestamp = DateTime.UtcNow, @@ -545,15 +560,16 @@ private static string GetFirstParagraphText(DocxSession session) public void Dispose() { - _tracker.Dispose(); - foreach (var session in _sessions) { try { _sessionManager.Close(session.Id); } catch { /* ignore */ } } - try { Directory.Delete(_tempDir, true); } - catch { /* ignore */ } + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, true); } + catch { /* ignore */ } + } } } diff --git a/tests/DocxMcp.Tests/MappedWalTests.cs b/tests/DocxMcp.Tests/MappedWalTests.cs deleted file mode 100644 index 9e75795..0000000 --- a/tests/DocxMcp.Tests/MappedWalTests.cs +++ /dev/null @@ -1,359 +0,0 @@ -using DocxMcp.Persistence; -using Xunit; - -namespace DocxMcp.Tests; - -public class MappedWalTests : IDisposable -{ - private readonly string _tempDir; - - public MappedWalTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - } - - public void Dispose() - { - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } - - private string WalPath(string name = "test") => Path.Combine(_tempDir, $"{name}.wal"); - - [Fact] - public void NewWal_IsEmpty() - { - using var wal = new MappedWal(WalPath()); - Assert.Empty(wal.ReadAll()); - Assert.Equal(0, wal.EntryCount); - } - - [Fact] - public void Append_SingleEntry_CanBeRead() - { - using var wal = new MappedWal(WalPath()); - wal.Append("line one"); - - var lines = wal.ReadAll(); - Assert.Single(lines); - Assert.Equal("line one", lines[0]); - Assert.Equal(1, wal.EntryCount); - } - - [Fact] - public void Append_MultipleEntries_PreservesOrder() - { - using var wal = new MappedWal(WalPath()); - wal.Append("first"); - wal.Append("second"); - wal.Append("third"); - - var lines = wal.ReadAll(); - Assert.Equal(3, lines.Count); - Assert.Equal("first", lines[0]); - Assert.Equal("second", lines[1]); - Assert.Equal("third", lines[2]); - Assert.Equal(3, wal.EntryCount); - } - - [Fact] - public void Truncate_ClearsAllEntries() - { - using var wal = new MappedWal(WalPath()); - wal.Append("data"); - wal.Append("more data"); - - wal.Truncate(); - - Assert.Empty(wal.ReadAll()); - Assert.Equal(0, wal.EntryCount); - } - - [Fact] - public void Truncate_ThenAppend_Works() - { - using var wal = new MappedWal(WalPath()); - wal.Append("old"); - wal.Truncate(); - wal.Append("new"); - - var lines = wal.ReadAll(); - Assert.Single(lines); - Assert.Equal("new", lines[0]); - } - - [Fact] - public void Persistence_SurvivesReopen() - { - var path = WalPath(); - - using (var wal = new MappedWal(path)) - { - wal.Append("persisted line 1"); - wal.Append("persisted line 2"); - } - - using (var wal2 = new MappedWal(path)) - { - var lines = wal2.ReadAll(); - Assert.Equal(2, lines.Count); - Assert.Equal("persisted line 1", lines[0]); - Assert.Equal("persisted line 2", lines[1]); - } - } - - [Fact] - public void Persistence_TruncatedWal_ReopensEmpty() - { - var path = WalPath(); - - using (var wal = new MappedWal(path)) - { - wal.Append("will be truncated"); - wal.Truncate(); - } - - using (var wal2 = new MappedWal(path)) - { - Assert.Empty(wal2.ReadAll()); - } - } - - [Fact] - public void Grow_HandlesLargeAppends() - { - using var wal = new MappedWal(WalPath()); - - // Append enough data to exceed the initial 1MB capacity - var largeLine = new string('x', 50_000); - for (int i = 0; i < 25; i++) - wal.Append(largeLine); - - var lines = wal.ReadAll(); - Assert.Equal(25, lines.Count); - Assert.All(lines, l => Assert.Equal(largeLine, l)); - } - - [Fact] - public void Append_Utf8Content_RoundTrips() - { - using var wal = new MappedWal(WalPath()); - wal.Append("{\"text\":\"héllo wörld 日本語\"}"); - - var lines = wal.ReadAll(); - Assert.Single(lines); - Assert.Equal("{\"text\":\"héllo wörld 日本語\"}", lines[0]); - } - - [Fact] - public void EntryCount_MatchesAppendCount() - { - using var wal = new MappedWal(WalPath()); - Assert.Equal(0, wal.EntryCount); - - for (int i = 1; i <= 10; i++) - { - wal.Append($"entry {i}"); - Assert.Equal(i, wal.EntryCount); - } - } - - // --- ReadRange tests --- - - [Fact] - public void ReadRange_Subset_ReturnsCorrectEntries() - { - using var wal = new MappedWal(WalPath()); - for (int i = 0; i < 5; i++) - wal.Append($"line {i}"); - - var range = wal.ReadRange(1, 4); - Assert.Equal(3, range.Count); - Assert.Equal("line 1", range[0]); - Assert.Equal("line 2", range[1]); - Assert.Equal("line 3", range[2]); - } - - [Fact] - public void ReadRange_FullRange_ReturnsAll() - { - using var wal = new MappedWal(WalPath()); - wal.Append("a"); - wal.Append("b"); - wal.Append("c"); - - var range = wal.ReadRange(0, 3); - Assert.Equal(3, range.Count); - Assert.Equal("a", range[0]); - Assert.Equal("b", range[1]); - Assert.Equal("c", range[2]); - } - - [Fact] - public void ReadRange_EmptyRange_ReturnsEmpty() - { - using var wal = new MappedWal(WalPath()); - wal.Append("data"); - - Assert.Empty(wal.ReadRange(1, 1)); - Assert.Empty(wal.ReadRange(2, 1)); - } - - [Fact] - public void ReadRange_OutOfBounds_ClampsSafely() - { - using var wal = new MappedWal(WalPath()); - wal.Append("a"); - wal.Append("b"); - - var range = wal.ReadRange(-1, 100); - Assert.Equal(2, range.Count); - Assert.Equal("a", range[0]); - Assert.Equal("b", range[1]); - } - - [Fact] - public void ReadRange_OnEmptyWal_ReturnsEmpty() - { - using var wal = new MappedWal(WalPath()); - Assert.Empty(wal.ReadRange(0, 10)); - } - - // --- ReadEntry tests --- - - [Fact] - public void ReadEntry_ByIndex_ReturnsCorrect() - { - using var wal = new MappedWal(WalPath()); - wal.Append("alpha"); - wal.Append("beta"); - wal.Append("gamma"); - - Assert.Equal("alpha", wal.ReadEntry(0)); - Assert.Equal("beta", wal.ReadEntry(1)); - Assert.Equal("gamma", wal.ReadEntry(2)); - } - - [Fact] - public void ReadEntry_OutOfRange_Throws() - { - using var wal = new MappedWal(WalPath()); - wal.Append("only one"); - - Assert.Throws(() => wal.ReadEntry(-1)); - Assert.Throws(() => wal.ReadEntry(1)); - Assert.Throws(() => wal.ReadEntry(100)); - } - - // --- TruncateAt tests --- - - [Fact] - public void TruncateAt_KeepsFirstN() - { - using var wal = new MappedWal(WalPath()); - wal.Append("a"); - wal.Append("b"); - wal.Append("c"); - wal.Append("d"); - - wal.TruncateAt(2); - - Assert.Equal(2, wal.EntryCount); - var lines = wal.ReadAll(); - Assert.Equal("a", lines[0]); - Assert.Equal("b", lines[1]); - } - - [Fact] - public void TruncateAt_Zero_ClearsAll() - { - using var wal = new MappedWal(WalPath()); - wal.Append("a"); - wal.Append("b"); - - wal.TruncateAt(0); - - Assert.Equal(0, wal.EntryCount); - Assert.Empty(wal.ReadAll()); - } - - [Fact] - public void TruncateAt_BeyondCount_NoOp() - { - using var wal = new MappedWal(WalPath()); - wal.Append("a"); - wal.Append("b"); - - wal.TruncateAt(10); - - Assert.Equal(2, wal.EntryCount); - Assert.Equal(2, wal.ReadAll().Count); - } - - [Fact] - public void TruncateAt_ThenAppend_Works() - { - using var wal = new MappedWal(WalPath()); - wal.Append("a"); - wal.Append("b"); - wal.Append("c"); - - wal.TruncateAt(1); - wal.Append("new b"); - - Assert.Equal(2, wal.EntryCount); - var lines = wal.ReadAll(); - Assert.Equal("a", lines[0]); - Assert.Equal("new b", lines[1]); - } - - [Fact] - public void TruncateAt_Persistence_SurvivesReopen() - { - var path = WalPath(); - - using (var wal = new MappedWal(path)) - { - wal.Append("a"); - wal.Append("b"); - wal.Append("c"); - wal.TruncateAt(2); - } - - using (var wal2 = new MappedWal(path)) - { - Assert.Equal(2, wal2.EntryCount); - var lines = wal2.ReadAll(); - Assert.Equal("a", lines[0]); - Assert.Equal("b", lines[1]); - } - } - - [Fact] - public void OffsetIndex_RebuiltOnReopen() - { - var path = WalPath(); - - using (var wal = new MappedWal(path)) - { - wal.Append("line 0"); - wal.Append("line 1"); - wal.Append("line 2"); - } - - using (var wal2 = new MappedWal(path)) - { - // Verify random access works after reopen (offset index rebuilt) - Assert.Equal("line 0", wal2.ReadEntry(0)); - Assert.Equal("line 1", wal2.ReadEntry(1)); - Assert.Equal("line 2", wal2.ReadEntry(2)); - Assert.Equal(3, wal2.EntryCount); - - var range = wal2.ReadRange(1, 3); - Assert.Equal(2, range.Count); - Assert.Equal("line 1", range[0]); - Assert.Equal("line 2", range[1]); - } - } -} diff --git a/tests/DocxMcp.Tests/PatchLimitTests.cs b/tests/DocxMcp.Tests/PatchLimitTests.cs index bffbe03..fb56254 100644 --- a/tests/DocxMcp.Tests/PatchLimitTests.cs +++ b/tests/DocxMcp.Tests/PatchLimitTests.cs @@ -2,6 +2,7 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using System.Text.Json; +using DocxMcp.ExternalChanges; using Xunit; namespace DocxMcp.Tests; @@ -10,14 +11,19 @@ public class PatchLimitTests : IDisposable { private readonly DocxSession _session; private readonly SessionManager _sessions; + private readonly SyncManager _sync; + private readonly ExternalChangeGate _gate = TestHelpers.CreateExternalChangeGate(); public PatchLimitTests() { _sessions = TestHelpers.CreateSessionManager(); + _sync = TestHelpers.CreateSyncManager(); _session = _sessions.Create(); var body = _session.GetBody(); body.AppendChild(new Paragraph(new Run(new Text("Content")))); + + TestHelpers.PersistBaseline(_sessions, _session); } [Fact] @@ -35,7 +41,7 @@ public void TenPatchesAreAccepted() } var json = JsonSerializer.Serialize(patches); - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); @@ -57,7 +63,7 @@ public void ElevenPatchesAreRejected() } var json = JsonSerializer.Serialize(patches); - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); Assert.False(doc.RootElement.GetProperty("success").GetBoolean()); @@ -68,7 +74,7 @@ public void ElevenPatchesAreRejected() public void OnePatchIsAccepted() { var json = """[{"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "Hello"}}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); @@ -78,7 +84,7 @@ public void OnePatchIsAccepted() [Fact] public void EmptyPatchArrayIsAccepted() { - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, "[]"); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, "[]"); var doc = JsonDocument.Parse(result); Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); diff --git a/tests/DocxMcp.Tests/PatchResultTests.cs b/tests/DocxMcp.Tests/PatchResultTests.cs index 245ecf2..70e8b36 100644 --- a/tests/DocxMcp.Tests/PatchResultTests.cs +++ b/tests/DocxMcp.Tests/PatchResultTests.cs @@ -1,6 +1,7 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using DocxMcp.ExternalChanges; using System.Text.Json; using Xunit; @@ -13,15 +14,20 @@ public class PatchResultTests : IDisposable { private readonly DocxSession _session; private readonly SessionManager _sessions; + private readonly SyncManager _sync; + private readonly ExternalChangeGate _gate = TestHelpers.CreateExternalChangeGate(); public PatchResultTests() { _sessions = TestHelpers.CreateSessionManager(); + _sync = TestHelpers.CreateSyncManager(); _session = _sessions.Create(); var body = _session.GetBody(); body.AppendChild(new Paragraph(new Run(new Text("hello world, hello universe, hello everyone")))); body.AppendChild(new Paragraph(new Run(new Text("Second paragraph with hello")))); + + TestHelpers.PersistBaseline(_sessions, _session); } #region JSON Response Format Tests @@ -30,7 +36,7 @@ public PatchResultTests() public void ApplyPatch_ReturnsStructuredJson() { var json = """[{"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "New"}}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -52,7 +58,7 @@ public void ApplyPatch_ReturnsStructuredJson() public void ApplyPatch_ErrorReturnsStructuredJson() { var json = """[{"op": "remove", "path": "/body/paragraph[999]"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -69,7 +75,7 @@ public void ApplyPatch_ErrorReturnsStructuredJson() [Fact] public void ApplyPatch_InvalidJsonReturnsStructuredError() { - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, "not json"); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, "not json"); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -89,7 +95,7 @@ public void ApplyPatch_TooManyOperationsReturnsStructuredError() } var json = JsonSerializer.Serialize(patches); - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -110,7 +116,7 @@ public void DryRun_DoesNotApplyChanges() var initialCount = body.Elements().Count(); var json = """[{"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "New"}}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json, dry_run: true); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json, dry_run: true); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -128,7 +134,7 @@ public void DryRun_DoesNotApplyChanges() public void DryRun_ReturnsWouldSucceedStatus() { var json = """[{"op": "remove", "path": "/body/paragraph[0]"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json, dry_run: true); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json, dry_run: true); var doc = JsonDocument.Parse(result); var ops = doc.RootElement.GetProperty("operations"); @@ -140,7 +146,7 @@ public void DryRun_ReturnsWouldSucceedStatus() public void DryRun_ReturnsWouldFailForInvalidPath() { var json = """[{"op": "remove", "path": "/body/paragraph[999]"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json, dry_run: true); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json, dry_run: true); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -153,7 +159,7 @@ public void DryRun_ReturnsWouldFailForInvalidPath() public void DryRun_ReplaceText_ReturnsMatchCountAndWouldReplace() { var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": "hi", "max_count": 2}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json, dry_run: true); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json, dry_run: true); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -171,7 +177,7 @@ public void DryRun_ReplaceText_ReturnsMatchCountAndWouldReplace() public void ReplaceText_DefaultMaxCountIsOne() { var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": "hi"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -181,7 +187,8 @@ public void ReplaceText_DefaultMaxCountIsOne() Assert.Equal(1, op.GetProperty("replacements_made").GetInt32()); // Verify only first occurrence was replaced - var text = _session.GetBody().Elements().First().InnerText; + using var reloaded = _sessions.Get(_session.Id); + var text = reloaded.GetBody().Elements().First().InnerText; Assert.Equal("hi world, hello universe, hello everyone", text); } @@ -191,7 +198,7 @@ public void ReplaceText_MaxCountZero_DoesNothing() var originalText = _session.GetBody().Elements().First().InnerText; var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": "hi", "max_count": 0}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -209,7 +216,7 @@ public void ReplaceText_MaxCountZero_DoesNothing() public void ReplaceText_MaxCountNegative_ReturnsError() { var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": "hi", "max_count": -1}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -222,7 +229,7 @@ public void ReplaceText_MaxCountNegative_ReturnsError() public void ReplaceText_MaxCountHigherThanMatches_ReplacesAll() { var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": "hi", "max_count": 100}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -230,7 +237,8 @@ public void ReplaceText_MaxCountHigherThanMatches_ReplacesAll() Assert.Equal(3, op.GetProperty("matches_found").GetInt32()); Assert.Equal(3, op.GetProperty("replacements_made").GetInt32()); - var text = _session.GetBody().Elements().First().InnerText; + using var reloaded = _sessions.Get(_session.Id); + var text = reloaded.GetBody().Elements().First().InnerText; Assert.Equal("hi world, hi universe, hi everyone", text); } @@ -238,7 +246,7 @@ public void ReplaceText_MaxCountHigherThanMatches_ReplacesAll() public void ReplaceText_MaxCountTwo_ReplacesTwoOccurrences() { var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": "hi", "max_count": 2}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -246,7 +254,8 @@ public void ReplaceText_MaxCountTwo_ReplacesTwoOccurrences() Assert.Equal(3, op.GetProperty("matches_found").GetInt32()); Assert.Equal(2, op.GetProperty("replacements_made").GetInt32()); - var text = _session.GetBody().Elements().First().InnerText; + using var reloaded = _sessions.Get(_session.Id); + var text = reloaded.GetBody().Elements().First().InnerText; Assert.Equal("hi world, hi universe, hello everyone", text); } @@ -258,7 +267,7 @@ public void ReplaceText_MaxCountTwo_ReplacesTwoOccurrences() public void ReplaceText_EmptyReplace_ReturnsError() { var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": ""}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -275,7 +284,7 @@ public void ReplaceText_NullReplace_ReturnsError() { // JSON null for replace field var json = """[{"op": "replace_text", "path": "/body/paragraph[0]", "find": "hello", "replace": null}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -291,7 +300,7 @@ public void ReplaceText_NullReplace_ReturnsError() public void AddOperation_ReturnsCreatedId() { var json = """[{"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "New"}}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -306,10 +315,10 @@ public void RemoveOperation_ReturnsRemovedId() { // First add a paragraph via patch so it gets an ID var addJson = """[{"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "Paragraph to remove"}}]"""; - DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, addJson); + DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, addJson); var json = """[{"op": "remove", "path": "/body/paragraph[0]"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -323,10 +332,10 @@ public void MoveOperation_ReturnsMovedIdAndFrom() { // First add a paragraph via patch so it gets an ID var addJson = """[{"op": "add", "path": "/body/children/999", "value": {"type": "paragraph", "text": "Paragraph to move"}}]"""; - DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, addJson); + DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, addJson); var json = """[{"op": "move", "from": "/body/paragraph[-1]", "path": "/body/children/0"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -340,10 +349,10 @@ public void CopyOperation_ReturnsSourceIdAndCopyId() { // First add a paragraph via patch so it gets an ID var addJson = """[{"op": "add", "path": "/body/children/0", "value": {"type": "paragraph", "text": "Paragraph to copy"}}]"""; - DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, addJson); + DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, addJson); var json = """[{"op": "copy", "from": "/body/paragraph[0]", "path": "/body/children/999"}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; @@ -357,11 +366,11 @@ public void RemoveColumnOperation_ReturnsColumnIndexAndRowsAffected() { // First add a table var addTableJson = """[{"op": "add", "path": "/body/children/0", "value": {"type": "table", "headers": ["A", "B", "C"], "rows": [["1", "2", "3"], ["4", "5", "6"]]}}]"""; - DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, addTableJson); + DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, addTableJson); // Then remove a column var json = """[{"op": "remove_column", "path": "/body/table[0]", "column": 1}]"""; - var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, null, _session.Id, json); + var result = DocxMcp.Tools.PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, json); var doc = JsonDocument.Parse(result); var op = doc.RootElement.GetProperty("operations")[0]; diff --git a/tests/DocxMcp.Tests/QueryPaginationTests.cs b/tests/DocxMcp.Tests/QueryPaginationTests.cs index 34c4e62..b57b4c9 100644 --- a/tests/DocxMcp.Tests/QueryPaginationTests.cs +++ b/tests/DocxMcp.Tests/QueryPaginationTests.cs @@ -23,6 +23,8 @@ public QueryPaginationTests() { body.AppendChild(new Paragraph(new Run(new Text($"Paragraph {i}")))); } + + TestHelpers.PersistBaseline(_sessions, _session); } [Fact] diff --git a/tests/DocxMcp.Tests/QueryRoundTripTests.cs b/tests/DocxMcp.Tests/QueryRoundTripTests.cs index f5e8264..23a0f6a 100644 --- a/tests/DocxMcp.Tests/QueryRoundTripTests.cs +++ b/tests/DocxMcp.Tests/QueryRoundTripTests.cs @@ -1,6 +1,7 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; +using DocxMcp.ExternalChanges; using DocxMcp.Helpers; using DocxMcp.Tools; using System.Text.Json; @@ -16,10 +17,13 @@ public class QueryRoundTripTests : IDisposable { private readonly DocxSession _session; private readonly SessionManager _sessions; + private readonly SyncManager _sync; + private readonly ExternalChangeGate _gate = TestHelpers.CreateExternalChangeGate(); public QueryRoundTripTests() { _sessions = TestHelpers.CreateSessionManager(); + _sync = TestHelpers.CreateSyncManager(); _session = _sessions.Create(); } @@ -28,6 +32,7 @@ public void QuerySingleRunIncludesRunsArray() { var body = _session.GetBody(); body.AppendChild(new Paragraph(new Run(new Text("Single run")))); + TestHelpers.PersistBaseline(_sessions, _session); var result = QueryTool.Query(_sessions, _session.Id, "/body/paragraph[0]"); using var doc = JsonDocument.Parse(result); @@ -48,6 +53,7 @@ public void QueryTabRunDetectedCorrectly() new Run(new TabChar()), new Run(new Text("After") { Space = SpaceProcessingModeValues.Preserve })); body.AppendChild(p); + TestHelpers.PersistBaseline(_sessions, _session); var result = QueryTool.Query(_sessions, _session.Id, "/body/paragraph[0]"); using var doc = JsonDocument.Parse(result); @@ -73,6 +79,7 @@ public void QueryBreakRunDetectedCorrectly() new Run(new Break { Type = BreakValues.Page }), new Run(new Text("After"))); body.AppendChild(p); + TestHelpers.PersistBaseline(_sessions, _session); var result = QueryTool.Query(_sessions, _session.Id, "/body/paragraph[0]"); using var doc = JsonDocument.Parse(result); @@ -93,6 +100,7 @@ public void QueryParagraphPropertiesIncluded() new Indentation { Left = "720", Right = "360" }), new Run(new Text("Formatted"))); body.AppendChild(p); + TestHelpers.PersistBaseline(_sessions, _session); var result = QueryTool.Query(_sessions, _session.Id, "/body/paragraph[0]"); using var doc = JsonDocument.Parse(result); @@ -122,6 +130,7 @@ public void QueryTabStopsIncluded() new Run(new TabChar()), new Run(new Text("Right"))); body.AppendChild(p); + TestHelpers.PersistBaseline(_sessions, _session); var result = QueryTool.Query(_sessions, _session.Id, "/body/paragraph[0]"); using var doc = JsonDocument.Parse(result); @@ -152,6 +161,7 @@ public void QueryRunStylesPreserved() new Color { Val = "FF0000" }), new Text("Styled run"))); body.AppendChild(p); + TestHelpers.PersistBaseline(_sessions, _session); var result = QueryTool.Query(_sessions, _session.Id, "/body/paragraph[0]"); using var doc = JsonDocument.Parse(result); @@ -168,7 +178,7 @@ public void QueryRunStylesPreserved() public void RoundTripCreateThenQueryParagraph() { // Create a paragraph with runs via patch - var patchResult = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var patchResult = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "add", "path": "/body/children/0", @@ -220,7 +230,7 @@ public void RoundTripCreateThenQueryParagraph() [Fact] public void RoundTripCreateThenQueryHeading() { - var patchResult = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var patchResult = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "add", "path": "/body/children/0", diff --git a/tests/DocxMcp.Tests/QueryTests.cs b/tests/DocxMcp.Tests/QueryTests.cs index 3082b1d..178943b 100644 --- a/tests/DocxMcp.Tests/QueryTests.cs +++ b/tests/DocxMcp.Tests/QueryTests.cs @@ -35,6 +35,8 @@ public QueryTests() new TableRow( new TableCell(new Paragraph(new Run(new Text("V1")))), new TableCell(new Paragraph(new Run(new Text("V2"))))))); + + TestHelpers.PersistBaseline(_sessions, _session); } [Fact] diff --git a/tests/DocxMcp.Tests/ReadHeadingContentTests.cs b/tests/DocxMcp.Tests/ReadHeadingContentTests.cs index a59a34f..cb79adb 100644 --- a/tests/DocxMcp.Tests/ReadHeadingContentTests.cs +++ b/tests/DocxMcp.Tests/ReadHeadingContentTests.cs @@ -59,6 +59,8 @@ public ReadHeadingContentTests() body.AppendChild(MakeHeading(1, "Conclusion")); body.AppendChild(MakeParagraph("Conclusion text")); + + TestHelpers.PersistBaseline(_sessions, _session); } // --- Listing mode tests --- diff --git a/tests/DocxMcp.Tests/ReadSectionTests.cs b/tests/DocxMcp.Tests/ReadSectionTests.cs index 0cd456a..0b64b43 100644 --- a/tests/DocxMcp.Tests/ReadSectionTests.cs +++ b/tests/DocxMcp.Tests/ReadSectionTests.cs @@ -37,6 +37,8 @@ public ReadSectionTests() // Final SectionProperties as direct child of body (marks end of last section) body.AppendChild(new SectionProperties()); + + TestHelpers.PersistBaseline(_sessions, _session); } [Fact] diff --git a/tests/DocxMcp.Tests/SessionPersistenceTests.cs b/tests/DocxMcp.Tests/SessionPersistenceTests.cs index 8f9b4ca..ca5b5bc 100644 --- a/tests/DocxMcp.Tests/SessionPersistenceTests.cs +++ b/tests/DocxMcp.Tests/SessionPersistenceTests.cs @@ -1,157 +1,90 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Wordprocessing; -using DocxMcp.Persistence; +using DocxMcp.ExternalChanges; +using DocxMcp.Grpc; using DocxMcp.Tools; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace DocxMcp.Tests; -public class SessionPersistenceTests : IDisposable +/// +/// Tests for session persistence via gRPC storage. +/// These tests verify that sessions persist correctly across manager instances. +/// +public class SessionPersistenceTests { - private readonly string _tempDir; - private readonly SessionStore _store; - - public SessionPersistenceTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - _store = new SessionStore(NullLogger.Instance, _tempDir); - } - - public void Dispose() - { - _store.Dispose(); - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } - - private SessionManager CreateManager() => - new SessionManager(_store, NullLogger.Instance); - [Fact] - public void OpenSession_PersistsBaselineAndIndex() + public void CreateSession_CanBeRetrieved() { - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(); var session = mgr.Create(); - Assert.True(File.Exists(_store.BaselinePath(session.Id))); - Assert.True(File.Exists(Path.Combine(_tempDir, "index.json"))); - - var index = _store.LoadIndex(); - Assert.Single(index.Sessions); - Assert.Equal(session.Id, index.Sessions[0].Id); + // Session should be retrievable + var retrieved = mgr.Get(session.Id); + Assert.NotNull(retrieved); + Assert.Equal(session.Id, retrieved.Id); } [Fact] - public void CloseSession_RemovesFromDisk() + public void CloseSession_RemovesFromList() { - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(); var session = mgr.Create(); var id = session.Id; mgr.Close(id); - Assert.False(File.Exists(_store.BaselinePath(id))); - var index = _store.LoadIndex(); - Assert.Empty(index.Sessions); + // Session should no longer be in list + var list = mgr.List(); + Assert.DoesNotContain(list, s => s.Id == id); } [Fact] - public void AppendWal_WritesToMappedFile() + public void AppendWal_RecordsInHistory() { - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(); var session = mgr.Create(); - mgr.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"Hello\"}}]"); + // Add content via WAL + session.GetBody().AppendChild(new Paragraph(new Run(new Text("Hello")))); + var bytes = session.ToBytes(); + mgr.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"Hello\"}}]", null, bytes); - var walEntries = _store.ReadWal(session.Id); - Assert.Single(walEntries); - - var index = _store.LoadIndex(); - Assert.Equal(1, index.Sessions[0].WalCount); + var history = mgr.GetHistory(session.Id); + // History should have at least 2 entries: baseline + WAL entry + Assert.True(history.Entries.Count >= 2); } [Fact] - public void Compact_ResetsWalAndUpdatesBaseline() + public void Compact_ResetsWalPosition() { - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(); var session = mgr.Create(); // Add content via patch var body = session.GetBody(); body.AppendChild(new Paragraph(new Run(new Text("Test content")))); + var bytes = session.ToBytes(); + mgr.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"Test\"}}]", null, bytes); - mgr.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"Test\"}}]"); + var historyBefore = mgr.GetHistory(session.Id); + var countBefore = historyBefore.Entries.Count; mgr.Compact(session.Id); - var index = _store.LoadIndex(); - Assert.Equal(0, index.Sessions[0].WalCount); - - // WAL should be empty after compaction - var walEntries = _store.ReadWal(session.Id); - Assert.Empty(walEntries); + // After compaction, history should be reset to just the baseline + var historyAfter = mgr.GetHistory(session.Id); + Assert.True(historyAfter.Entries.Count <= countBefore); } [Fact] - public void AppendWal_AutoCompacts_WhenThresholdReached() + public void RestoreSessions_RehydratesFromStorage() { - // Use a custom store with a low compaction threshold - var customTempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - var customStore = new SessionStore(NullLogger.Instance, customTempDir); - - try - { - // Set threshold to 5 via environment variable before creating the manager - var originalThreshold = Environment.GetEnvironmentVariable("DOCX_WAL_COMPACT_THRESHOLD"); - Environment.SetEnvironmentVariable("DOCX_WAL_COMPACT_THRESHOLD", "5"); - - try - { - var mgr = new SessionManager(customStore, NullLogger.Instance); - var session = mgr.Create(); - var id = session.Id; - - // Append 4 WAL entries (threshold is 5, so no compaction yet) - for (int i = 0; i < 4; i++) - { - session.GetBody().AppendChild(new Paragraph(new Run(new Text($"Entry {i}")))); - mgr.AppendWal(id, $"[{{\"op\":\"add\",\"path\":\"/body/children/{i}\",\"value\":{{\"type\":\"paragraph\",\"text\":\"Entry {i}\"}}}}]"); - } - - var indexBefore = customStore.LoadIndex(); - Assert.Equal(4, indexBefore.Sessions[0].WalCount); - - // Append the 5th entry — should trigger auto-compaction - session.GetBody().AppendChild(new Paragraph(new Run(new Text("Entry 4")))); - mgr.AppendWal(id, "[{\"op\":\"add\",\"path\":\"/body/children/4\",\"value\":{\"type\":\"paragraph\",\"text\":\"Entry 4\"}}]"); - - var indexAfter = customStore.LoadIndex(); - Assert.Equal(0, indexAfter.Sessions[0].WalCount); // Compaction reset WAL count - - // WAL should be empty after compaction - var walEntries = customStore.ReadWal(id); - Assert.Empty(walEntries); - } - finally - { - // Restore original environment variable - Environment.SetEnvironmentVariable("DOCX_WAL_COMPACT_THRESHOLD", originalThreshold); - } - } - finally - { - customStore.Dispose(); - if (Directory.Exists(customTempDir)) - Directory.Delete(customTempDir, recursive: true); - } - } + // Use same tenant for both managers + var tenantId = $"test-persist-{Guid.NewGuid():N}"; - [Fact] - public void RestoreSessions_RehydratesFromBaseline() - { // Create a session and persist it - var mgr1 = CreateManager(); + var mgr1 = TestHelpers.CreateSessionManager(tenantId); var session = mgr1.Create(); var id = session.Id; @@ -159,114 +92,107 @@ public void RestoreSessions_RehydratesFromBaseline() var body = session.GetBody(); body.AppendChild(new Paragraph(new Run(new Text("Persisted content")))); - // Compact to save current state as baseline - mgr1.Compact(id); + // Persist modified baseline so gRPC has the content + TestHelpers.PersistBaseline(mgr1, session); - // Simulate server restart: create a new manager with the same store - _store.Dispose(); // close existing WAL mappings - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); + // Compact to save current state + mgr1.Compact(id); - var restored = mgr2.RestoreSessions(); - Assert.Equal(1, restored); + // Create a new manager with the same tenant — stateless, no RestoreSessions needed + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - // Verify the session is accessible with the same ID - var restoredSession = mgr2.Get(id); + // Verify the session is accessible with the same ID (stateless Get loads from gRPC) + using var restoredSession = mgr2.Get(id); Assert.NotNull(restoredSession); Assert.Contains("Persisted content", restoredSession.GetBody().InnerText); - - store2.Dispose(); } [Fact] public void RestoreSessions_ReplaysWal() { + var tenantId = $"test-wal-replay-{Guid.NewGuid():N}"; + // Create a session and add a patch via WAL (not compacted) - var mgr1 = CreateManager(); + var mgr1 = TestHelpers.CreateSessionManager(tenantId); var session = mgr1.Create(); var id = session.Id; // Apply a patch through PatchTool - PatchTool.ApplyPatch(mgr1, null, id, + PatchTool.ApplyPatch(mgr1, TestHelpers.CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"WAL entry\"}}]"); - // Verify WAL has entries - var walEntries = _store.ReadWal(id); - Assert.NotEmpty(walEntries); - - // Simulate restart - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); + // Verify WAL has entries via history + var history = mgr1.GetHistory(id); + Assert.True(history.Entries.Count > 1); - var restored = mgr2.RestoreSessions(); - Assert.Equal(1, restored); + // Create new manager with same tenant — stateless, no RestoreSessions needed + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - // Verify the WAL was replayed — the paragraph should exist - var restoredSession = mgr2.Get(id); + // Verify the WAL was replayed — the paragraph should exist (stateless Get loads from gRPC) + using var restoredSession = mgr2.Get(id); Assert.Contains("WAL entry", restoredSession.GetBody().InnerText); - - store2.Dispose(); - } - - [Fact] - public void RestoreSessions_CorruptBaseline_SkipsButPreservesIndex() - { - var mgr = CreateManager(); - var session = mgr.Create(); - var id = session.Id; - - // Corrupt the baseline file - File.WriteAllBytes(_store.BaselinePath(id), new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }); - - // Simulate restart - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); - - var restored = mgr2.RestoreSessions(); - Assert.Equal(0, restored); // Session not restored to memory - - // Index entry should be preserved (WAL history preservation) - var index = store2.LoadIndex(); - Assert.Single(index.Sessions); - Assert.Equal(id, index.Sessions[0].Id); - - store2.Dispose(); } [Fact] public void MultipleSessions_PersistIndependently() { - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(); var s1 = mgr.Create(); var s2 = mgr.Create(); - Assert.True(File.Exists(_store.BaselinePath(s1.Id))); - Assert.True(File.Exists(_store.BaselinePath(s2.Id))); + var list = mgr.List().ToList(); + Assert.Equal(2, list.Count); - var index = _store.LoadIndex(); - Assert.Equal(2, index.Sessions.Count); + var ids = list.Select(s => s.Id).ToHashSet(); + Assert.Contains(s1.Id, ids); + Assert.Contains(s2.Id, ids); mgr.Close(s1.Id); - index = _store.LoadIndex(); - Assert.Single(index.Sessions); - Assert.Equal(s2.Id, index.Sessions[0].Id); + list = mgr.List().ToList(); + Assert.Single(list); + Assert.Equal(s2.Id, list[0].Id); } [Fact] - public void DocumentSnapshot_CompactsViaToolCall() + public void DocumentSnapshot_CompactsSession() { - var mgr = CreateManager(); + var mgr = TestHelpers.CreateSessionManager(); var session = mgr.Create(); - mgr.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"Before snapshot\"}}]"); + // Add some WAL entries + session.GetBody().AppendChild(new Paragraph(new Run(new Text("Before snapshot")))); + var bytes = session.ToBytes(); + mgr.AppendWal(session.Id, "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"Before snapshot\"}}]", null, bytes); var result = DocumentTools.DocumentSnapshot(mgr, session.Id); Assert.Contains("Snapshot created", result); + } + + [Fact] + public void UndoRedo_WorksAfterRestart() + { + var tenantId = $"test-undo-restart-{Guid.NewGuid():N}"; + + // Create session and apply patches + var mgr1 = TestHelpers.CreateSessionManager(tenantId); + var session = mgr1.Create(); + var id = session.Id; + + PatchTool.ApplyPatch(mgr1, TestHelpers.CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, + "[{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{\"type\":\"paragraph\",\"text\":\"First\"}}]"); + PatchTool.ApplyPatch(mgr1, TestHelpers.CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, + "[{\"op\":\"add\",\"path\":\"/body/children/1\",\"value\":{\"type\":\"paragraph\",\"text\":\"Second\"}}]"); + + // Restart — stateless, no RestoreSessions needed + var mgr2 = TestHelpers.CreateSessionManager(tenantId); + + // Undo should work + var undoResult = mgr2.Undo(id); + Assert.True(undoResult.Steps > 0); - var index = _store.LoadIndex(); - Assert.Equal(0, index.Sessions[0].WalCount); + var text = mgr2.Get(id).GetBody().InnerText; + Assert.Contains("First", text); + Assert.DoesNotContain("Second", text); } } diff --git a/tests/DocxMcp.Tests/SessionStoreTests.cs b/tests/DocxMcp.Tests/SessionStoreTests.cs deleted file mode 100644 index bbd9920..0000000 --- a/tests/DocxMcp.Tests/SessionStoreTests.cs +++ /dev/null @@ -1,500 +0,0 @@ -using System.Text.Json; -using DocxMcp.Persistence; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace DocxMcp.Tests; - -public class SessionStoreTests : IDisposable -{ - private readonly string _tempDir; - private readonly SessionStore _store; - - public SessionStoreTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - _store = new SessionStore(NullLogger.Instance, _tempDir); - } - - public void Dispose() - { - _store.Dispose(); - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } - - // --- Index tests --- - - [Fact] - public void LoadIndex_NoFile_ReturnsEmpty() - { - var index = _store.LoadIndex(); - Assert.Equal(1, index.Version); - Assert.Empty(index.Sessions); - } - - [Fact] - public void SaveAndLoadIndex_RoundTrips() - { - var index = new SessionIndexFile - { - Sessions = new() - { - new SessionEntry - { - Id = "abc123", - SourcePath = "/tmp/test.docx", - CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), - LastModifiedAt = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), - DocxFile = "abc123.docx", - WalCount = 5 - } - } - }; - - _store.SaveIndex(index); - var loaded = _store.LoadIndex(); - - Assert.Single(loaded.Sessions); - var entry = loaded.Sessions[0]; - Assert.Equal("abc123", entry.Id); - Assert.Equal("/tmp/test.docx", entry.SourcePath); - Assert.Equal("abc123.docx", entry.DocxFile); - Assert.Equal(5, entry.WalCount); - } - - [Fact] - public void SaveIndex_MultipleSessions_RoundTrips() - { - var index = new SessionIndexFile - { - Sessions = new() - { - new SessionEntry { Id = "aaa", DocxFile = "aaa.docx" }, - new SessionEntry { Id = "bbb", DocxFile = "bbb.docx" }, - new SessionEntry { Id = "ccc", DocxFile = "ccc.docx" }, - } - }; - - _store.SaveIndex(index); - var loaded = _store.LoadIndex(); - - Assert.Equal(3, loaded.Sessions.Count); - Assert.Equal("aaa", loaded.Sessions[0].Id); - Assert.Equal("bbb", loaded.Sessions[1].Id); - Assert.Equal("ccc", loaded.Sessions[2].Id); - } - - [Fact] - public void LoadIndex_NullSourcePath_RoundTrips() - { - var index = new SessionIndexFile - { - Sessions = new() - { - new SessionEntry { Id = "x", SourcePath = null, DocxFile = "x.docx" } - } - }; - - _store.SaveIndex(index); - var loaded = _store.LoadIndex(); - - Assert.Null(loaded.Sessions[0].SourcePath); - } - - [Fact] - public void LoadIndex_CorruptJson_ReturnsEmpty() - { - _store.EnsureDirectory(); - File.WriteAllText(Path.Combine(_tempDir, "index.json"), "not valid json {{{"); - - var index = _store.LoadIndex(); - Assert.Empty(index.Sessions); - } - - [Fact] - public void SaveIndex_CreatesDirectoryIfMissing() - { - Assert.False(Directory.Exists(_tempDir)); - _store.SaveIndex(new SessionIndexFile()); - Assert.True(Directory.Exists(_tempDir)); - Assert.True(File.Exists(Path.Combine(_tempDir, "index.json"))); - } - - // --- Baseline tests --- - - [Fact] - public void PersistAndLoadBaseline_RoundTrips() - { - var data = new byte[] { 0x50, 0x4B, 0x03, 0x04, 0x01, 0x02, 0x03 }; - _store.PersistBaseline("sess1", data); - - var loaded = _store.LoadBaseline("sess1"); - Assert.Equal(data, loaded); - } - - [Fact] - public void PersistBaseline_LargeData_RoundTrips() - { - var data = new byte[500_000]; - new Random(42).NextBytes(data); - - _store.PersistBaseline("large", data); - var loaded = _store.LoadBaseline("large"); - - Assert.Equal(data.Length, loaded.Length); - Assert.Equal(data, loaded); - } - - [Fact] - public void PersistBaseline_Overwrite_ReplacesOldData() - { - _store.PersistBaseline("s1", new byte[] { 1, 2, 3 }); - _store.PersistBaseline("s1", new byte[] { 4, 5, 6, 7 }); - - var loaded = _store.LoadBaseline("s1"); - Assert.Equal(new byte[] { 4, 5, 6, 7 }, loaded); - } - - [Fact] - public void LoadBaseline_MissingFile_Throws() - { - Assert.ThrowsAny(() => _store.LoadBaseline("nonexistent")); - } - - [Fact] - public void PersistBaseline_CreatesDirectoryIfMissing() - { - Assert.False(Directory.Exists(_tempDir)); - _store.PersistBaseline("s1", new byte[] { 0xFF }); - Assert.True(File.Exists(_store.BaselinePath("s1"))); - } - - // --- DeleteSession tests --- - - [Fact] - public void DeleteSession_RemovesBothFiles() - { - _store.PersistBaseline("del1", new byte[] { 1, 2 }); - _store.GetOrCreateWal("del1"); - - Assert.True(File.Exists(_store.BaselinePath("del1"))); - Assert.True(File.Exists(_store.WalPath("del1"))); - - _store.DeleteSession("del1"); - - Assert.False(File.Exists(_store.BaselinePath("del1"))); - Assert.False(File.Exists(_store.WalPath("del1"))); - } - - [Fact] - public void DeleteSession_NonExistent_DoesNotThrow() - { - _store.DeleteSession("ghost"); // should not throw - } - - [Fact] - public void DeleteSession_AlsoRemovesCheckpoints() - { - _store.PersistBaseline("ck1", new byte[] { 1, 2 }); - _store.PersistCheckpoint("ck1", 10, new byte[] { 3, 4 }); - _store.PersistCheckpoint("ck1", 20, new byte[] { 5, 6 }); - _store.GetOrCreateWal("ck1"); - - _store.DeleteSession("ck1"); - - Assert.False(File.Exists(_store.CheckpointPath("ck1", 10))); - Assert.False(File.Exists(_store.CheckpointPath("ck1", 20))); - } - - // --- WAL integration with store --- - - [Fact] - public void AppendWal_AndReadWal_RoundTrips() - { - _store.AppendWal("w1", "[{\"op\":\"add\"}]"); - _store.AppendWal("w1", "[{\"op\":\"remove\"}]"); - - var patches = _store.ReadWal("w1"); - Assert.Equal(2, patches.Count); - Assert.Equal("[{\"op\":\"add\"}]", patches[0]); - Assert.Equal("[{\"op\":\"remove\"}]", patches[1]); - } - - [Fact] - public void WalEntryCount_TracksCorrectly() - { - Assert.Equal(0, _store.WalEntryCount("w2")); - - _store.AppendWal("w2", "[{\"op\":\"add\"}]"); - Assert.Equal(1, _store.WalEntryCount("w2")); - - _store.AppendWal("w2", "[{\"op\":\"remove\"}]"); - Assert.Equal(2, _store.WalEntryCount("w2")); - } - - [Fact] - public void TruncateWal_ClearsEntries() - { - _store.AppendWal("w3", "[{\"op\":\"add\"}]"); - _store.TruncateWal("w3"); - - Assert.Equal(0, _store.WalEntryCount("w3")); - Assert.Empty(_store.ReadWal("w3")); - } - - [Fact] - public void ReadWal_NoWalFile_ReturnsEmpty() - { - // GetOrCreateWal creates the file, but ReadWal on a fresh store should handle missing file - var patches = _store.ReadWal("nowal"); - Assert.Empty(patches); - } - - // --- JSON serialization tests --- - - [Fact] - public void SessionJsonContext_ProducesSnakeCaseKeys() - { - var entry = new SessionEntry - { - Id = "test", - SourcePath = "/path", - CreatedAt = DateTime.UtcNow, - LastModifiedAt = DateTime.UtcNow, - DocxFile = "test.docx", - WalCount = 3 - }; - - var index = new SessionIndexFile { Sessions = new() { entry } }; - var json = JsonSerializer.Serialize(index, SessionJsonContext.Default.SessionIndexFile); - - Assert.Contains("\"source_path\"", json); - Assert.Contains("\"created_at\"", json); - Assert.Contains("\"last_modified_at\"", json); - Assert.Contains("\"docx_file\"", json); - Assert.Contains("\"wal_count\"", json); - Assert.DoesNotContain("\"SourcePath\"", json); - Assert.DoesNotContain("\"WalCount\"", json); - } - - [Fact] - public void SessionJsonContext_IncludesCursorAndCheckpoints() - { - var entry = new SessionEntry - { - Id = "test", - DocxFile = "test.docx", - CursorPosition = 5, - CheckpointPositions = new() { 10, 20 } - }; - - var index = new SessionIndexFile { Sessions = new() { entry } }; - var json = JsonSerializer.Serialize(index, SessionJsonContext.Default.SessionIndexFile); - - Assert.Contains("\"cursor_position\"", json); - Assert.Contains("\"checkpoint_positions\"", json); - - var loaded = JsonSerializer.Deserialize(json, SessionJsonContext.Default.SessionIndexFile); - Assert.NotNull(loaded); - Assert.Equal(5, loaded!.Sessions[0].CursorPosition); - Assert.Equal(new List { 10, 20 }, loaded.Sessions[0].CheckpointPositions); - } - - [Fact] - public void WalJsonContext_ProducesSnakeCaseKeys() - { - var entry = new WalEntry { Patches = "[{\"op\":\"add\"}]" }; - var json = JsonSerializer.Serialize(entry, WalJsonContext.Default.WalEntry); - - Assert.Contains("\"patches\"", json); - Assert.DoesNotContain("\"Patches\"", json); - - var deserialized = JsonSerializer.Deserialize(json, WalJsonContext.Default.WalEntry); - Assert.NotNull(deserialized); - Assert.Equal("[{\"op\":\"add\"}]", deserialized!.Patches); - } - - [Fact] - public void WalJsonContext_IncludesTimestampAndDescription() - { - var entry = new WalEntry - { - Patches = "[]", - Timestamp = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc), - Description = "add /body/paragraph[0]" - }; - var json = JsonSerializer.Serialize(entry, WalJsonContext.Default.WalEntry); - - Assert.Contains("\"timestamp\"", json); - Assert.Contains("\"description\"", json); - - var deserialized = JsonSerializer.Deserialize(json, WalJsonContext.Default.WalEntry); - Assert.NotNull(deserialized); - Assert.Equal("add /body/paragraph[0]", deserialized!.Description); - } - - // --- Path helpers --- - - [Fact] - public void BaselinePath_IncludesSessionId() - { - var path = _store.BaselinePath("abc123"); - Assert.EndsWith("abc123.docx", path); - Assert.StartsWith(_tempDir, path); - } - - [Fact] - public void WalPath_IncludesSessionId() - { - var path = _store.WalPath("abc123"); - Assert.EndsWith("abc123.wal", path); - Assert.StartsWith(_tempDir, path); - } - - // --- Checkpoint tests --- - - [Fact] - public void CheckpointPath_Format() - { - var path = _store.CheckpointPath("sess1", 10); - Assert.EndsWith("sess1.ckpt.10.docx", path); - Assert.StartsWith(_tempDir, path); - } - - [Fact] - public void PersistAndLoadCheckpoint_RoundTrips() - { - var data = new byte[] { 0xAA, 0xBB, 0xCC }; - _store.PersistCheckpoint("ck1", 10, data); - - Assert.True(File.Exists(_store.CheckpointPath("ck1", 10))); - - // Load via LoadNearestCheckpoint - var (pos, bytes) = _store.LoadNearestCheckpoint("ck1", 10, new List { 10 }); - Assert.Equal(10, pos); - Assert.Equal(data, bytes); - } - - [Fact] - public void LoadNearestCheckpoint_SelectsNearest() - { - _store.PersistBaseline("ck2", new byte[] { 0x01 }); - _store.PersistCheckpoint("ck2", 10, new byte[] { 0x0A }); - _store.PersistCheckpoint("ck2", 20, new byte[] { 0x14 }); - - // Target 15: nearest <= 15 is 10 - var (pos, bytes) = _store.LoadNearestCheckpoint("ck2", 15, new List { 10, 20 }); - Assert.Equal(10, pos); - Assert.Equal(new byte[] { 0x0A }, bytes); - - // Target 25: nearest <= 25 is 20 - (pos, bytes) = _store.LoadNearestCheckpoint("ck2", 25, new List { 10, 20 }); - Assert.Equal(20, pos); - Assert.Equal(new byte[] { 0x14 }, bytes); - } - - [Fact] - public void LoadNearestCheckpoint_FallsBackToBaseline() - { - _store.PersistBaseline("ck3", new byte[] { 0xFF }); - _store.PersistCheckpoint("ck3", 10, new byte[] { 0x0A }); - - // Target 5: no checkpoint <= 5 (only 10), fallback to baseline - var (pos, bytes) = _store.LoadNearestCheckpoint("ck3", 5, new List { 10 }); - Assert.Equal(0, pos); - Assert.Equal(new byte[] { 0xFF }, bytes); - } - - [Fact] - public void DeleteCheckpoints_RemovesAll() - { - _store.PersistCheckpoint("ck4", 10, new byte[] { 1 }); - _store.PersistCheckpoint("ck4", 20, new byte[] { 2 }); - - Assert.True(File.Exists(_store.CheckpointPath("ck4", 10))); - Assert.True(File.Exists(_store.CheckpointPath("ck4", 20))); - - _store.DeleteCheckpoints("ck4"); - - Assert.False(File.Exists(_store.CheckpointPath("ck4", 10))); - Assert.False(File.Exists(_store.CheckpointPath("ck4", 20))); - } - - [Fact] - public void DeleteCheckpointsAfter_RemovesOnlyLater() - { - _store.PersistCheckpoint("ck5", 10, new byte[] { 1 }); - _store.PersistCheckpoint("ck5", 20, new byte[] { 2 }); - _store.PersistCheckpoint("ck5", 30, new byte[] { 3 }); - - _store.DeleteCheckpointsAfter("ck5", 15, new List { 10, 20, 30 }); - - Assert.True(File.Exists(_store.CheckpointPath("ck5", 10))); - Assert.False(File.Exists(_store.CheckpointPath("ck5", 20))); - Assert.False(File.Exists(_store.CheckpointPath("ck5", 30))); - } - - // --- ReadWalRange tests --- - - [Fact] - public void ReadWalRange_ReturnsSubset() - { - _store.AppendWal("wr1", "[{\"op\":\"add\"}]"); - _store.AppendWal("wr1", "[{\"op\":\"remove\"}]"); - _store.AppendWal("wr1", "[{\"op\":\"replace\"}]"); - - var range = _store.ReadWalRange("wr1", 1, 3); - Assert.Equal(2, range.Count); - Assert.Equal("[{\"op\":\"remove\"}]", range[0]); - Assert.Equal("[{\"op\":\"replace\"}]", range[1]); - } - - // --- TruncateWalAt tests --- - - [Fact] - public void TruncateWalAt_KeepsFirstN() - { - _store.AppendWal("tw1", "[{\"op\":\"add\"}]"); - _store.AppendWal("tw1", "[{\"op\":\"remove\"}]"); - _store.AppendWal("tw1", "[{\"op\":\"replace\"}]"); - - _store.TruncateWalAt("tw1", 2); - - Assert.Equal(2, _store.WalEntryCount("tw1")); - var patches = _store.ReadWal("tw1"); - Assert.Equal(2, patches.Count); - Assert.Equal("[{\"op\":\"add\"}]", patches[0]); - Assert.Equal("[{\"op\":\"remove\"}]", patches[1]); - } - - // --- AppendWal with description --- - - [Fact] - public void AppendWal_WithDescription_RoundTrips() - { - _store.AppendWal("wd1", "[{\"op\":\"add\"}]", "add paragraph"); - - var entries = _store.ReadWalEntries("wd1"); - Assert.Single(entries); - Assert.Equal("[{\"op\":\"add\"}]", entries[0].Patches); - Assert.Equal("add paragraph", entries[0].Description); - Assert.True(entries[0].Timestamp > DateTime.MinValue); - } - - // --- ReadWalEntries tests --- - - [Fact] - public void ReadWalEntries_ReturnsFullMetadata() - { - _store.AppendWal("we1", "[{\"op\":\"add\"}]", "first op"); - _store.AppendWal("we1", "[{\"op\":\"remove\"}]", "second op"); - - var entries = _store.ReadWalEntries("we1"); - Assert.Equal(2, entries.Count); - Assert.Equal("first op", entries[0].Description); - Assert.Equal("second op", entries[1].Description); - Assert.Equal("[{\"op\":\"add\"}]", entries[0].Patches); - Assert.Equal("[{\"op\":\"remove\"}]", entries[1].Patches); - } -} diff --git a/tests/DocxMcp.Tests/StyleTests.cs b/tests/DocxMcp.Tests/StyleTests.cs index f2fd1dc..ff66757 100644 --- a/tests/DocxMcp.Tests/StyleTests.cs +++ b/tests/DocxMcp.Tests/StyleTests.cs @@ -3,33 +3,17 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocxMcp.Helpers; -using DocxMcp.Persistence; +using DocxMcp.ExternalChanges; using DocxMcp.Tools; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace DocxMcp.Tests; -public class StyleTests : IDisposable +public class StyleTests { - private readonly string _tempDir; - private readonly SessionStore _store; - - public StyleTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - _store = new SessionStore(NullLogger.Instance, _tempDir); - } - - public void Dispose() - { - _store.Dispose(); - if (Directory.Exists(_tempDir)) - Directory.Delete(_tempDir, recursive: true); - } - - private SessionManager CreateManager() => - new SessionManager(_store, NullLogger.Instance); + private SessionManager CreateManager() => TestHelpers.CreateSessionManager(); + private SyncManager CreateSyncManager() => TestHelpers.CreateSyncManager(); + private ExternalChangeGate CreateGate() => TestHelpers.CreateExternalChangeGate(); private static string AddParagraphPatch(string text) => $"[{{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{{\"type\":\"paragraph\",\"text\":\"{text}\"}}}}]"; @@ -51,9 +35,9 @@ public void StyleElement_AddBold_PreservesItalic() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddStyledParagraphPatch("test", "{\"italic\":true}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddStyledParagraphPatch("test", "{\"italic\":true}")); - var result = StyleTools.StyleElement(mgr, id, "{\"bold\":true}"); + var result = StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"bold\":true}"); Assert.Contains("Styled", result); var run = mgr.Get(id).GetBody().Descendants().First(); @@ -68,9 +52,9 @@ public void StyleElement_RemoveBold() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddStyledParagraphPatch("test", "{\"bold\":true}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddStyledParagraphPatch("test", "{\"bold\":true}")); - StyleTools.StyleElement(mgr, id, "{\"bold\":false}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"bold\":false}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.Null(run.RunProperties?.Bold); @@ -83,9 +67,9 @@ public void StyleElement_SetColor() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleElement(mgr, id, "{\"color\":\"FF0000\"}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"color\":\"FF0000\"}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("FF0000", run.RunProperties?.Color?.Val?.Value); @@ -98,9 +82,9 @@ public void StyleElement_NullRemovesColor() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddStyledParagraphPatch("test", "{\"color\":\"00FF00\"}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddStyledParagraphPatch("test", "{\"color\":\"00FF00\"}")); - StyleTools.StyleElement(mgr, id, "{\"color\":null}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"color\":null}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.Null(run.RunProperties?.Color); @@ -113,9 +97,9 @@ public void StyleElement_SetFontSizeAndName() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleElement(mgr, id, "{\"font_size\":14,\"font_name\":\"Arial\"}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"font_size\":14,\"font_name\":\"Arial\"}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("28", run.RunProperties?.FontSize?.Val?.Value); // 14pt * 2 = 28 half-points @@ -129,9 +113,9 @@ public void StyleElement_SetHighlight() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleElement(mgr, id, "{\"highlight\":\"yellow\"}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"highlight\":\"yellow\"}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal(HighlightColorValues.Yellow, run.RunProperties?.Highlight?.Val?.Value); @@ -144,9 +128,9 @@ public void StyleElement_SetVerticalAlign() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleElement(mgr, id, "{\"vertical_align\":\"superscript\"}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"vertical_align\":\"superscript\"}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal(VerticalPositionValues.Superscript, run.RunProperties?.VerticalTextAlignment?.Val?.Value); @@ -159,9 +143,9 @@ public void StyleElement_SetUnderlineAndStrike() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleElement(mgr, id, "{\"underline\":true,\"strike\":true}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"underline\":true,\"strike\":true}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.NotNull(run.RunProperties?.Underline); @@ -180,12 +164,12 @@ public void StyleParagraph_Alignment_PreservesIndent() var id = session.Id; // Add paragraph, then set indent via patch - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); - PatchTool.ApplyPatch(mgr, null, id, + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, "[{\"op\":\"replace\",\"path\":\"/body/paragraph[0]/style\",\"value\":{\"indent_left\":720}}]"); // Now merge alignment — indent should be preserved - StyleTools.StyleParagraph(mgr, id, "{\"alignment\":\"center\"}", "/body/paragraph[0]"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"alignment\":\"center\"}", "/body/paragraph[0]"); var para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal(JustificationValues.Center, para.ParagraphProperties?.Justification?.Val?.Value); @@ -199,16 +183,16 @@ public void StyleParagraph_CompoundSpacingMerge() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); // Set spacing_before - StyleTools.StyleParagraph(mgr, id, "{\"spacing_before\":200}", "/body/paragraph[0]"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"spacing_before\":200}", "/body/paragraph[0]"); var para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("200", para.ParagraphProperties?.SpacingBetweenLines?.Before?.Value); // Now set spacing_after — spacing_before should be preserved - StyleTools.StyleParagraph(mgr, id, "{\"spacing_after\":100}", "/body/paragraph[0]"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"spacing_after\":100}", "/body/paragraph[0]"); para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("200", para.ParagraphProperties?.SpacingBetweenLines?.Before?.Value); @@ -222,9 +206,9 @@ public void StyleParagraph_Shading() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleParagraph(mgr, id, "{\"shading\":\"FFFF00\"}"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"shading\":\"FFFF00\"}"); var para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("FFFF00", para.ParagraphProperties?.Shading?.Fill?.Value); @@ -237,9 +221,9 @@ public void StyleParagraph_SetParagraphStyle() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleParagraph(mgr, id, "{\"style\":\"Heading1\"}"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"style\":\"Heading1\"}"); var para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("Heading1", para.ParagraphProperties?.ParagraphStyleId?.Val?.Value); @@ -252,10 +236,10 @@ public void StyleParagraph_CompoundIndentMerge() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleParagraph(mgr, id, "{\"indent_left\":720}", "/body/paragraph[0]"); - StyleTools.StyleParagraph(mgr, id, "{\"indent_first_line\":360}", "/body/paragraph[0]"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"indent_left\":720}", "/body/paragraph[0]"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"indent_first_line\":360}", "/body/paragraph[0]"); var para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal("720", para.ParagraphProperties?.Indentation?.Left?.Value); @@ -273,9 +257,9 @@ public void StyleTable_BorderStyle() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, style: "{\"border_style\":\"double\"}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, style: "{\"border_style\":\"double\"}"); var table = mgr.Get(id).GetBody().Descendants
    ().First(); var borders = table.GetFirstChild()?.TableBorders; @@ -290,9 +274,9 @@ public void StyleTable_CellShadingOnAllCells() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, cell_style: "{\"shading\":\"F0F0F0\"}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, cell_style: "{\"shading\":\"F0F0F0\"}"); var cells = mgr.Get(id).GetBody().Descendants().ToList(); Assert.True(cells.Count >= 4); // headers + data @@ -309,9 +293,9 @@ public void StyleTable_RowHeight() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, row_style: "{\"height\":400}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, row_style: "{\"height\":400}"); var rows = mgr.Get(id).GetBody().Descendants().ToList(); foreach (var row in rows) @@ -329,9 +313,9 @@ public void StyleTable_IsHeader() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, row_style: "{\"is_header\":true}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, row_style: "{\"is_header\":true}"); var rows = mgr.Get(id).GetBody().Descendants().ToList(); foreach (var row in rows) @@ -347,9 +331,9 @@ public void StyleTable_TableAlignment() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, style: "{\"table_alignment\":\"center\"}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, style: "{\"table_alignment\":\"center\"}"); var table = mgr.Get(id).GetBody().Descendants
    ().First(); var props = table.GetFirstChild(); @@ -363,9 +347,9 @@ public void StyleTable_CellVerticalAlign() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, cell_style: "{\"vertical_align\":\"center\"}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, cell_style: "{\"vertical_align\":\"center\"}"); var cells = mgr.Get(id).GetBody().Descendants().ToList(); foreach (var cell in cells) @@ -386,10 +370,10 @@ public void StyleElement_NoPath_StylesAllRunsIncludingTables() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("body text")); - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("body text")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleElement(mgr, id, "{\"bold\":true}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"bold\":true}"); var runs = mgr.Get(id).GetBody().Descendants().ToList(); Assert.True(runs.Count > 1); @@ -406,10 +390,10 @@ public void StyleParagraph_NoPath_StylesAllParagraphsIncludingTables() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("body text")); - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("body text")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleParagraph(mgr, id, "{\"alignment\":\"center\"}"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"alignment\":\"center\"}"); var paragraphs = mgr.Get(id).GetBody().Descendants().ToList(); Assert.True(paragraphs.Count > 1); @@ -430,10 +414,10 @@ public void StyleElement_WildcardPath_StylesMatchedRuns() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("first")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("second")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("first")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("second")); - StyleTools.StyleElement(mgr, id, "{\"italic\":true}", "/body/paragraph[*]"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"italic\":true}", "/body/paragraph[*]"); var runs = mgr.Get(id).GetBody().Descendants().ToList(); foreach (var run in runs) @@ -453,10 +437,10 @@ public void StyleElement_UndoRedo_RoundTrip() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); // Style it bold - StyleTools.StyleElement(mgr, id, "{\"bold\":true}"); + StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"bold\":true}"); var run = mgr.Get(id).GetBody().Descendants().First(); Assert.NotNull(run.RunProperties?.Bold); @@ -478,9 +462,9 @@ public void StyleParagraph_UndoRedo_RoundTrip() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - StyleTools.StyleParagraph(mgr, id, "{\"alignment\":\"right\"}"); + StyleTools.StyleParagraph(mgr, CreateSyncManager(), id, "{\"alignment\":\"right\"}"); var para = mgr.Get(id).GetBody().Descendants().First(); Assert.Equal(JustificationValues.Right, para.ParagraphProperties?.Justification?.Val?.Value); @@ -500,9 +484,9 @@ public void StyleTable_UndoRedo_RoundTrip() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddTablePatch()); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddTablePatch()); - StyleTools.StyleTable(mgr, id, style: "{\"border_style\":\"double\"}"); + StyleTools.StyleTable(mgr, CreateSyncManager(), id, style: "{\"border_style\":\"double\"}"); var table = mgr.Get(id).GetBody().Descendants
    ().First(); Assert.Equal(BorderValues.Double, table.GetFirstChild()?.TableBorders?.TopBorder?.Val?.Value); @@ -523,66 +507,58 @@ public void StyleTable_UndoRedo_RoundTrip() [Fact] public void StyleElement_PersistsThroughRestart() { - var mgr1 = CreateManager(); + // Use same tenant for both managers + var tenantId = $"test-style-persist-{Guid.NewGuid():N}"; + var mgr1 = TestHelpers.CreateSessionManager(tenantId); var session = mgr1.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr1, null, id, AddParagraphPatch("persist")); - StyleTools.StyleElement(mgr1, id, "{\"bold\":true,\"color\":\"00FF00\"}"); + PatchTool.ApplyPatch(mgr1, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("persist")); + StyleTools.StyleElement(mgr1, CreateSyncManager(), id, "{\"bold\":true,\"color\":\"00FF00\"}"); - // Simulate restart - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); - mgr2.RestoreSessions(); + // Simulate restart: create new manager with same tenant (stateless) + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - var run = mgr2.Get(id).GetBody().Descendants().First(); + using var restored = mgr2.Get(id); + var run = restored.GetBody().Descendants().First(); Assert.NotNull(run.RunProperties?.Bold); Assert.Equal("00FF00", run.RunProperties?.Color?.Val?.Value); - - store2.Dispose(); } [Fact] public void StyleParagraph_PersistsThroughRestart() { - var mgr1 = CreateManager(); + var tenantId = $"test-para-persist-{Guid.NewGuid():N}"; + var mgr1 = TestHelpers.CreateSessionManager(tenantId); var session = mgr1.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr1, null, id, AddParagraphPatch("persist")); - StyleTools.StyleParagraph(mgr1, id, "{\"alignment\":\"center\"}"); + PatchTool.ApplyPatch(mgr1, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("persist")); + StyleTools.StyleParagraph(mgr1, CreateSyncManager(), id, "{\"alignment\":\"center\"}"); - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); - mgr2.RestoreSessions(); + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - var para = mgr2.Get(id).GetBody().Descendants().First(); + using var restored = mgr2.Get(id); + var para = restored.GetBody().Descendants().First(); Assert.Equal(JustificationValues.Center, para.ParagraphProperties?.Justification?.Val?.Value); - - store2.Dispose(); } [Fact] public void StyleTable_PersistsThroughRestart() { - var mgr1 = CreateManager(); + var tenantId = $"test-table-persist-{Guid.NewGuid():N}"; + var mgr1 = TestHelpers.CreateSessionManager(tenantId); var session = mgr1.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr1, null, id, AddTablePatch()); - StyleTools.StyleTable(mgr1, id, cell_style: "{\"shading\":\"AABBCC\"}"); + PatchTool.ApplyPatch(mgr1, CreateSyncManager(), CreateGate(), id, AddTablePatch()); + StyleTools.StyleTable(mgr1, CreateSyncManager(), id, cell_style: "{\"shading\":\"AABBCC\"}"); - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); - mgr2.RestoreSessions(); + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - var cell = mgr2.Get(id).GetBody().Descendants().First(); + using var restored = mgr2.Get(id); + var cell = restored.GetBody().Descendants().First(); Assert.Equal("AABBCC", cell.GetFirstChild()?.Shading?.Fill?.Value); - - store2.Dispose(); } // ========================= @@ -596,7 +572,7 @@ public void StyleElement_InvalidJson_ReturnsError() var session = mgr.Create(); var id = session.Id; - var result = StyleTools.StyleElement(mgr, id, "not json"); + var result = StyleTools.StyleElement(mgr, CreateSyncManager(), id, "not json"); Assert.StartsWith("Error:", result); } @@ -607,9 +583,9 @@ public void StyleElement_BadPath_ReturnsError() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), CreateGate(), id, AddParagraphPatch("test")); - var result = StyleTools.StyleElement(mgr, id, "{\"bold\":true}", "/body/paragraph[99]"); + var result = StyleTools.StyleElement(mgr, CreateSyncManager(), id, "{\"bold\":true}", "/body/paragraph[99]"); Assert.StartsWith("Error:", result); } @@ -620,7 +596,7 @@ public void StyleTable_AllNullStyles_ReturnsError() var session = mgr.Create(); var id = session.Id; - var result = StyleTools.StyleTable(mgr, id); + var result = StyleTools.StyleTable(mgr, CreateSyncManager(), id); Assert.StartsWith("Error:", result); } @@ -631,7 +607,7 @@ public void StyleElement_NotObject_ReturnsError() var session = mgr.Create(); var id = session.Id; - var result = StyleTools.StyleElement(mgr, id, "42"); + var result = StyleTools.StyleElement(mgr, CreateSyncManager(), id, "42"); Assert.Contains("must be a JSON object", result); } } diff --git a/tests/DocxMcp.Tests/SyncDuplicateTests.cs b/tests/DocxMcp.Tests/SyncDuplicateTests.cs index d50c68d..9d639f4 100644 --- a/tests/DocxMcp.Tests/SyncDuplicateTests.cs +++ b/tests/DocxMcp.Tests/SyncDuplicateTests.cs @@ -2,8 +2,8 @@ using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocxMcp.ExternalChanges; -using DocxMcp.Persistence; -using Microsoft.Extensions.Logging.Abstractions; +using DocxMcp.Grpc; +using DocxMcp.Tools; using Xunit; namespace DocxMcp.Tests; @@ -18,9 +18,8 @@ public class SyncDuplicateTests : IDisposable { private readonly string _tempDir; private readonly string _tempFile; - private readonly SessionStore _store; + private readonly string _tenantId; private readonly SessionManager _sessionManager; - private readonly ExternalChangeTracker _tracker; public SyncDuplicateTests() { @@ -32,9 +31,8 @@ public SyncDuplicateTests() // Create test document CreateTestDocx(_tempFile, "Test content"); - _store = new SessionStore(NullLogger.Instance, _tempDir); - _sessionManager = new SessionManager(_store, NullLogger.Instance); - _tracker = new ExternalChangeTracker(_sessionManager, NullLogger.Instance); + _tenantId = $"test-sync-dup-{Guid.NewGuid():N}"; + _sessionManager = TestHelpers.CreateSessionManager(_tenantId); } [Fact] @@ -44,10 +42,10 @@ public void SyncExternalChanges_CalledTwice_OnlyCreatesOneWalEntry() var session = _sessionManager.Open(_tempFile); // Act - first sync (may or may not have changes depending on ID assignment) - var result1 = _tracker.SyncExternalChanges(session.Id); + var result1 = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Act - second sync (should NOT create a new entry) - var result2 = _tracker.SyncExternalChanges(session.Id); + var result2 = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.False(result2.HasChanges, "Second sync should report no changes"); @@ -64,9 +62,9 @@ public void SyncExternalChanges_CalledThreeTimes_OnlyCreatesOneWalEntry() var session = _sessionManager.Open(_tempFile); // Act - _tracker.SyncExternalChanges(session.Id); - var result2 = _tracker.SyncExternalChanges(session.Id); - var result3 = _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); + var result2 = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); + var result3 = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.False(result2.HasChanges, "Second sync should report no changes"); @@ -78,14 +76,14 @@ public void SyncExternalChanges_AfterFileModified_CreatesNewEntry() { // Arrange var session = _sessionManager.Open(_tempFile); - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Modify the external file Thread.Sleep(100); // Ensure different timestamp ModifyTestDocx(_tempFile, "Modified content"); // Act - var result = _tracker.SyncExternalChanges(session.Id); + var result = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.True(result.HasChanges, "Sync after file modification should have changes"); @@ -98,16 +96,16 @@ public void SyncExternalChanges_AfterModifyThenNoChange_LastSyncHasNoChanges() var session = _sessionManager.Open(_tempFile); // First sync (no changes or initial sync) - _tracker.SyncExternalChanges(session.Id); + ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Modify and sync Thread.Sleep(100); ModifyTestDocx(_tempFile, "Modified"); - var modifyResult = _tracker.SyncExternalChanges(session.Id); + var modifyResult = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); Assert.True(modifyResult.HasChanges); // Sync again without changes - var noChangeResult = _tracker.SyncExternalChanges(session.Id); + var noChangeResult = ExternalChangeTools.PerformSync(_sessionManager, session.Id, isImport: false); // Assert Assert.False(noChangeResult.HasChanges, "Sync after modification sync without further changes should have no changes"); @@ -219,33 +217,25 @@ public void RestoreSessions_WithExternalSyncCheckpoint_RestoresFromCheckpoint() // Sync external (creates checkpoint with new content) Thread.Sleep(100); ModifyTestDocx(_tempFile, "New content from external"); - var syncResult = _tracker.SyncExternalChanges(sessionId); + var syncResult = ExternalChangeTools.PerformSync(_sessionManager, sessionId, isImport: false); Assert.True(syncResult.HasChanges, "Sync should detect changes"); // Verify synced content is in memory - var syncedText = GetParagraphText(_sessionManager.Get(sessionId)); + using var syncedSession = _sessionManager.Get(sessionId); + var syncedText = GetParagraphText(syncedSession); Assert.Contains("New content from external", syncedText); - // Simulate server restart by creating a new SessionManager - // (keep the same store to share the persisted data) - var newSessionManager = new SessionManager(_store, NullLogger.Instance); - var newTracker = new ExternalChangeTracker(newSessionManager, NullLogger.Instance); + // Simulate server restart by creating a new SessionManager with same tenant (stateless, no RestoreSessions needed) + var newSessionManager = TestHelpers.CreateSessionManager(_tenantId); - // Act - restore sessions - var restoredCount = newSessionManager.RestoreSessions(); - - // Assert - should have restored the session with checkpoint content - Assert.Equal(1, restoredCount); - var restoredSession = newSessionManager.Get(sessionId); + // Assert - session is accessible via stateless Get (loads from gRPC checkpoint) + using var restoredSession = newSessionManager.Get(sessionId); var restoredText = GetParagraphText(restoredSession); Assert.Contains("New content from external", restoredText); // Additional check: syncing again should NOT create a new WAL entry - var secondSyncResult = newTracker.SyncExternalChanges(sessionId); + var secondSyncResult = ExternalChangeTools.PerformSync(newSessionManager, sessionId, isImport: false); Assert.False(secondSyncResult.HasChanges, "Sync after restore should report no changes"); - - // Cleanup the new tracker - newTracker.Dispose(); } [Fact] @@ -262,29 +252,24 @@ public void RestoreSessions_ThenSync_NoDuplicateWalEntries() // Create external sync entry Thread.Sleep(100); ModifyTestDocx(_tempFile, "Externally modified content"); - _tracker.SyncExternalChanges(sessionId); + ExternalChangeTools.PerformSync(_sessionManager, sessionId, isImport: false); var historyBefore = _sessionManager.GetHistory(sessionId); var syncEntriesBefore = historyBefore.Entries.Count(e => e.IsExternalSync); - // Simulate server restart - var newSessionManager = new SessionManager(_store, NullLogger.Instance); - var newTracker = new ExternalChangeTracker(newSessionManager, NullLogger.Instance); - newSessionManager.RestoreSessions(); + // Simulate server restart with same tenant (stateless, no restore needed) + var newSessionManager = TestHelpers.CreateSessionManager(_tenantId); // Act - sync multiple times after restart - newTracker.SyncExternalChanges(sessionId); - newTracker.SyncExternalChanges(sessionId); - newTracker.SyncExternalChanges(sessionId); + ExternalChangeTools.PerformSync(newSessionManager, sessionId, isImport: false); + ExternalChangeTools.PerformSync(newSessionManager, sessionId, isImport: false); + ExternalChangeTools.PerformSync(newSessionManager, sessionId, isImport: false); // Assert - should still have the same number of sync entries var historyAfter = newSessionManager.GetHistory(sessionId); var syncEntriesAfter = historyAfter.Entries.Count(e => e.IsExternalSync); Assert.Equal(syncEntriesBefore, syncEntriesAfter); - - // Cleanup - newTracker.Dispose(); } #region Helpers @@ -330,8 +315,6 @@ private static string GetParagraphText(DocxSession session) public void Dispose() { - _tracker.Dispose(); - // Close any open sessions foreach (var (id, _) in _sessionManager.List().ToList()) { @@ -339,7 +322,10 @@ public void Dispose() catch { /* ignore */ } } - try { Directory.Delete(_tempDir, true); } - catch { /* ignore */ } + if (Directory.Exists(_tempDir)) + { + try { Directory.Delete(_tempDir, true); } + catch { /* ignore */ } + } } } diff --git a/tests/DocxMcp.Tests/TableModificationTests.cs b/tests/DocxMcp.Tests/TableModificationTests.cs index 5e3b978..6922ab2 100644 --- a/tests/DocxMcp.Tests/TableModificationTests.cs +++ b/tests/DocxMcp.Tests/TableModificationTests.cs @@ -3,6 +3,7 @@ using DocumentFormat.OpenXml.Wordprocessing; using DocxMcp.Helpers; using DocxMcp.Paths; +using DocxMcp.ExternalChanges; using DocxMcp.Tools; using System.Text.Json; using Xunit; @@ -13,10 +14,13 @@ public class TableModificationTests : IDisposable { private readonly DocxSession _session; private readonly SessionManager _sessions; + private readonly SyncManager _sync; + private readonly ExternalChangeGate _gate = TestHelpers.CreateExternalChangeGate(); public TableModificationTests() { _sessions = TestHelpers.CreateSessionManager(); + _sync = TestHelpers.CreateSyncManager(); _session = _sessions.Create(); var body = _session.GetBody(); @@ -63,6 +67,8 @@ public TableModificationTests() new TableCell(new Paragraph(new Run(new Text("London")))))); body.AppendChild(table); + + TestHelpers.PersistBaseline(_sessions, _session); } // =========================== @@ -462,12 +468,13 @@ public void CreateTableWithRowHeight() [Fact] public void RemoveTableRow() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """[{"op": "remove", "path": "/body/table[0]/row[2]"}]"""); Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var rows = table.Elements().ToList(); Assert.Equal(2, rows.Count); // header + 1 data row (removed "Bob" row) } @@ -475,12 +482,13 @@ public void RemoveTableRow() [Fact] public void RemoveTableCell() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """[{"op": "remove", "path": "/body/table[0]/row[1]/cell[2]"}]"""); Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var row = table.Elements().ElementAt(1); var cells = row.Elements().ToList(); Assert.Equal(2, cells.Count); // "Alice", "30" (removed "Paris") @@ -489,7 +497,7 @@ public void RemoveTableCell() [Fact] public void ReplaceTableCell() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "replace", "path": "/body/table[0]/row[1]/cell[0]", @@ -504,7 +512,8 @@ public void ReplaceTableCell() Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var cell = table.Elements().ElementAt(1).Elements().First(); Assert.Equal("Alice Smith", cell.InnerText); Assert.Equal("E0FFE0", cell.TableCellProperties?.Shading?.Fill?.Value); @@ -513,7 +522,7 @@ public void ReplaceTableCell() [Fact] public void ReplaceTableRow() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "replace", "path": "/body/table[0]/row[2]", @@ -530,7 +539,8 @@ public void ReplaceTableRow() Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var row = table.Elements().Last(); var cells = row.Elements().ToList(); Assert.Equal("Charlie", cells[0].InnerText); @@ -541,12 +551,13 @@ public void ReplaceTableRow() [Fact] public void RemoveColumn() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """[{"op": "remove_column", "path": "/body/table[0]", "column": 1}]"""); Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); foreach (var row in table.Elements()) { var cells = row.Elements().ToList(); @@ -562,12 +573,13 @@ public void RemoveColumn() [Fact] public void RemoveFirstColumn() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """[{"op": "remove_column", "path": "/body/table[0]", "column": 0}]"""); Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var headerCells = table.Elements().First().Elements().ToList(); Assert.Equal("Age", headerCells[0].InnerText); Assert.Equal("City", headerCells[1].InnerText); @@ -576,12 +588,13 @@ public void RemoveFirstColumn() [Fact] public void RemoveLastColumn() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """[{"op": "remove_column", "path": "/body/table[0]", "column": 2}]"""); Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var headerCells = table.Elements().First().Elements().ToList(); Assert.Equal("Name", headerCells[0].InnerText); Assert.Equal("Age", headerCells[1].InnerText); @@ -600,8 +613,9 @@ public void ReplaceTextPreservesFormatting() new RunProperties(new Italic()), new Text(" is great") { Space = SpaceProcessingModeValues.Preserve })); body.AppendChild(p); + TestHelpers.PersistBaseline(_sessions, _session); - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "replace_text", "path": "/body/paragraph[text~='Hello World']", @@ -612,8 +626,12 @@ public void ReplaceTextPreservesFormatting() Assert.Contains("\"success\": true", result); + // Reload from gRPC to see the patched state + using var reloaded = _sessions.Get(_session.Id); + var reloadedBody = reloaded.GetBody(); + // Find the paragraph that was modified - var modified = body.Elements() + var modified = reloadedBody.Elements() .FirstOrDefault(par => par.InnerText.Contains("Universe")); Assert.NotNull(modified); @@ -631,7 +649,7 @@ public void ReplaceTextPreservesFormatting() [Fact] public void ReplaceTextInTableCell() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "replace_text", "path": "/body/table[0]/row[1]/cell[0]", @@ -642,7 +660,8 @@ public void ReplaceTextInTableCell() Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var cell = table.Elements().ElementAt(1).Elements().First(); Assert.Equal("Eve", cell.InnerText); } @@ -651,7 +670,7 @@ public void ReplaceTextInTableCell() public void AddRowToExistingTable() { // Add a new row after the last row - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "add", "path": "/body/table[0]", @@ -664,7 +683,8 @@ public void AddRowToExistingTable() Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var rows = table.Elements().ToList(); Assert.Equal(4, rows.Count); // header + 3 data rows @@ -676,7 +696,7 @@ public void AddRowToExistingTable() public void AddStyledCellToRow() { // Add a new cell to the first data row - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "add", "path": "/body/table[0]/row[1]", @@ -690,7 +710,8 @@ public void AddStyledCellToRow() Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var row = table.Elements().ElementAt(1); var cells = row.Elements().ToList(); Assert.Equal(4, cells.Count); // original 3 + new cell @@ -736,6 +757,8 @@ public void QueryCellReturnsProperties() new Shading { Fill = "AABBCC", Val = ShadingPatternValues.Clear }, new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center }); + TestHelpers.PersistBaseline(_sessions, _session); + var result = QueryTool.Query(_sessions, _session.Id, "/body/table[0]/row[1]/cell[0]"); using var doc = JsonDocument.Parse(result); @@ -758,6 +781,8 @@ public void QueryTableReturnsTableProperties() tblProps.TableWidth = new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct }; tblProps.TableJustification = new TableJustification { Val = TableRowAlignmentValues.Center }; + TestHelpers.PersistBaseline(_sessions, _session); + var result = QueryTool.Query(_sessions, _session.Id, "/body/table[0]"); using var doc = JsonDocument.Parse(result); var root = doc.RootElement; @@ -770,7 +795,7 @@ public void QueryTableReturnsTableProperties() [Fact] public void ReplaceTableProperties() { - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [{ "op": "replace", "path": "/body/table[0]/style", @@ -784,7 +809,8 @@ public void ReplaceTableProperties() Assert.Contains("\"success\": true", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); var tblProps = table.GetFirstChild(); Assert.NotNull(tblProps); Assert.Equal(BorderValues.Double, tblProps!.TableBorders?.TopBorder?.Val?.Value); @@ -799,7 +825,7 @@ public void MultiplePatchOperationsOnTable() // 1. Replace header cell text // 2. Remove a column // 3. Add a new row - var result = PatchTool.ApplyPatch(_sessions, null, _session.Id, """ + var result = PatchTool.ApplyPatch(_sessions, _sync, _gate, _session.Id, """ [ { "op": "replace_text", @@ -818,7 +844,8 @@ public void MultiplePatchOperationsOnTable() Assert.Contains("\"success\": true", result); Assert.Contains("\"applied\": 2", result); - var table = _session.GetBody().Elements
    ().First(); + using var reloaded = _sessions.Get(_session.Id); + var table = reloaded.GetBody().Elements
    ().First(); // Verify header text changed var headerCells = table.Elements().First().Elements().ToList(); diff --git a/tests/DocxMcp.Tests/TestHelpers.cs b/tests/DocxMcp.Tests/TestHelpers.cs index 733da6c..04fd33b 100644 --- a/tests/DocxMcp.Tests/TestHelpers.cs +++ b/tests/DocxMcp.Tests/TestHelpers.cs @@ -1,18 +1,178 @@ -using DocxMcp.Persistence; +using DocxMcp.ExternalChanges; +using DocxMcp.Grpc; using Microsoft.Extensions.Logging.Abstractions; namespace DocxMcp.Tests; internal static class TestHelpers { + private static IHistoryStorage? _sharedHistoryStorage; + private static ISyncStorage? _sharedSyncStorage; + private static readonly object _lock = new(); + private static string? _testStorageDir; + /// - /// Create a SessionManager backed by a temporary directory for testing. - /// Each call creates a unique temp directory so tests don't interfere. + /// Create a SessionManager backed by the gRPC storage server. + /// Auto-launches the Rust storage server if not already running. + /// Uses a unique tenant ID per test to ensure isolation. /// public static SessionManager CreateSessionManager() { - var tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - var store = new SessionStore(NullLogger.Instance, tempDir); - return new SessionManager(store, NullLogger.Instance); + var historyStorage = GetOrCreateHistoryStorage(); + + // Use unique tenant per test for isolation + var tenantId = $"test-{Guid.NewGuid():N}"; + + return new SessionManager(historyStorage, NullLogger.Instance, tenantId); + } + + /// + /// Create a SessionManager with a specific tenant ID (for multi-tenant tests). + /// The tenant ID is captured at construction time, ensuring thread-safety + /// even when used across parallel operations. + /// + public static SessionManager CreateSessionManager(string tenantId) + { + var historyStorage = GetOrCreateHistoryStorage(); + return new SessionManager(historyStorage, NullLogger.Instance, tenantId); + } + + /// + /// Create an ExternalChangeGate backed by the shared gRPC history storage. + /// + public static ExternalChangeGate CreateExternalChangeGate() + { + var historyStorage = GetOrCreateHistoryStorage(); + return new ExternalChangeGate(historyStorage); + } + + /// + /// Create a SyncManager backed by the gRPC sync storage. + /// + public static SyncManager CreateSyncManager() + { + var syncStorage = GetOrCreateSyncStorage(); + return new SyncManager(syncStorage, NullLogger.Instance); + } + + /// + /// Get or create a shared history storage client. + /// The Rust gRPC server is auto-launched via Unix socket if not running. + /// + public static IHistoryStorage GetOrCreateHistoryStorage() + { + if (_sharedHistoryStorage != null) + return _sharedHistoryStorage; + + lock (_lock) + { + if (_sharedHistoryStorage != null) + return _sharedHistoryStorage; + + EnsureStorageInitialized(); + return _sharedHistoryStorage!; + } + } + + /// + /// Get or create a shared sync storage client. + /// + public static ISyncStorage GetOrCreateSyncStorage() + { + if (_sharedSyncStorage != null) + return _sharedSyncStorage; + + lock (_lock) + { + if (_sharedSyncStorage != null) + return _sharedSyncStorage; + + EnsureStorageInitialized(); + return _sharedSyncStorage!; + } + } + + private static void EnsureStorageInitialized() + { + if (_sharedHistoryStorage != null && _sharedSyncStorage != null) + return; + + // Use a temporary directory for test isolation + _testStorageDir = Path.Combine(Path.GetTempPath(), $"docx-mcp-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testStorageDir); + + var options = StorageClientOptions.FromEnvironment(); + options.LocalStorageDir = _testStorageDir; + + if (!string.IsNullOrEmpty(options.ServerUrl)) + { + // Dual-server mode: history → remote STORAGE_GRPC_URL, sync → local embedded + // CreateChannelAsync already applies retry policy internally + var remoteChannel = HistoryStorageClient.CreateChannelAsync(options, launcher: null) + .GetAwaiter().GetResult(); + _sharedHistoryStorage = new HistoryStorageClient(remoteChannel, NullLogger.Instance); + + // Local embedded server for sync (always local file operations) + var localOptions = new StorageClientOptions { LocalStorageDir = _testStorageDir }; + var localLauncher = new GrpcLauncher(localOptions, NullLogger.Instance); + var localChannel = HistoryStorageClient.CreateChannelAsync(localOptions, localLauncher) + .GetAwaiter().GetResult(); + _sharedSyncStorage = new SyncStorageClient(localChannel, NullLogger.Instance); + } + else + { + // Embedded mode: single local server for both + // CreateChannelAsync already applies retry policy internally + var launcher = new GrpcLauncher(options, NullLogger.Instance); + var channel = HistoryStorageClient.CreateChannelAsync(options, launcher) + .GetAwaiter().GetResult(); + _sharedHistoryStorage = new HistoryStorageClient(channel, NullLogger.Instance); + _sharedSyncStorage = new SyncStorageClient(channel, NullLogger.Instance); + } + } + + /// + /// After modifying a session's document in-memory during test setup, + /// call this to persist the current state as the baseline in gRPC storage. + /// This is necessary because Get(id) loads from gRPC (stateless). + /// + public static void PersistBaseline(SessionManager sessions, DocxSession session) + { + var bytes = session.ToBytes(); + var history = GetOrCreateHistoryStorage(); + history.SaveSessionAsync(sessions.TenantId, session.Id, bytes).GetAwaiter().GetResult(); + } + + /// + /// Cleanup: dispose the shared storage clients and remove temp directory. + /// Call this in test cleanup if needed. + /// + public static async Task DisposeStorageAsync() + { + if (_sharedHistoryStorage != null) + { + await _sharedHistoryStorage.DisposeAsync(); + _sharedHistoryStorage = null; + } + + if (_sharedSyncStorage != null) + { + await _sharedSyncStorage.DisposeAsync(); + _sharedSyncStorage = null; + } + + // Clean up temp directory + if (_testStorageDir != null && Directory.Exists(_testStorageDir)) + { + try + { + Directory.Delete(_testStorageDir, recursive: true); + } + catch + { + // Ignore cleanup errors + } + _testStorageDir = null; + } } } diff --git a/tests/DocxMcp.Tests/UndoRedoTests.cs b/tests/DocxMcp.Tests/UndoRedoTests.cs index 49fb775..b223ade 100644 --- a/tests/DocxMcp.Tests/UndoRedoTests.cs +++ b/tests/DocxMcp.Tests/UndoRedoTests.cs @@ -1,7 +1,6 @@ using DocumentFormat.OpenXml.Wordprocessing; -using DocxMcp.Persistence; +using DocxMcp.ExternalChanges; using DocxMcp.Tools; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace DocxMcp.Tests; @@ -9,23 +8,22 @@ namespace DocxMcp.Tests; public class UndoRedoTests : IDisposable { private readonly string _tempDir; - private readonly SessionStore _store; public UndoRedoTests() { _tempDir = Path.Combine(Path.GetTempPath(), "docx-mcp-tests", Guid.NewGuid().ToString("N")); - _store = new SessionStore(NullLogger.Instance, _tempDir); + Directory.CreateDirectory(_tempDir); } public void Dispose() { - _store.Dispose(); if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } - private SessionManager CreateManager() => - new SessionManager(_store, NullLogger.Instance); + private SessionManager CreateManager() => TestHelpers.CreateSessionManager(); + + private SyncManager CreateSyncManager() => TestHelpers.CreateSyncManager(); private static string AddParagraphPatch(string text) => $"[{{\"op\":\"add\",\"path\":\"/body/children/0\",\"value\":{{\"type\":\"paragraph\",\"text\":\"{text}\"}}}}]"; @@ -39,16 +37,19 @@ public void Undo_SingleStep_RestoresState() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("First")); - Assert.Contains("First", session.GetBody().InnerText); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("First")); + + // Reload from gRPC to verify patch was applied + using (var afterPatch = mgr.Get(id)) + Assert.Contains("First", afterPatch.GetBody().InnerText); var result = mgr.Undo(id); Assert.Equal(0, result.Position); Assert.Equal(1, result.Steps); // Document should be back to empty baseline - var body = mgr.Get(id).GetBody(); - Assert.DoesNotContain("First", body.InnerText); + using var afterUndo = mgr.Get(id); + Assert.DoesNotContain("First", afterUndo.GetBody().InnerText); } [Fact] @@ -58,9 +59,9 @@ public void Undo_MultipleSteps_RestoresEarlierState() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("C")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("C")); var result = mgr.Undo(id, 2); Assert.Equal(1, result.Position); @@ -92,8 +93,8 @@ public void Undo_BeyondBeginning_ClampsToZero() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); var result = mgr.Undo(id, 100); Assert.Equal(0, result.Position); @@ -109,7 +110,7 @@ public void Redo_SingleStep_ReappliesPatch() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Hello")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Hello")); mgr.Undo(id); // After undo, document should not contain "Hello" @@ -129,9 +130,9 @@ public void Redo_MultipleSteps_ReappliesAll() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("C")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("C")); mgr.Undo(id, 3); Assert.DoesNotContain("A", mgr.Get(id).GetBody().InnerText); @@ -152,7 +153,7 @@ public void Redo_AtEnd_ReturnsZeroSteps() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); // No undo happened, so redo should do nothing var result = mgr.Redo(id); @@ -167,8 +168,8 @@ public void Redo_BeyondEnd_ClampsToCurrent() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); mgr.Undo(id, 2); var result = mgr.Redo(id, 100); @@ -185,15 +186,15 @@ public void Undo_ThenNewPatch_DiscardsRedoHistory() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("C")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("C")); // Undo 2 steps (back to position 1, only A) mgr.Undo(id, 2); // Apply new patch — should discard B and C from history - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("D")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("D")); // Redo should now have nothing var redoResult = mgr.Redo(id); @@ -216,9 +217,9 @@ public void JumpTo_Forward_RebuildsCorrectly() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("C")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("C")); mgr.JumpTo(id, 0); Assert.DoesNotContain("A", mgr.Get(id).GetBody().InnerText); @@ -239,9 +240,9 @@ public void JumpTo_Backward_RebuildsCorrectly() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("C")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("C")); var result = mgr.JumpTo(id, 1); Assert.Equal(1, result.Position); @@ -258,7 +259,7 @@ public void JumpTo_Zero_ReturnsBaseline() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); var result = mgr.JumpTo(id, 0); Assert.Equal(0, result.Position); @@ -272,7 +273,7 @@ public void JumpTo_OutOfRange_ReturnsNoChange() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); var result = mgr.JumpTo(id, 100); Assert.Equal(0, result.Steps); @@ -286,7 +287,7 @@ public void JumpTo_SamePosition_NoOp() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); var result = mgr.JumpTo(id, 1); Assert.Equal(0, result.Steps); @@ -302,8 +303,8 @@ public void GetHistory_ReturnsEntries() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); var history = mgr.GetHistory(id); Assert.Equal(3, history.TotalEntries); // baseline + 2 patches @@ -327,8 +328,8 @@ public void GetHistory_AfterUndo_ShowsCurrentMarker() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); mgr.Undo(id); var history = mgr.GetHistory(id); @@ -350,7 +351,7 @@ public void GetHistory_Pagination_Works() var id = session.Id; for (int i = 0; i < 5; i++) - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch($"P{i}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch($"P{i}")); var page = mgr.GetHistory(id, offset: 2, limit: 2); Assert.Equal(6, page.TotalEntries); @@ -368,16 +369,16 @@ public void Compact_WithRedoEntries_SkipsWithoutFlag() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); mgr.Undo(id); // Compact should skip because redo entries exist mgr.Compact(id); - // WAL should still have entries (compact was skipped) - var walCount = _store.WalEntryCount(id); - Assert.True(walCount > 0); + // History should still have entries (compact was skipped) + var history = mgr.GetHistory(id); + Assert.True(history.TotalEntries > 1); } [Fact] @@ -387,14 +388,15 @@ public void Compact_WithDiscardFlag_Works() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); mgr.Undo(id); mgr.Compact(id, discardRedoHistory: true); - var walCount = _store.WalEntryCount(id); - Assert.Equal(0, walCount); + // After compact with discard, history should be minimal + var history = mgr.GetHistory(id); + Assert.Equal(1, history.TotalEntries); // Only baseline } [Fact] @@ -406,14 +408,18 @@ public void Compact_ClearsCheckpoints() // Apply enough patches to create a checkpoint (interval default = 10) for (int i = 0; i < 10; i++) - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch($"P{i}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch($"P{i}")); - // Checkpoint at position 10 should exist - Assert.True(File.Exists(_store.CheckpointPath(id, 10))); + // Verify checkpoint exists via history + var historyBefore = mgr.GetHistory(id); + var hasCheckpoint = historyBefore.Entries.Any(e => e.IsCheckpoint && e.Position == 10); + Assert.True(hasCheckpoint); mgr.Compact(id); - Assert.False(File.Exists(_store.CheckpointPath(id, 10))); + // After compact, only baseline checkpoint remains + var historyAfter = mgr.GetHistory(id); + Assert.Equal(1, historyAfter.TotalEntries); } // --- Checkpoint tests --- @@ -427,9 +433,11 @@ public void Checkpoint_CreatedAtInterval() // Default interval is 10 for (int i = 0; i < 10; i++) - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch($"P{i}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch($"P{i}")); - Assert.True(File.Exists(_store.CheckpointPath(id, 10))); + var history = mgr.GetHistory(id); + var hasCheckpoint = history.Entries.Any(e => e.IsCheckpoint && e.Position == 10); + Assert.True(hasCheckpoint); } [Fact] @@ -441,9 +449,11 @@ public void Checkpoint_UsedDuringUndo() // Apply 15 patches (checkpoint at position 10) for (int i = 0; i < 15; i++) - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch($"P{i}")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch($"P{i}")); - Assert.True(File.Exists(_store.CheckpointPath(id, 10))); + // Verify checkpoint at 10 + var history = mgr.GetHistory(id); + Assert.True(history.Entries.Any(e => e.IsCheckpoint && e.Position == 10)); // Undo to position 12 — should use checkpoint at 10, replay 2 patches var result = mgr.Undo(id, 3); @@ -460,65 +470,29 @@ public void Checkpoint_UsedDuringUndo() [Fact] public void RestoreSessions_RespectsCursor() { - var mgr1 = CreateManager(); + // Use explicit tenant so second manager can find the session + var tenantId = $"test-restore-cursor-{Guid.NewGuid():N}"; + var mgr1 = TestHelpers.CreateSessionManager(tenantId); var session = mgr1.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr1, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr1, null, id, AddParagraphPatch("B")); - PatchTool.ApplyPatch(mgr1, null, id, AddParagraphPatch("C")); + PatchTool.ApplyPatch(mgr1, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr1, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr1, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("C")); // Undo to position 1 mgr1.Undo(id, 2); - // Simulate restart - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); - - var restored = mgr2.RestoreSessions(); - Assert.Equal(1, restored); + // Simulate restart: create a new manager with same tenant (stateless, no RestoreSessions needed) + var mgr2 = TestHelpers.CreateSessionManager(tenantId); - // Document should be at position 1 (only "A") - var body = mgr2.Get(id).GetBody(); + // Cursor position IS persisted in the gRPC index, so Get() respects it. + // After undo to position 1, only "A" should be present. + using var restored = mgr2.Get(id); + var body = restored.GetBody(); Assert.Contains("A", body.InnerText); Assert.DoesNotContain("B", body.InnerText); Assert.DoesNotContain("C", body.InnerText); - - store2.Dispose(); - } - - [Fact] - public void RestoreSessions_BackwardCompat_CursorZeroReplayAll() - { - // Simulate an old index without cursor position - var mgr1 = CreateManager(); - var session = mgr1.Create(); - var id = session.Id; - - PatchTool.ApplyPatch(mgr1, null, id, AddParagraphPatch("Legacy")); - - // Manually set cursor to -1 in index to simulate old format (no cursor tracking) - var index = _store.LoadIndex(); - var entry = index.Sessions.Find(e => e.Id == id); - Assert.NotNull(entry); - entry!.CursorPosition = -1; - entry.CheckpointPositions.Clear(); - _store.SaveIndex(index); - - // Simulate restart - _store.Dispose(); - var store2 = new SessionStore(NullLogger.Instance, _tempDir); - var mgr2 = new SessionManager(store2, NullLogger.Instance); - - var restored = mgr2.RestoreSessions(); - Assert.Equal(1, restored); - - // All WAL entries should be replayed (backward compat) - var body = mgr2.Get(id).GetBody(); - Assert.Contains("Legacy", body.InnerText); - - store2.Dispose(); } // --- MCP Tool integration --- @@ -530,9 +504,9 @@ public void HistoryTools_Undo_Integration() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); - var result = HistoryTools.DocumentUndo(mgr, id); + var result = HistoryTools.DocumentUndo(mgr, CreateSyncManager(), id); Assert.Contains("Undid 1 step", result); Assert.Contains("Position: 0", result); } @@ -544,10 +518,10 @@ public void HistoryTools_Redo_Integration() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); mgr.Undo(id); - var result = HistoryTools.DocumentRedo(mgr, id); + var result = HistoryTools.DocumentRedo(mgr, CreateSyncManager(), id); Assert.Contains("Redid 1 step", result); Assert.Contains("Position: 1", result); } @@ -559,7 +533,7 @@ public void HistoryTools_History_Integration() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); var result = HistoryTools.DocumentHistory(mgr, id); Assert.Contains("History for document", result); @@ -575,10 +549,10 @@ public void HistoryTools_JumpTo_Integration() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("Test")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("More")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("Test")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("More")); - var result = HistoryTools.DocumentJumpTo(mgr, id, 0); + var result = HistoryTools.DocumentJumpTo(mgr, CreateSyncManager(), id, 0); Assert.Contains("Jumped to position 0", result); } @@ -589,8 +563,8 @@ public void DocumentSnapshot_WithDiscard_Integration() var session = mgr.Create(); var id = session.Id; - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("A")); - PatchTool.ApplyPatch(mgr, null, id, AddParagraphPatch("B")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("A")); + PatchTool.ApplyPatch(mgr, CreateSyncManager(), TestHelpers.CreateExternalChangeGate(), id, AddParagraphPatch("B")); mgr.Undo(id); var result = DocumentTools.DocumentSnapshot(mgr, id, discard_redo: true); diff --git a/website/astro.config.mjs b/website/astro.config.mjs index d87d716..f064d2d 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -3,6 +3,9 @@ import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ site: 'https://docx.lapoule.dev', + security: { + checkOrigin: false, + }, adapter: cloudflare({ imageService: 'compile', }), diff --git a/website/migrations/0005_oauth_connections.sql b/website/migrations/0005_oauth_connections.sql new file mode 100644 index 0000000..72431fd --- /dev/null +++ b/website/migrations/0005_oauth_connections.sql @@ -0,0 +1,21 @@ +-- OAuth connections for external file providers (Google Drive, OneDrive, etc.) +-- Each tenant can have multiple connections per provider. +-- Tokens are stored in D1 (encrypted at rest by Cloudflare). + +CREATE TABLE IF NOT EXISTS "oauth_connection" ( + "id" TEXT PRIMARY KEY NOT NULL, + "tenantId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "providerAccountId" TEXT, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "tokenExpiresAt" TEXT, + "scopes" TEXT NOT NULL, + "createdAt" TEXT NOT NULL, + "updatedAt" TEXT NOT NULL, + FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "idx_oauth_conn_tenant" + ON "oauth_connection"("tenantId", "provider"); diff --git a/website/migrations/0006_oauth_server.sql b/website/migrations/0006_oauth_server.sql new file mode 100644 index 0000000..0e47996 --- /dev/null +++ b/website/migrations/0006_oauth_server.sql @@ -0,0 +1,67 @@ +-- OAuth 2.1 Authorization Server tables +-- Supports Dynamic Client Registration (RFC 7591), Authorization Code + PKCE, Refresh Token rotation + +-- Registered clients (DCR or pre-registered) +CREATE TABLE IF NOT EXISTS "oauth_client" ( + "id" TEXT PRIMARY KEY NOT NULL, + "clientName" TEXT NOT NULL, + "redirectUris" TEXT NOT NULL, + "grantTypes" TEXT NOT NULL, + "tokenEndpointAuthMethod" TEXT NOT NULL DEFAULT 'none', + "clientSecret" TEXT, + "clientUri" TEXT, + "logoUri" TEXT, + "createdAt" TEXT NOT NULL, + "updatedAt" TEXT NOT NULL +); + +-- Authorization codes (short-lived, PKCE) +CREATE TABLE IF NOT EXISTS "oauth_authorization_code" ( + "code" TEXT PRIMARY KEY NOT NULL, + "clientId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "redirectUri" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "codeChallenge" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "expiresAt" TEXT NOT NULL, + "createdAt" TEXT NOT NULL, + FOREIGN KEY ("clientId") REFERENCES "oauth_client"("id") ON DELETE CASCADE, + FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE +); + +-- Access tokens (opaque, like PATs) +CREATE TABLE IF NOT EXISTS "oauth_access_token" ( + "id" TEXT PRIMARY KEY NOT NULL, + "clientId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL UNIQUE, + "tokenPrefix" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "expiresAt" TEXT NOT NULL, + "createdAt" TEXT NOT NULL, + "lastUsedAt" TEXT, + FOREIGN KEY ("clientId") REFERENCES "oauth_client"("id") ON DELETE CASCADE, + FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE +); + +-- Refresh tokens (opaque, rotation obligatoire) +CREATE TABLE IF NOT EXISTS "oauth_refresh_token" ( + "id" TEXT PRIMARY KEY NOT NULL, + "clientId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL UNIQUE, + "scope" TEXT NOT NULL, + "resource" TEXT NOT NULL, + "expiresAt" TEXT NOT NULL, + "createdAt" TEXT NOT NULL, + "revoked" INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY ("clientId") REFERENCES "oauth_client"("id") ON DELETE CASCADE, + FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "idx_oauth_access_token_hash" ON "oauth_access_token"("tokenHash"); +CREATE INDEX IF NOT EXISTS "idx_oauth_access_token_tenant" ON "oauth_access_token"("tenantId"); +CREATE INDEX IF NOT EXISTS "idx_oauth_refresh_token_hash" ON "oauth_refresh_token"("tokenHash"); +CREATE INDEX IF NOT EXISTS "idx_oauth_authorization_code_expires" ON "oauth_authorization_code"("expiresAt"); diff --git a/website/package-lock.json b/website/package-lock.json index bb67cff..9335d3a 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -1,12 +1,12 @@ { "name": "docx-mcp-website", - "version": "1.0.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docx-mcp-website", - "version": "1.0.0", + "version": "1.6.0", "dependencies": { "@astrojs/cloudflare": "13.0.0-beta.4", "@google-cloud/storage": "^7.0.0", @@ -18,7 +18,7 @@ "devDependencies": { "@better-auth/cli": "^1.0.0", "@cloudflare/workers-types": "^4.0.0", - "wrangler": "^4.0.0" + "wrangler": "^4.65.0" } }, "node_modules/@astrojs/cloudflare": { @@ -884,21 +884,512 @@ } } }, - "node_modules/@cloudflare/vite-plugin": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.23.0.tgz", - "integrity": "sha512-Pz3kF5wxUx99NOOYPq/jgaknKQuamN52FQkc8WBmLfbzBd9fWu+4NaJeZjDtFTXUBA0FEA7bOROuV52YFOA2TA==", - "license": "MIT", + "node_modules/@cloudflare/vite-plugin": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.23.0.tgz", + "integrity": "sha512-Pz3kF5wxUx99NOOYPq/jgaknKQuamN52FQkc8WBmLfbzBd9fWu+4NaJeZjDtFTXUBA0FEA7bOROuV52YFOA2TA==", + "license": "MIT", + "dependencies": { + "@cloudflare/unenv-preset": "2.12.0", + "miniflare": "4.20260131.0", + "unenv": "2.0.0-rc.24", + "wrangler": "4.62.0", + "ws": "8.18.0" + }, + "peerDependencies": { + "vite": "^6.1.0 || ^7.0.0", + "wrangler": "^4.62.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.62.0.tgz", + "integrity": "sha512-DogP9jifqw85g33BqwF6m21YBW5J7+Ep9IJLgr6oqHU0RkA79JMN5baeWXdmnIWZl+VZh6bmtNtR+5/Djd32tg==", + "license": "MIT OR Apache-2.0", "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.12.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.0", "miniflare": "4.20260131.0", + "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "wrangler": "4.62.0", - "ws": "8.18.0" + "workerd": "1.20260131.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" }, "peerDependencies": { - "vite": "^6.1.0 || ^7.0.0", - "wrangler": "^4.62.0" + "@cloudflare/workers-types": "^4.20260131.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, "node_modules/@cloudflare/workerd-darwin-64": { @@ -982,9 +1473,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260203.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260203.0.tgz", - "integrity": "sha512-XD2uglpGbVppjXXLuAdalKkcTi/i4TyQSx0w/ijJbvrR1Cfm7zNkxtvFBNy3tBNxZOiFIJtw5bszifQB1eow6A==", + "version": "4.20260214.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260214.0.tgz", + "integrity": "sha512-qb8rgbAdJR4BAPXolXhFL/wuGtecHLh1veOyZ1mK6QqWuCdI3vK1biKC0i3lzmzdLR/DZvsN3mNtpUE8zpWGEg==", "devOptional": true, "license": "MIT OR Apache-2.0" }, @@ -8642,19 +9133,19 @@ } }, "node_modules/wrangler": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.62.0.tgz", - "integrity": "sha512-DogP9jifqw85g33BqwF6m21YBW5J7+Ep9IJLgr6oqHU0RkA79JMN5baeWXdmnIWZl+VZh6bmtNtR+5/Djd32tg==", + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.65.0.tgz", + "integrity": "sha512-R+n3o3tlGzLK9I4fGocPReOuvcnjhtOL2aCVKkHMeuEwt9pPbOO4FxJtx/ec5cIUG/otRyJnfQGCAr9DplBVng==", "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", - "@cloudflare/unenv-preset": "2.12.0", + "@cloudflare/unenv-preset": "2.12.1", "blake3-wasm": "2.1.5", - "esbuild": "0.27.0", - "miniflare": "4.20260131.0", + "esbuild": "0.27.3", + "miniflare": "4.20260212.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260131.0" + "workerd": "1.20260212.0" }, "bin": { "wrangler": "bin/wrangler.js", @@ -8667,7 +9158,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260131.0" + "@cloudflare/workers-types": "^4.20260212.0" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -8675,10 +9166,105 @@ } } }, + "node_modules/wrangler/node_modules/@cloudflare/unenv-preset": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.12.1.tgz", + "integrity": "sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA==", + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20260115.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260212.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260212.0.tgz", + "integrity": "sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260212.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260212.0.tgz", + "integrity": "sha512-fqoqQWMA1D0ZzDOD8sp0allREM2M8GHdpxMXQ8EdZpZ70z5bJbJ9Vr4qe35++FNIZJspsDHfTw3Xm/M4ELm/dQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260212.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260212.0.tgz", + "integrity": "sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260212.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260212.0.tgz", + "integrity": "sha512-GPvp1iiKQodtbUDi6OmR5I0vD75lawB54tdYGtmypuHC7ZOI2WhBmhb3wCxgnQNOG1z7mhCQrzRCoqrKwYbVWQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260212.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260212.0.tgz", + "integrity": "sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -8692,9 +9278,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -8708,9 +9294,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -8724,9 +9310,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -8740,9 +9326,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -8756,9 +9342,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -8772,9 +9358,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -8788,9 +9374,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -8804,9 +9390,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -8820,9 +9406,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -8836,9 +9422,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -8852,9 +9438,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -8868,9 +9454,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -8884,9 +9470,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -8900,9 +9486,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -8916,9 +9502,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -8932,9 +9518,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -8948,9 +9534,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -8964,9 +9550,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -8980,9 +9566,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -8996,9 +9582,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -9012,9 +9598,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -9028,9 +9614,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -9044,9 +9630,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -9060,9 +9646,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -9076,9 +9662,9 @@ } }, "node_modules/wrangler/node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -9092,9 +9678,9 @@ } }, "node_modules/wrangler/node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -9104,32 +9690,72 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "4.20260212.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260212.0.tgz", + "integrity": "sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg==", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.18.2", + "workerd": "1.20260212.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20260212.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260212.0.tgz", + "integrity": "sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260212.0", + "@cloudflare/workerd-darwin-arm64": "1.20260212.0", + "@cloudflare/workerd-linux-64": "1.20260212.0", + "@cloudflare/workerd-linux-arm64": "1.20260212.0", + "@cloudflare/workerd-windows-64": "1.20260212.0" } }, "node_modules/wrap-ansi": { diff --git a/website/package.json b/website/package.json index 53c775c..efe35de 100644 --- a/website/package.json +++ b/website/package.json @@ -1,7 +1,7 @@ { "name": "docx-mcp-website", "type": "module", - "version": "1.0.0", + "version": "1.6.0", "scripts": { "dev": "astro dev", "start": "astro dev", @@ -12,16 +12,16 @@ "astro": "astro" }, "dependencies": { - "astro": "6.0.0-beta.7", "@astrojs/cloudflare": "13.0.0-beta.4", + "@google-cloud/storage": "^7.0.0", + "astro": "6.0.0-beta.7", "better-auth": "^1.0.0", "kysely": "^0.28.0", - "kysely-d1": "^0.3.0", - "@google-cloud/storage": "^7.0.0" + "kysely-d1": "^0.3.0" }, "devDependencies": { - "wrangler": "^4.0.0", + "@better-auth/cli": "^1.0.0", "@cloudflare/workers-types": "^4.0.0", - "@better-auth/cli": "^1.0.0" + "wrangler": "^4.65.0" } } diff --git a/website/src/components/ConnectionsManager.astro b/website/src/components/ConnectionsManager.astro new file mode 100644 index 0000000..707d6ac --- /dev/null +++ b/website/src/components/ConnectionsManager.astro @@ -0,0 +1,508 @@ +--- +interface Props { + lang: 'fr' | 'en'; +} + +const { lang } = Astro.props; + +const translations = { + fr: { + title: 'Stockage', + description: 'Connectez vos espaces de stockage pour synchroniser vos documents.', + add: 'Ajouter une connexion', + provider: 'Fournisseur', + selectProvider: 'Sélectionnez un fournisseur', + googleDrive: 'Google Drive', + onedrive: 'OneDrive', + sharepoint: 'SharePoint', + comingSoon: 'Bientôt', + connect: 'Connecter', + cancel: 'Annuler', + delete: 'Déconnecter', + deleteConfirm: 'Êtes-vous sûr de vouloir supprimer cette connexion ?', + empty: 'Aucune connexion configurée', + connected: 'Connecté', + expired: 'À rafraîchir', + added: 'Ajouté le', + }, + en: { + title: 'Storage', + description: 'Connect your storage providers to sync your documents.', + add: 'Add connection', + provider: 'Provider', + selectProvider: 'Select a provider', + googleDrive: 'Google Drive', + onedrive: 'OneDrive', + sharepoint: 'SharePoint', + comingSoon: 'Soon', + connect: 'Connect', + cancel: 'Cancel', + delete: 'Disconnect', + deleteConfirm: 'Are you sure you want to remove this connection?', + empty: 'No connections configured', + connected: 'Connected', + expired: 'Needs refresh', + added: 'Added', + }, +}; + +const t = translations[lang]; +const connectBasePath = '/api/oauth/connect'; +--- + + +
    +
    +
    +

    {t.title}

    +

    {t.description}

    +
    + +
    + +
    +

    Loading...

    +
    + + + + + +
    +
    + + + + diff --git a/website/src/components/Doccy.astro b/website/src/components/Doccy.astro index 7e52a6a..707e66b 100644 --- a/website/src/components/Doccy.astro +++ b/website/src/components/Doccy.astro @@ -20,7 +20,7 @@ const initialHref = inDashboard ? dashboardPath : loginPath; const initialText = inDashboard ? consoleCta : ctaText; --- -
    +

    {messages[0]}

    @@ -47,7 +47,7 @@ const initialText = inDashboard ? consoleCta : ctaText;
    - + + diff --git a/website/src/components/PatManager.astro b/website/src/components/PatManager.astro index d902f06..c179c08 100644 --- a/website/src/components/PatManager.astro +++ b/website/src/components/PatManager.astro @@ -303,33 +303,34 @@ const t = translations[lang]; font-size: var(--font-size-300); } - .pat-item { + /* Dynamic content injected via JS — needs :global() for scoped styles */ + .pat-list :global(.pat-item) { display: flex; justify-content: space-between; align-items: center; - padding: var(--spacing-l); + padding: var(--spacing-l) var(--spacing-xl); background: var(--bg-layer-3); border-radius: var(--radius-m); - gap: var(--spacing-l); + gap: var(--spacing-xl); } - .pat-info { + .pat-list :global(.pat-info) { flex: 1; min-width: 0; } - .pat-name { + .pat-list :global(.pat-name) { display: flex; align-items: center; gap: var(--spacing-m); - margin-bottom: var(--spacing-xs); + margin-bottom: var(--spacing-s); } - .pat-name span { + .pat-list :global(.pat-name span) { font-weight: var(--font-weight-medium); } - .pat-prefix { + .pat-list :global(.pat-prefix) { font-size: var(--font-size-200); background: var(--bg-layer-4); padding: var(--spacing-xxs) var(--spacing-s); @@ -337,7 +338,7 @@ const t = translations[lang]; color: var(--text-secondary); } - .pat-meta { + .pat-list :global(.pat-meta) { display: flex; gap: var(--spacing-xl); font-size: var(--font-size-200); @@ -378,17 +379,18 @@ const t = translations[lang]; background: var(--bg-layer-3); } - .btn-danger { + .pat-list :global(.btn-danger) { background: transparent; color: var(--text-error, #dc2626); - border: 1px solid var(--border-error, #dc2626); + border: 1px solid var(--border-error, rgba(220, 38, 38, 0.3)); } - .btn-danger:hover { + .pat-list :global(.btn-danger:hover) { background: rgba(220, 38, 38, 0.1); + border-color: var(--border-error, #dc2626); } - .btn-small { + .pat-list :global(.btn-small) { padding: var(--spacing-s) var(--spacing-m); font-size: var(--font-size-200); } diff --git a/website/src/env.d.ts b/website/src/env.d.ts index f74cbe2..c97a092 100644 --- a/website/src/env.d.ts +++ b/website/src/env.d.ts @@ -25,7 +25,7 @@ declare namespace App { id: string; name: string; email: string; - image?: string; + image?: string | null; }; session?: { id: string; @@ -39,6 +39,7 @@ declare namespace App { gcsPrefix: string; storageQuotaBytes: number; storageUsedBytes: number; + preferences: string | null; createdAt: string; updatedAt: string; }; diff --git a/website/src/i18n/ui.ts b/website/src/i18n/ui.ts index 918052f..5e422b3 100644 --- a/website/src/i18n/ui.ts +++ b/website/src/i18n/ui.ts @@ -121,6 +121,14 @@ export const ui = { 'pat.empty': 'Aucun token créé', 'pat.copyWarning': 'Copiez ce token maintenant. Il ne sera plus affiché.', 'pat.copied': 'Copié !', + + // Consent + 'consent.title': 'Autorisation', + 'consent.wants_access': 'souhaite accéder à votre compte Docx System.', + 'consent.logged_in_as': 'Connecté en tant que', + 'consent.permissions': 'Permissions demandées', + 'consent.authorize': 'Autoriser', + 'consent.deny': 'Refuser', }, en: { // Nav @@ -235,5 +243,13 @@ export const ui = { 'pat.empty': 'No tokens created', 'pat.copyWarning': 'Copy this token now. It won\'t be shown again.', 'pat.copied': 'Copied!', + + // Consent + 'consent.title': 'Authorization', + 'consent.wants_access': 'wants to access your Docx System account.', + 'consent.logged_in_as': 'Logged in as', + 'consent.permissions': 'Requested permissions', + 'consent.authorize': 'Authorize', + 'consent.deny': 'Deny', }, } as const; diff --git a/website/src/lib/oauth-apps.ts b/website/src/lib/oauth-apps.ts new file mode 100644 index 0000000..7a4852e --- /dev/null +++ b/website/src/lib/oauth-apps.ts @@ -0,0 +1,136 @@ +import { Kysely } from 'kysely'; +import { D1Dialect } from 'kysely-d1'; + +interface OAuthAccessTokenRecord { + id: string; + clientId: string; + tenantId: string; + tokenHash: string; + tokenPrefix: string; + scope: string; + resource: string; + expiresAt: string; + createdAt: string; + lastUsedAt: string | null; +} + +interface OAuthRefreshTokenRecord { + id: string; + clientId: string; + tenantId: string; + tokenHash: string; + scope: string; + resource: string; + expiresAt: string; + createdAt: string; + revoked: number; +} + +interface OAuthClientRecord { + id: string; + clientName: string; + redirectUris: string; + grantTypes: string; + tokenEndpointAuthMethod: string; + clientSecret: string | null; + clientUri: string | null; + logoUri: string | null; + createdAt: string; + updatedAt: string; +} + +interface OAuthDB { + oauth_access_token: OAuthAccessTokenRecord; + oauth_refresh_token: OAuthRefreshTokenRecord; + oauth_client: OAuthClientRecord; +} + +export interface OAuthAppInfo { + clientId: string; + clientName: string; + scope: string; + createdAt: string; + lastUsedAt: string | null; + activeTokens: number; +} + +function getKysely(db: D1Database) { + return new Kysely({ + dialect: new D1Dialect({ database: db }), + }); +} + +export async function listAuthorizedApps( + db: D1Database, + tenantId: string, +): Promise { + const kysely = getKysely(db); + + // Get all active (non-expired) access tokens for this tenant, grouped by client + const tokens = await kysely + .selectFrom('oauth_access_token') + .innerJoin('oauth_client', 'oauth_client.id', 'oauth_access_token.clientId') + .select([ + 'oauth_access_token.clientId', + 'oauth_client.clientName', + 'oauth_access_token.scope', + 'oauth_access_token.createdAt', + 'oauth_access_token.lastUsedAt', + 'oauth_access_token.expiresAt', + ]) + .where('oauth_access_token.tenantId', '=', tenantId) + .orderBy('oauth_access_token.createdAt', 'desc') + .execute(); + + // Group by clientId + const appMap = new Map(); + for (const token of tokens) { + const existing = appMap.get(token.clientId); + if (existing) { + existing.activeTokens++; + // Keep the most recent lastUsedAt + if ( + token.lastUsedAt && + (!existing.lastUsedAt || token.lastUsedAt > existing.lastUsedAt) + ) { + existing.lastUsedAt = token.lastUsedAt; + } + } else { + appMap.set(token.clientId, { + clientId: token.clientId, + clientName: token.clientName, + scope: token.scope, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + activeTokens: 1, + }); + } + } + + return Array.from(appMap.values()); +} + +export async function revokeAppAccess( + db: D1Database, + tenantId: string, + clientId: string, +): Promise { + const kysely = getKysely(db); + + // Delete all access tokens for this client + tenant + await kysely + .deleteFrom('oauth_access_token') + .where('tenantId', '=', tenantId) + .where('clientId', '=', clientId) + .execute(); + + // Revoke all refresh tokens for this client + tenant + await kysely + .updateTable('oauth_refresh_token') + .set({ revoked: 1 }) + .where('tenantId', '=', tenantId) + .where('clientId', '=', clientId) + .execute(); + + return true; +} diff --git a/website/src/lib/oauth-connections.ts b/website/src/lib/oauth-connections.ts new file mode 100644 index 0000000..643da82 --- /dev/null +++ b/website/src/lib/oauth-connections.ts @@ -0,0 +1,158 @@ +import { Kysely } from 'kysely'; +import { D1Dialect } from 'kysely-d1'; + +interface OAuthConnectionRecord { + id: string; + tenantId: string; + provider: string; + displayName: string; + providerAccountId: string | null; + accessToken: string; + refreshToken: string; + tokenExpiresAt: string | null; + scopes: string; + createdAt: string; + updatedAt: string; +} + +export interface OAuthConnectionInfo { + id: string; + provider: string; + displayName: string; + providerAccountId: string | null; + scopes: string; + tokenExpiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateConnectionParams { + provider: string; + displayName: string; + providerAccountId: string | null; + accessToken: string; + refreshToken: string; + tokenExpiresAt: string | null; + scopes: string; +} + +function getKysely(db: D1Database) { + return new Kysely<{ oauth_connection: OAuthConnectionRecord }>({ + dialect: new D1Dialect({ database: db }), + }); +} + +export async function createConnection( + db: D1Database, + tenantId: string, + params: CreateConnectionParams, +): Promise { + const kysely = getKysely(db); + const now = new Date().toISOString(); + const id = crypto.randomUUID(); + + const record: OAuthConnectionRecord = { + id, + tenantId, + provider: params.provider, + displayName: params.displayName, + providerAccountId: params.providerAccountId, + accessToken: params.accessToken, + refreshToken: params.refreshToken, + tokenExpiresAt: params.tokenExpiresAt, + scopes: params.scopes, + createdAt: now, + updatedAt: now, + }; + + await kysely.insertInto('oauth_connection').values(record).execute(); + + return { + id, + provider: params.provider, + displayName: params.displayName, + providerAccountId: params.providerAccountId, + scopes: params.scopes, + tokenExpiresAt: params.tokenExpiresAt, + createdAt: now, + updatedAt: now, + }; +} + +export async function listConnections( + db: D1Database, + tenantId: string, + provider?: string, +): Promise { + const kysely = getKysely(db); + + let query = kysely + .selectFrom('oauth_connection') + .select([ + 'id', + 'provider', + 'displayName', + 'providerAccountId', + 'scopes', + 'tokenExpiresAt', + 'createdAt', + 'updatedAt', + ]) + .where('tenantId', '=', tenantId); + + if (provider) { + query = query.where('provider', '=', provider); + } + + return await query.orderBy('createdAt', 'desc').execute(); +} + +export async function getConnection( + db: D1Database, + connectionId: string, +): Promise { + const kysely = getKysely(db); + + return await kysely + .selectFrom('oauth_connection') + .selectAll() + .where('id', '=', connectionId) + .executeTakeFirst(); +} + +export async function deleteConnection( + db: D1Database, + tenantId: string, + connectionId: string, +): Promise { + const kysely = getKysely(db); + + const result = await kysely + .deleteFrom('oauth_connection') + .where('id', '=', connectionId) + .where('tenantId', '=', tenantId) + .executeTakeFirst(); + + return (result.numDeletedRows ?? 0) > 0; +} + +export async function updateTokens( + db: D1Database, + connectionId: string, + accessToken: string, + refreshToken: string, + expiresAt: string | null, +): Promise { + const kysely = getKysely(db); + + await kysely + .updateTable('oauth_connection') + .set({ + accessToken, + refreshToken, + tokenExpiresAt: expiresAt, + updatedAt: new Date().toISOString(), + }) + .where('id', '=', connectionId) + .execute(); +} diff --git a/website/src/lib/oauth-server.ts b/website/src/lib/oauth-server.ts new file mode 100644 index 0000000..ce6c573 --- /dev/null +++ b/website/src/lib/oauth-server.ts @@ -0,0 +1,436 @@ +import { Kysely } from 'kysely'; +import { D1Dialect } from 'kysely-d1'; + +// --- Types --- + +interface OAuthClientRecord { + id: string; + clientName: string; + redirectUris: string; // JSON array + grantTypes: string; // JSON array + tokenEndpointAuthMethod: string; + clientSecret: string | null; + clientUri: string | null; + logoUri: string | null; + createdAt: string; + updatedAt: string; +} + +interface OAuthAuthorizationCodeRecord { + code: string; + clientId: string; + tenantId: string; + redirectUri: string; + scope: string; + codeChallenge: string; + resource: string; + expiresAt: string; + createdAt: string; +} + +interface OAuthAccessTokenRecord { + id: string; + clientId: string; + tenantId: string; + tokenHash: string; + tokenPrefix: string; + scope: string; + resource: string; + expiresAt: string; + createdAt: string; + lastUsedAt: string | null; +} + +interface OAuthRefreshTokenRecord { + id: string; + clientId: string; + tenantId: string; + tokenHash: string; + scope: string; + resource: string; + expiresAt: string; + createdAt: string; + revoked: number; +} + +interface OAuthDB { + oauth_client: OAuthClientRecord; + oauth_authorization_code: OAuthAuthorizationCodeRecord; + oauth_access_token: OAuthAccessTokenRecord; + oauth_refresh_token: OAuthRefreshTokenRecord; +} + +export interface RegisterClientParams { + client_name: string; + redirect_uris: string[]; + grant_types?: string[]; + response_types?: string[]; + token_endpoint_auth_method?: string; + client_uri?: string; + logo_uri?: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + token_type: string; + expires_in: number; + scope: string; +} + +// --- Constants --- + +const ACCESS_TOKEN_PREFIX = 'oat_'; +const REFRESH_TOKEN_PREFIX = 'ort_'; +const ACCESS_TOKEN_TTL_SECONDS = 3600; // 1 hour +const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 3600; // 30 days +const AUTHORIZATION_CODE_TTL_SECONDS = 300; // 5 minutes + +// --- Helpers --- + +function getKysely(db: D1Database) { + return new Kysely({ + dialect: new D1Dialect({ database: db }), + }); +} + +export function generateOpaqueToken(prefix: string): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + const randomPart = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `${prefix}${randomPart}`; +} + +export async function hashToken(token: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export async function verifyPkce( + codeVerifier: string, + codeChallenge: string, +): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(codeVerifier); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + // Base64url encode (no padding) + const hashBase64 = btoa(String.fromCharCode(...new Uint8Array(hashBuffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return hashBase64 === codeChallenge; +} + +function normalizeRedirectUri(uri: string): string { + // Treat localhost and 127.0.0.1 as equivalent + try { + const parsed = new URL(uri); + if (parsed.hostname === '127.0.0.1') { + parsed.hostname = 'localhost'; + return parsed.toString().replace(/\/$/, ''); + } + return uri.replace(/\/$/, ''); + } catch { + return uri; + } +} + +function redirectUrisMatch(registered: string, provided: string): boolean { + return normalizeRedirectUri(registered) === normalizeRedirectUri(provided); +} + +// --- Client Registration (RFC 7591) --- + +export async function registerClient( + db: D1Database, + params: RegisterClientParams, +): Promise { + const kysely = getKysely(db); + const now = new Date().toISOString(); + const id = crypto.randomUUID(); + + const grantTypes = params.grant_types ?? ['authorization_code']; + const authMethod = params.token_endpoint_auth_method ?? 'none'; + + let clientSecret: string | null = null; + if (authMethod === 'client_secret_post') { + clientSecret = await hashToken(generateOpaqueToken('cs_')); + } + + const record: OAuthClientRecord = { + id, + clientName: params.client_name, + redirectUris: JSON.stringify(params.redirect_uris), + grantTypes: JSON.stringify(grantTypes), + tokenEndpointAuthMethod: authMethod, + clientSecret, + clientUri: params.client_uri ?? null, + logoUri: params.logo_uri ?? null, + createdAt: now, + updatedAt: now, + }; + + await kysely.insertInto('oauth_client').values(record).execute(); + + return record; +} + +export async function getClient( + db: D1Database, + clientId: string, +): Promise { + const kysely = getKysely(db); + return await kysely + .selectFrom('oauth_client') + .selectAll() + .where('id', '=', clientId) + .executeTakeFirst(); +} + +// --- Authorization Code --- + +export async function createAuthorizationCode( + db: D1Database, + clientId: string, + tenantId: string, + redirectUri: string, + scope: string, + codeChallenge: string, + resource: string, +): Promise { + const kysely = getKysely(db); + const code = generateOpaqueToken(''); + const now = new Date(); + const expiresAt = new Date(now.getTime() + AUTHORIZATION_CODE_TTL_SECONDS * 1000); + + const record: OAuthAuthorizationCodeRecord = { + code, + clientId, + tenantId, + redirectUri, + scope, + codeChallenge, + resource, + expiresAt: expiresAt.toISOString(), + createdAt: now.toISOString(), + }; + + await kysely.insertInto('oauth_authorization_code').values(record).execute(); + + return code; +} + +// --- Token Exchange (authorization_code) --- + +export async function exchangeCode( + db: D1Database, + code: string, + clientId: string, + redirectUri: string, + codeVerifier: string, +): Promise { + const kysely = getKysely(db); + + // 1. Find and validate the authorization code + const authCode = await kysely + .selectFrom('oauth_authorization_code') + .selectAll() + .where('code', '=', code) + .executeTakeFirst(); + + if (!authCode) { + throw new OAuthError('invalid_grant', 'Authorization code not found'); + } + + if (new Date(authCode.expiresAt) < new Date()) { + // Clean up expired code + await kysely + .deleteFrom('oauth_authorization_code') + .where('code', '=', code) + .execute(); + throw new OAuthError('invalid_grant', 'Authorization code expired'); + } + + if (authCode.clientId !== clientId) { + throw new OAuthError('invalid_grant', 'Client ID mismatch'); + } + + if (!redirectUrisMatch(authCode.redirectUri, redirectUri)) { + throw new OAuthError('invalid_grant', 'Redirect URI mismatch'); + } + + // 2. Verify PKCE + const pkceValid = await verifyPkce(codeVerifier, authCode.codeChallenge); + if (!pkceValid) { + throw new OAuthError('invalid_grant', 'PKCE verification failed'); + } + + // 3. Delete the code (one-time use) + await kysely + .deleteFrom('oauth_authorization_code') + .where('code', '=', code) + .execute(); + + // 4. Generate tokens + const now = new Date(); + const accessToken = generateOpaqueToken(ACCESS_TOKEN_PREFIX); + const refreshToken = generateOpaqueToken(REFRESH_TOKEN_PREFIX); + + const accessTokenHash = await hashToken(accessToken); + const refreshTokenHash = await hashToken(refreshToken); + + const accessTokenRecord: OAuthAccessTokenRecord = { + id: crypto.randomUUID(), + clientId, + tenantId: authCode.tenantId, + tokenHash: accessTokenHash, + tokenPrefix: accessToken.slice(0, 12), + scope: authCode.scope, + resource: authCode.resource, + expiresAt: new Date(now.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000).toISOString(), + createdAt: now.toISOString(), + lastUsedAt: null, + }; + + const refreshTokenRecord: OAuthRefreshTokenRecord = { + id: crypto.randomUUID(), + clientId, + tenantId: authCode.tenantId, + tokenHash: refreshTokenHash, + scope: authCode.scope, + resource: authCode.resource, + expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_SECONDS * 1000).toISOString(), + createdAt: now.toISOString(), + revoked: 0, + }; + + await kysely.insertInto('oauth_access_token').values(accessTokenRecord).execute(); + await kysely.insertInto('oauth_refresh_token').values(refreshTokenRecord).execute(); + + return { + access_token: accessToken, + refresh_token: refreshToken, + token_type: 'Bearer', + expires_in: ACCESS_TOKEN_TTL_SECONDS, + scope: authCode.scope, + }; +} + +// --- Token Refresh --- + +export async function refreshAccessToken( + db: D1Database, + refreshTokenStr: string, + clientId: string, +): Promise { + const kysely = getKysely(db); + const tokenHash = await hashToken(refreshTokenStr); + + // 1. Find and validate the refresh token + const refreshRecord = await kysely + .selectFrom('oauth_refresh_token') + .selectAll() + .where('tokenHash', '=', tokenHash) + .executeTakeFirst(); + + if (!refreshRecord) { + throw new OAuthError('invalid_grant', 'Refresh token not found'); + } + + if (refreshRecord.revoked) { + throw new OAuthError('invalid_grant', 'Refresh token has been revoked'); + } + + if (new Date(refreshRecord.expiresAt) < new Date()) { + throw new OAuthError('invalid_grant', 'Refresh token expired'); + } + + if (refreshRecord.clientId !== clientId) { + throw new OAuthError('invalid_grant', 'Client ID mismatch'); + } + + // 2. Revoke the old refresh token (rotation) + await kysely + .updateTable('oauth_refresh_token') + .set({ revoked: 1 }) + .where('id', '=', refreshRecord.id) + .execute(); + + // 3. Generate new tokens + const now = new Date(); + const newAccessToken = generateOpaqueToken(ACCESS_TOKEN_PREFIX); + const newRefreshToken = generateOpaqueToken(REFRESH_TOKEN_PREFIX); + + const accessTokenHash = await hashToken(newAccessToken); + const refreshTokenHash = await hashToken(newRefreshToken); + + const accessTokenRecord: OAuthAccessTokenRecord = { + id: crypto.randomUUID(), + clientId, + tenantId: refreshRecord.tenantId, + tokenHash: accessTokenHash, + tokenPrefix: newAccessToken.slice(0, 12), + scope: refreshRecord.scope, + resource: refreshRecord.resource, + expiresAt: new Date(now.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000).toISOString(), + createdAt: now.toISOString(), + lastUsedAt: null, + }; + + const refreshTokenRecord: OAuthRefreshTokenRecord = { + id: crypto.randomUUID(), + clientId, + tenantId: refreshRecord.tenantId, + tokenHash: refreshTokenHash, + scope: refreshRecord.scope, + resource: refreshRecord.resource, + expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_SECONDS * 1000).toISOString(), + createdAt: now.toISOString(), + revoked: 0, + }; + + await kysely.insertInto('oauth_access_token').values(accessTokenRecord).execute(); + await kysely.insertInto('oauth_refresh_token').values(refreshTokenRecord).execute(); + + return { + access_token: newAccessToken, + refresh_token: newRefreshToken, + token_type: 'Bearer', + expires_in: ACCESS_TOKEN_TTL_SECONDS, + scope: refreshRecord.scope, + }; +} + +// --- Validation helpers for authorize endpoint --- + +export function validateRedirectUri( + client: OAuthClientRecord, + redirectUri: string, +): boolean { + const registeredUris: string[] = JSON.parse(client.redirectUris); + return registeredUris.some((uri) => redirectUrisMatch(uri, redirectUri)); +} + +// --- Error --- + +export class OAuthError extends Error { + constructor( + public code: string, + message: string, + ) { + super(message); + this.name = 'OAuthError'; + } + + toJSON() { + return { + error: this.code, + error_description: this.message, + }; + } +} diff --git a/website/src/middleware.ts b/website/src/middleware.ts index f8d38f6..d80fb6e 100644 --- a/website/src/middleware.ts +++ b/website/src/middleware.ts @@ -9,9 +9,36 @@ export const onRequest = defineMiddleware(async (context, next) => { const isPatRoute = url.pathname.startsWith('/api/pat'); const isPreferencesRoute = url.pathname.startsWith('/api/preferences'); const isAuthRoute = url.pathname.startsWith('/api/auth'); + const isConsentRoute = + url.pathname === '/consent' || url.pathname === '/en/consent'; - // Skip for static pages (landing, etc.) - if (!isProtectedRoute && !isAuthRoute && !isPatRoute && !isPreferencesRoute) { + // OAuth server routes that do NOT require session auth + const isOAuthServerPublicRoute = + url.pathname === '/api/oauth/register' || + url.pathname === '/api/oauth/token' || + url.pathname.startsWith('/.well-known/'); + // OAuth connection management routes (require auth) + const isOAuthConnectionRoute = + url.pathname.startsWith('/api/oauth') && !isOAuthServerPublicRoute; + // OAuth authorize requires session but handles redirect to login itself + const isOAuthAuthorizeRoute = url.pathname === '/api/oauth/authorize'; + + // Skip for static pages, Better Auth routes, and public OAuth server endpoints + if ( + !isProtectedRoute && + !isAuthRoute && + !isPatRoute && + !isPreferencesRoute && + !isOAuthConnectionRoute && + !isConsentRoute && + !isOAuthServerPublicRoute + ) { + return next(); + } + + // Public OAuth server endpoints — pass through without auth + if (isOAuthServerPublicRoute) { + console.log('[Middleware] Public OAuth route, passing through:', url.pathname); return next(); } @@ -36,8 +63,16 @@ export const onRequest = defineMiddleware(async (context, next) => { return context.redirect(loginPath); } - // Return 401 for API routes without auth - if ((isPatRoute || isPreferencesRoute) && !session) { + // OAuth authorize: if not logged in, redirect to login with return_to + if ((isOAuthAuthorizeRoute || isConsentRoute) && !session) { + const lang = url.pathname.startsWith('/en/') ? 'en' : 'fr'; + const loginPath = lang === 'fr' ? '/connexion' : '/en/login'; + const returnTo = encodeURIComponent(url.pathname + url.search); + return context.redirect(`${loginPath}?return_to=${returnTo}`); + } + + // Return 401 for API routes without auth (PAT, preferences, OAuth connections) + if ((isPatRoute || isPreferencesRoute || (isOAuthConnectionRoute && !isOAuthAuthorizeRoute)) && !session) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, @@ -45,7 +80,14 @@ export const onRequest = defineMiddleware(async (context, next) => { } // Provision tenant on protected routes and API routes - if ((isProtectedRoute || isPatRoute || isPreferencesRoute) && session) { + if ( + (isProtectedRoute || + isPatRoute || + isPreferencesRoute || + isOAuthConnectionRoute || + isConsentRoute) && + session + ) { const { getOrCreateTenant } = await import('./lib/tenant'); const typedEnv = env as unknown as Env; const tenant = await getOrCreateTenant( diff --git a/website/src/pages/.well-known/oauth-authorization-server.ts b/website/src/pages/.well-known/oauth-authorization-server.ts new file mode 100644 index 0000000..1655cac --- /dev/null +++ b/website/src/pages/.well-known/oauth-authorization-server.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const { env } = await import('cloudflare:workers'); + const baseUrl = (env as unknown as Env).BETTER_AUTH_URL; + + return new Response( + JSON.stringify({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/api/oauth/authorize`, + token_endpoint: `${baseUrl}/api/oauth/token`, + registration_endpoint: `${baseUrl}/api/oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none', 'client_secret_post'], + scopes_supported: ['mcp:tools'], + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + }, + }, + ); +}; diff --git a/website/src/pages/api/oauth/apps.ts b/website/src/pages/api/oauth/apps.ts new file mode 100644 index 0000000..99f42bc --- /dev/null +++ b/website/src/pages/api/oauth/apps.ts @@ -0,0 +1,59 @@ +import type { APIRoute } from 'astro'; +import { listAuthorizedApps, revokeAppAccess } from '../../../lib/oauth-apps'; + +export const prerender = false; + +// GET /api/oauth/apps — List authorized OAuth apps for the current tenant +export const GET: APIRoute = async (context) => { + const tenant = context.locals.tenant; + if (!tenant) { + return new Response(JSON.stringify({ error: 'Tenant not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { env } = await import('cloudflare:workers'); + const apps = await listAuthorizedApps((env as unknown as Env).DB, tenant.id); + + return new Response(JSON.stringify({ apps }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; + +// DELETE /api/oauth/apps — Revoke all tokens for an OAuth app +export const DELETE: APIRoute = async (context) => { + const tenant = context.locals.tenant; + if (!tenant) { + return new Response(JSON.stringify({ error: 'Tenant not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + let body: { clientId?: string }; + try { + body = await context.request.json(); + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!body.clientId) { + return new Response(JSON.stringify({ error: 'clientId is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { env } = await import('cloudflare:workers'); + await revokeAppAccess((env as unknown as Env).DB, tenant.id, body.clientId); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/website/src/pages/api/oauth/authorize.ts b/website/src/pages/api/oauth/authorize.ts new file mode 100644 index 0000000..5b99c10 --- /dev/null +++ b/website/src/pages/api/oauth/authorize.ts @@ -0,0 +1,190 @@ +import type { APIRoute } from 'astro'; +import { + getClient, + validateRedirectUri, + createAuthorizationCode, +} from '../../../lib/oauth-server'; + +export const prerender = false; + +// GET /api/oauth/authorize — Authorization endpoint +// Requires Better Auth session (user must be logged in) +// On GET without consent: redirect to consent page +// On POST (consent granted): generate code and redirect +export const GET: APIRoute = async (context) => { + const url = new URL(context.request.url); + console.log('[OAuth Authorize] GET', url.pathname + url.search); + const { env } = await import('cloudflare:workers'); + const db = (env as unknown as Env).DB; + + // Extract OAuth params + const clientId = url.searchParams.get('client_id'); + const redirectUri = url.searchParams.get('redirect_uri'); + const responseType = url.searchParams.get('response_type'); + const codeChallenge = url.searchParams.get('code_challenge'); + const codeChallengeMethod = url.searchParams.get('code_challenge_method'); + const scope = url.searchParams.get('scope') ?? 'mcp:tools'; + const state = url.searchParams.get('state'); + const resource = url.searchParams.get('resource'); + + // Validate required params + if (!clientId || !redirectUri || !responseType || !codeChallenge) { + return new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'Missing required parameters: client_id, redirect_uri, response_type, code_challenge', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (responseType !== 'code') { + return new Response( + JSON.stringify({ + error: 'unsupported_response_type', + error_description: 'Only response_type=code is supported', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (codeChallengeMethod && codeChallengeMethod !== 'S256') { + return new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'Only code_challenge_method=S256 is supported', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Validate client + const client = await getClient(db, clientId); + if (!client) { + return new Response( + JSON.stringify({ + error: 'invalid_client', + error_description: 'Unknown client_id', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Validate redirect_uri + if (!validateRedirectUri(client, redirectUri)) { + return new Response( + JSON.stringify({ + error: 'invalid_redirect_uri', + error_description: 'redirect_uri not registered for this client', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Check if user is logged in (middleware ensures session for /api/oauth/* routes) + if (!context.locals.user || !context.locals.tenant) { + // Redirect to login, preserving the full authorize URL as return_to + const lang = url.pathname.startsWith('/en/') ? 'en' : 'fr'; + const loginPath = lang === 'fr' ? '/connexion' : '/en/login'; + const returnTo = encodeURIComponent(url.pathname + url.search); + return context.redirect(`${loginPath}?return_to=${returnTo}`); + } + + console.log('[OAuth Authorize] User logged in:', context.locals.user?.name, 'tenant:', context.locals.tenant?.id); + + // User is logged in — redirect to consent page with all params + const consentParams = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + scope, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod ?? 'S256', + resource: resource ?? '', + ...(state ? { state } : {}), + client_name: client.clientName, + }); + + return context.redirect(`/consent?${consentParams.toString()}`); +}; + +// POST /api/oauth/authorize — Consent granted, generate code and redirect +export const POST: APIRoute = async (context) => { + console.log('[OAuth Authorize] POST /api/oauth/authorize'); + const { env } = await import('cloudflare:workers'); + const db = (env as unknown as Env).DB; + + // Must be logged in + if (!context.locals.user || !context.locals.tenant) { + console.log('[OAuth Authorize] POST — no session, returning 401'); + return new Response( + JSON.stringify({ error: 'unauthorized' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ); + } + + let body: Record; + const contentType = context.request.headers.get('content-type') ?? ''; + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await context.request.formData(); + body = Object.fromEntries(formData.entries()) as Record; + } else { + try { + body = await context.request.json(); + } catch { + return new Response( + JSON.stringify({ error: 'invalid_request', error_description: 'Invalid body' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + } + + const { client_id, redirect_uri, scope, code_challenge, resource, state, action } = body; + + // Handle deny + if (action === 'deny') { + const params = new URLSearchParams({ + error: 'access_denied', + error_description: 'The user denied the authorization request', + ...(state ? { state } : {}), + }); + return context.redirect(`${redirect_uri}?${params.toString()}`); + } + + // Validate client + const client = await getClient(db, client_id); + if (!client) { + return new Response( + JSON.stringify({ error: 'invalid_client' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (!validateRedirectUri(client, redirect_uri)) { + return new Response( + JSON.stringify({ error: 'invalid_redirect_uri' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + console.log('[OAuth Authorize] POST consent approved for client:', client_id, 'redirect_uri:', redirect_uri); + + // Generate authorization code + const code = await createAuthorizationCode( + db, + client_id, + context.locals.tenant.id, + redirect_uri, + scope ?? 'mcp:tools', + code_challenge, + resource ?? '', + ); + + // Redirect back to client with code + const params = new URLSearchParams({ + code, + ...(state ? { state } : {}), + }); + + console.log('[OAuth Authorize] Redirecting to:', redirect_uri, 'with code:', code.substring(0, 12) + '...'); + return context.redirect(`${redirect_uri}?${params.toString()}`); +}; diff --git a/website/src/pages/api/oauth/callback/google-drive.ts b/website/src/pages/api/oauth/callback/google-drive.ts new file mode 100644 index 0000000..5fecd14 --- /dev/null +++ b/website/src/pages/api/oauth/callback/google-drive.ts @@ -0,0 +1,125 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async (context) => { + const url = new URL(context.request.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + + const { env } = await import('cloudflare:workers'); + const typedEnv = env as unknown as Env; + + // Handle OAuth errors + if (error) { + const lang = url.pathname.startsWith('/en/') ? 'en' : 'fr'; + const dashboardPath = + lang === 'fr' ? '/tableau-de-bord' : '/en/dashboard'; + return context.redirect( + `${dashboardPath}?oauth_error=${encodeURIComponent(error)}`, + ); + } + + if (!code || !state) { + return new Response( + JSON.stringify({ error: 'Missing code or state parameter' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Validate state (CSRF protection) + const kvKey = `oauth_state:${state}`; + const stateData = await typedEnv.SESSION.get(kvKey); + if (!stateData) { + return new Response( + JSON.stringify({ error: 'Invalid or expired state' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Delete the state to prevent replay + await typedEnv.SESSION.delete(kvKey); + + const { tenantId, lang } = JSON.parse(stateData) as { + tenantId: string; + lang: string; + }; + + const dashboardPath = + lang === 'fr' ? '/tableau-de-bord' : '/en/dashboard'; + + // Exchange code for tokens + const redirectUri = new URL( + '/api/oauth/callback/google-drive', + typedEnv.BETTER_AUTH_URL, + ).toString(); + + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: typedEnv.OAUTH_GOOGLE_CLIENT_ID, + client_secret: typedEnv.OAUTH_GOOGLE_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!tokenResponse.ok) { + const errorBody = await tokenResponse.text(); + console.error('Token exchange failed:', errorBody); + return context.redirect( + `${dashboardPath}?oauth_error=token_exchange_failed`, + ); + } + + const tokens = (await tokenResponse.json()) as { + access_token: string; + refresh_token?: string; + expires_in: number; + scope: string; + }; + + if (!tokens.refresh_token) { + return context.redirect( + `${dashboardPath}?oauth_error=no_refresh_token`, + ); + } + + // Get the Google account email for display + const userinfoResponse = await fetch( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { headers: { Authorization: `Bearer ${tokens.access_token}` } }, + ); + + let email = 'Google Drive'; + if (userinfoResponse.ok) { + const userinfo = (await userinfoResponse.json()) as { email?: string }; + if (userinfo.email) { + email = userinfo.email; + } + } + + // Store the connection in D1 + const { createConnection } = await import( + '@/lib/oauth-connections' + ); + + const expiresAt = new Date( + Date.now() + tokens.expires_in * 1000, + ).toISOString(); + + await createConnection(typedEnv.DB, tenantId, { + provider: 'google_drive', + displayName: email, + providerAccountId: email, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + tokenExpiresAt: expiresAt, + scopes: tokens.scope, + }); + + return context.redirect(`${dashboardPath}?oauth_success=google_drive`); +}; diff --git a/website/src/pages/api/oauth/connect/google-drive.ts b/website/src/pages/api/oauth/connect/google-drive.ts new file mode 100644 index 0000000..74e1fb5 --- /dev/null +++ b/website/src/pages/api/oauth/connect/google-drive.ts @@ -0,0 +1,57 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async (context) => { + const tenant = context.locals.tenant; + if (!tenant) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { env } = await import('cloudflare:workers'); + const typedEnv = env as unknown as Env; + + const clientId = typedEnv.OAUTH_GOOGLE_CLIENT_ID; + if (!clientId) { + return new Response( + JSON.stringify({ error: 'Google OAuth not configured' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + } + + const state = crypto.randomUUID(); + + const redirectUri = new URL( + '/api/oauth/callback/google-drive', + typedEnv.BETTER_AUTH_URL, + ).toString(); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/userinfo.email', + access_type: 'offline', + prompt: 'consent', + state, + include_granted_scopes: 'true', + }); + + const url = context.request.url; + const lang = new URL(url).pathname.startsWith('/en/') ? 'en' : 'fr'; + + // Store state in KV for CSRF validation (5 min TTL) + const kvKey = `oauth_state:${state}`; + await typedEnv.SESSION.put( + kvKey, + JSON.stringify({ tenantId: tenant.id, lang }), + { expirationTtl: 300 }, + ); + + return context.redirect( + `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, + ); +}; diff --git a/website/src/pages/api/oauth/connections.ts b/website/src/pages/api/oauth/connections.ts new file mode 100644 index 0000000..f80e62a --- /dev/null +++ b/website/src/pages/api/oauth/connections.ts @@ -0,0 +1,65 @@ +import type { APIRoute } from 'astro'; + +export const prerender = false; + +export const GET: APIRoute = async (context) => { + const tenant = context.locals.tenant; + if (!tenant) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { env } = await import('cloudflare:workers'); + const typedEnv = env as unknown as Env; + const { listConnections } = await import('@/lib/oauth-connections'); + + const url = new URL(context.request.url); + const provider = url.searchParams.get('provider') ?? undefined; + + const connections = await listConnections(typedEnv.DB, tenant.id, provider); + + return new Response(JSON.stringify(connections), { + headers: { 'Content-Type': 'application/json' }, + }); +}; + +export const DELETE: APIRoute = async (context) => { + const tenant = context.locals.tenant; + if (!tenant) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const { env } = await import('cloudflare:workers'); + const typedEnv = env as unknown as Env; + const { deleteConnection } = await import('@/lib/oauth-connections'); + + const body = (await context.request.json()) as { connectionId?: string }; + if (!body.connectionId) { + return new Response( + JSON.stringify({ error: 'connectionId is required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + const deleted = await deleteConnection( + typedEnv.DB, + tenant.id, + body.connectionId, + ); + + if (!deleted) { + return new Response( + JSON.stringify({ error: 'Connection not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ); + } + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json' }, + }); +}; diff --git a/website/src/pages/api/oauth/register.ts b/website/src/pages/api/oauth/register.ts new file mode 100644 index 0000000..5ef6bdb --- /dev/null +++ b/website/src/pages/api/oauth/register.ts @@ -0,0 +1,146 @@ +import type { APIRoute } from 'astro'; +import { + registerClient, + OAuthError, + type RegisterClientParams, +} from '../../../lib/oauth-server'; + +export const prerender = false; + +// POST /api/oauth/register — Dynamic Client Registration (RFC 7591) +// No auth required +export const POST: APIRoute = async (context) => { + console.log('[OAuth DCR] POST /api/oauth/register'); + let body: RegisterClientParams; + try { + body = await context.request.json(); + } catch { + return new Response( + JSON.stringify({ + error: 'invalid_client_metadata', + error_description: 'Invalid JSON body', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Validate required fields + if (!body.client_name?.trim()) { + return new Response( + JSON.stringify({ + error: 'invalid_client_metadata', + error_description: 'client_name is required', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + if (!Array.isArray(body.redirect_uris) || body.redirect_uris.length === 0) { + return new Response( + JSON.stringify({ + error: 'invalid_client_metadata', + error_description: 'redirect_uris must be a non-empty array', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + // Validate each redirect URI + for (const uri of body.redirect_uris) { + try { + const parsed = new URL(uri); + // Allow http only for localhost + if ( + parsed.protocol === 'http:' && + parsed.hostname !== 'localhost' && + parsed.hostname !== '127.0.0.1' + ) { + return new Response( + JSON.stringify({ + error: 'invalid_redirect_uri', + error_description: `Non-localhost redirect_uri must use HTTPS: ${uri}`, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + } catch { + return new Response( + JSON.stringify({ + error: 'invalid_redirect_uri', + error_description: `Invalid redirect_uri: ${uri}`, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + } + + // Validate grant_types if provided + const allowedGrants = ['authorization_code', 'refresh_token']; + if (body.grant_types) { + for (const gt of body.grant_types) { + if (!allowedGrants.includes(gt)) { + return new Response( + JSON.stringify({ + error: 'invalid_client_metadata', + error_description: `Unsupported grant_type: ${gt}`, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + } + } + + // Validate token_endpoint_auth_method if provided + const allowedAuthMethods = ['none', 'client_secret_post']; + if ( + body.token_endpoint_auth_method && + !allowedAuthMethods.includes(body.token_endpoint_auth_method) + ) { + return new Response( + JSON.stringify({ + error: 'invalid_client_metadata', + error_description: `Unsupported token_endpoint_auth_method: ${body.token_endpoint_auth_method}`, + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + + console.log('[OAuth DCR] Registering client:', body.client_name, 'redirect_uris:', body.redirect_uris); + + try { + const { env } = await import('cloudflare:workers'); + const client = await registerClient((env as unknown as Env).DB, body); + + console.log('[OAuth DCR] Client registered:', client.id); + return new Response( + JSON.stringify({ + client_id: client.id, + client_name: client.clientName, + redirect_uris: JSON.parse(client.redirectUris), + grant_types: JSON.parse(client.grantTypes), + token_endpoint_auth_method: client.tokenEndpointAuthMethod, + client_uri: client.clientUri, + logo_uri: client.logoUri, + }), + { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } catch (e) { + if (e instanceof OAuthError) { + return new Response(JSON.stringify(e.toJSON()), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + console.error('DCR error:', e); + return new Response( + JSON.stringify({ + error: 'server_error', + error_description: 'Internal server error', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ); + } +}; diff --git a/website/src/pages/api/oauth/token.ts b/website/src/pages/api/oauth/token.ts new file mode 100644 index 0000000..8e0ace5 --- /dev/null +++ b/website/src/pages/api/oauth/token.ts @@ -0,0 +1,137 @@ +import type { APIRoute } from 'astro'; +import { exchangeCode, refreshAccessToken, OAuthError } from '../../../lib/oauth-server'; + +export const prerender = false; + +// POST /api/oauth/token — Token endpoint +// No session auth required (clients send client_id in body) +export const POST: APIRoute = async (context) => { + console.log('[OAuth Token] POST /api/oauth/token'); + console.log('[OAuth Token] Origin:', context.request.headers.get('origin'), 'Content-Type:', context.request.headers.get('content-type')); + const { env } = await import('cloudflare:workers'); + const db = (env as unknown as Env).DB; + + let params: Record; + const contentType = context.request.headers.get('content-type') ?? ''; + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await context.request.formData(); + params = Object.fromEntries(formData.entries()) as Record; + } else { + try { + params = await context.request.json(); + } catch { + return new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'Invalid body', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ); + } + } + + const grantType = params.grant_type; + + try { + if (grantType === 'authorization_code') { + const { code, client_id, redirect_uri, code_verifier } = params; + console.log('[OAuth Token] authorization_code grant — client_id:', client_id, 'redirect_uri:', redirect_uri, 'code:', code?.substring(0, 12) + '...', 'code_verifier present:', !!code_verifier); + + if (!code || !client_id || !redirect_uri || !code_verifier) { + return new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'Missing required parameters: code, client_id, redirect_uri, code_verifier', + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }, + ); + } + + const result = await exchangeCode(db, code, client_id, redirect_uri, code_verifier); + + console.log('[OAuth Token] Token issued successfully, access_token prefix:', result.access_token?.substring(0, 12)); + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + + if (grantType === 'refresh_token') { + const { refresh_token, client_id } = params; + + if (!refresh_token || !client_id) { + return new Response( + JSON.stringify({ + error: 'invalid_request', + error_description: 'Missing required parameters: refresh_token, client_id', + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }, + ); + } + + const result = await refreshAccessToken(db, refresh_token, client_id); + + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + + return new Response( + JSON.stringify({ + error: 'unsupported_grant_type', + error_description: `Unsupported grant_type: ${grantType}`, + }), + { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }, + ); + } catch (e) { + if (e instanceof OAuthError) { + console.error('[OAuth Token] OAuthError:', e.code, e.message); + return new Response(JSON.stringify(e.toJSON()), { + status: 400, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }); + } + console.error('Token endpoint error:', e); + return new Response( + JSON.stringify({ + error: 'server_error', + error_description: 'Internal server error', + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }, + ); + } +}; diff --git a/website/src/pages/consent.astro b/website/src/pages/consent.astro new file mode 100644 index 0000000..0de3c62 --- /dev/null +++ b/website/src/pages/consent.astro @@ -0,0 +1,217 @@ +--- +export const prerender = false; +import Layout from '../layouts/Layout.astro'; +import { useTranslations } from '../i18n/utils'; + +const t = useTranslations('fr'); + +const url = new URL(Astro.request.url); +const clientName = url.searchParams.get('client_name') ?? 'Application'; +const clientId = url.searchParams.get('client_id') ?? ''; +const redirectUri = url.searchParams.get('redirect_uri') ?? ''; +const scope = url.searchParams.get('scope') ?? 'mcp:tools'; +const codeChallenge = url.searchParams.get('code_challenge') ?? ''; +const codeChallengeMethod = url.searchParams.get('code_challenge_method') ?? 'S256'; +const resource = url.searchParams.get('resource') ?? ''; +const state = url.searchParams.get('state') ?? ''; + +const userName = Astro.locals.user?.name ?? ''; + +// Map scope codes to human-readable descriptions +const scopeDescriptions: Record = { + 'mcp:tools': { + fr: 'Utiliser les outils MCP (lire, modifier et formater vos documents Word)', + en: 'Use MCP tools (read, edit and format your Word documents)', + }, +}; + +const scopes = scope.split(' ').filter(Boolean); +--- + + + + + + diff --git a/website/src/pages/en/dashboard.astro b/website/src/pages/en/dashboard.astro index ef49516..a454b28 100644 --- a/website/src/pages/en/dashboard.astro +++ b/website/src/pages/en/dashboard.astro @@ -4,6 +4,8 @@ import Layout from '../../layouts/Layout.astro'; import Nav from '../../components/Nav.astro'; import Footer from '../../components/Footer.astro'; import PatManager from '../../components/PatManager.astro'; +import ConnectionsManager from '../../components/ConnectionsManager.astro'; +import OAuthAppsManager from '../../components/OAuthAppsManager.astro'; import Doccy from '../../components/Doccy.astro'; import { useTranslations } from '../../i18n/utils'; @@ -19,18 +21,15 @@ const tenant = Astro.locals.tenant!;

    {t('dashboard.welcome')}, {user.name}

    -
    -
    -

    {t('dashboard.storage')}

    -

    {t('dashboard.comingSoon')}

    -
    + -
    -

    {t('dashboard.documents')}

    -

    {t('dashboard.comingSoon')}

    -
    +
    +

    {t('dashboard.documents')}

    +

    {t('dashboard.comingSoon')}

    + +
    @@ -73,6 +72,7 @@ const tenant = Astro.locals.tenant!; border: 1px solid var(--border-subtle); border-radius: var(--radius-l); padding: var(--spacing-xxl); + margin-bottom: var(--spacing-xxxl); } .card h3 { diff --git a/website/src/pages/tableau-de-bord.astro b/website/src/pages/tableau-de-bord.astro index d2bae15..a59b6da 100644 --- a/website/src/pages/tableau-de-bord.astro +++ b/website/src/pages/tableau-de-bord.astro @@ -4,6 +4,8 @@ import Layout from '../layouts/Layout.astro'; import Nav from '../components/Nav.astro'; import Footer from '../components/Footer.astro'; import PatManager from '../components/PatManager.astro'; +import ConnectionsManager from '../components/ConnectionsManager.astro'; +import OAuthAppsManager from '../components/OAuthAppsManager.astro'; import Doccy from '../components/Doccy.astro'; import { useTranslations } from '../i18n/utils'; @@ -19,18 +21,15 @@ const tenant = Astro.locals.tenant!;

    {t('dashboard.welcome')}, {user.name}

    -
    -
    -

    {t('dashboard.storage')}

    -

    {t('dashboard.comingSoon')}

    -
    + -
    -

    {t('dashboard.documents')}

    -

    {t('dashboard.comingSoon')}

    -
    +
    +

    {t('dashboard.documents')}

    +

    {t('dashboard.comingSoon')}

    + + @@ -73,6 +72,7 @@ const tenant = Astro.locals.tenant!; border: 1px solid var(--border-subtle); border-radius: var(--radius-l); padding: var(--spacing-xxl); + margin-bottom: var(--spacing-xxxl); } .card h3 { diff --git a/website/tsconfig.json b/website/tsconfig.json index d3362da..7069b06 100644 --- a/website/tsconfig.json +++ b/website/tsconfig.json @@ -2,6 +2,10 @@ "extends": "astro/tsconfigs/strict", "compilerOptions": { "strictNullChecks": true, - "types": ["@cloudflare/workers-types"] + "types": ["@cloudflare/workers-types"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } } }