diff --git a/.cargo/config.toml b/.cargo/config.toml index 67dbd242..ad867b3b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,9 +3,29 @@ [target.x86_64-apple-darwin.env] MACOSX_DEPLOYMENT_TARGET = "10.12" +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "link-arg=-framework", + "-C", "link-arg=AppKit", + "-C", "link-arg=-framework", + "-C", "link-arg=ApplicationServices", + "-C", "link-arg=-framework", + "-C", "link-arg=Foundation", +] + [target.aarch64-apple-darwin.env] MACOSX_DEPLOYMENT_TARGET = "11.0" +[target.aarch64-apple-darwin] +rustflags = [ + "-C", "link-arg=-framework", + "-C", "link-arg=AppKit", + "-C", "link-arg=-framework", + "-C", "link-arg=ApplicationServices", + "-C", "link-arg=-framework", + "-C", "link-arg=Foundation", +] + [target.x86_64-pc-windows-msvc] rustflags = ["-C", "link-arg=/DEBUG:FASTLINK"] diff --git a/.dockerignore b/.dockerignore index 4aeadf24..9a1c4796 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,7 @@ dist/ build/ *.tgz *.tar.gz +remote-frontend/dist/ # IDE and editor files .vscode/ diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index e0e85526..5f088bf6 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -25,7 +25,7 @@ permissions: env: NODE_VERSION: 22 - PNPM_VERSION: 10.8.1 + PNPM_VERSION: 10.13.1 RUST_TOOLCHAIN: nightly-2025-05-18 jobs: @@ -46,7 +46,7 @@ jobs: - name: Install cargo-edit if: steps.cache-cargo-edit.outputs.cache-hit != 'true' run: cargo install cargo-edit - + - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -111,7 +111,7 @@ jobs: git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add package.json pnpm-lock.yaml npx-cli/package.json frontend/package.json Cargo.lock - git add $(find . -name Cargo.toml) + git add $(find . -name Cargo.toml) git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}" git tag -a ${{ steps.version.outputs.new_tag }} -m "Release ${{ steps.version.outputs.new_tag }}" git push @@ -244,6 +244,7 @@ jobs: env: POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} POSTHOG_API_ENDPOINT: ${{ secrets.POSTHOG_API_ENDPOINT }} + VK_SHARED_API_BASE: ${{ secrets.VK_SHARED_API_BASE }} - name: Setup Sentry CLI uses: matbour/setup-sentry-cli@v2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 124077c9..9981d9ee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,7 +22,7 @@ permissions: env: NODE_VERSION: 22 - PNPM_VERSION: 10.8.1 + PNPM_VERSION: 10.13.1 jobs: publish: diff --git a/.github/workflows/remote-deploy-dev.yml b/.github/workflows/remote-deploy-dev.yml new file mode 100644 index 00000000..68278b2a --- /dev/null +++ b/.github/workflows/remote-deploy-dev.yml @@ -0,0 +1,30 @@ +name: Remote Deploy Dev + +on: + push: + branches: + - gabriel/share + - main + paths: + - crates/remote/** + - remote-frontend/** + workflow_dispatch: + +jobs: + run-remote-deploy: + name: Deploy Remote Dev + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Dispatch dev remote deployment workflow + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }} + repository: BloopAI/vibe-kanban-remote-deployment + event-type: vibe-kanban-remote-deploy-dev + client-payload: | + { + "ref": "${{ github.ref_name }}", + "sha": "${{ github.sha }}" + } diff --git a/.github/workflows/remote-deploy-prod.yml b/.github/workflows/remote-deploy-prod.yml new file mode 100644 index 00000000..3345b001 --- /dev/null +++ b/.github/workflows/remote-deploy-prod.yml @@ -0,0 +1,23 @@ +name: Remote Deploy Prod + +on: + workflow_dispatch: + +jobs: + run-remote-deploy: + name: Deploy Remote Prod + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Dispatch prod remote deployment workflow + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.REMOTE_DEPLOYMENT_TOKEN }} + repository: BloopAI/vibe-kanban-remote-deployment + event-type: vibe-kanban-remote-deploy-prod + client-payload: | + { + "ref": "${{ github.ref_name }}", + "sha": "${{ github.sha }}" + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 432e93ff..468369f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ concurrency: env: CARGO_TERM_COLOR: always NODE_VERSION: 22 - PNPM_VERSION: 10.8.1 + PNPM_VERSION: 10.13.1 jobs: test: diff --git a/.gitignore b/.gitignore index e8012f93..666bf09c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ yarn-error.log* # Environment variables .env +.env.remote .env.local .env.development.local .env.test.local @@ -67,6 +68,7 @@ frontend/dist crates/executors/bindings crates/utils/bindings crates/services/bindings +crates/server/bindings build-npm-package-codesign.sh @@ -82,3 +84,4 @@ dev_assets .ssh vibe-kanban-cloud/ +remote-frontend/dist \ No newline at end of file diff --git a/.npmrc b/.npmrc index 86916fa5..b6f27f13 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ -package-lock=false engine-strict=true diff --git a/AGENTS.md b/AGENTS.md index d5d53b84..82ee3bb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,19 +12,20 @@ ## Managing Shared Types Between Rust and TypeScript ts-rs allows you to derive TypeScript types from Rust structs/enums. By annotating your Rust types with #[derive(TS)] and related macros, ts-rs will generate .ts declaration files for those types. -When making changes to the types, you can regenerate them using `npm run generate-types` +When making changes to the types, you can regenerate them using `pnpm run generate-types` Do not manually edit shared/types.ts, instead edit crates/server/src/bin/generate_types.rs ## Build, Test, and Development Commands - Install: `pnpm i` - Run dev (frontend + backend with ports auto-assigned): `pnpm run dev` -- Backend (watch): `npm run backend:dev:watch` -- Frontend (dev): `npm run frontend:dev` -- Type checks: `npm run check` (frontend) and `npm run backend:check` (Rust cargo check) +- Backend (watch): `pnpm run backend:dev:watch` +- Frontend (dev): `pnpm run frontend:dev` +- Type checks: `pnpm run check` (frontend) and `pnpm run backend:check` (Rust cargo check) - Rust tests: `cargo test --workspace` -- Generate TS types from Rust: `npm run generate-types` (or `generate-types:check` in CI) -- Prepare SQLx (offline): `npm run prepare-db` -- Local NPX build: `npm run build:npx` then `npm pack` in `npx-cli/` +- Generate TS types from Rust: `pnpm run generate-types` (or `generate-types:check` in CI) +- Prepare SQLx (offline): `pnpm run prepare-db` +- Prepare SQLx (remote package, postgres): `pnpm run remote:prepare-db` +- Local NPX build: `pnpm run build:npx` then `pnpm pack` in `npx-cli/` ## Coding Style & Naming Conventions - Rust: `rustfmt` enforced (`rustfmt.toml`); group imports by crate; snake_case modules, PascalCase types. @@ -33,8 +34,8 @@ Do not manually edit shared/types.ts, instead edit crates/server/src/bin/generat ## Testing Guidelines - Rust: prefer unit tests alongside code (`#[cfg(test)]`), run `cargo test --workspace`. Add tests for new logic and edge cases. -- Frontend: ensure `npm run check` and `npm run lint` pass. If adding runtime logic, include lightweight tests (e.g., Vitest) in the same directory. +- Frontend: ensure `pnpm run check` and `pnpm run lint` pass. If adding runtime logic, include lightweight tests (e.g., Vitest) in the same directory. ## Security & Config Tips -- Use `.env` for local overrides; never commit secrets. Key envs: `FRONTEND_PORT`, `BACKEND_PORT`, `HOST`, optional `GITHUB_CLIENT_ID` for custom OAuth. +- Use `.env` for local overrides; never commit secrets. Key envs: `FRONTEND_PORT`, `BACKEND_PORT`, `HOST` - Dev ports and assets are managed by `scripts/setup-dev-environment.js`. diff --git a/CLAUDE.md b/CLAUDE.md index efb8842c..1a859814 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,11 +119,10 @@ shared/types.ts # Auto-generated TypeScript types from Rust ### Environment Variables Build-time (set when building): -- `GITHUB_CLIENT_ID`: GitHub OAuth app ID (default: Bloop AI's app) - `POSTHOG_API_KEY`: Analytics key (optional) Runtime: - `BACKEND_PORT`: Backend server port (default: auto-assign) - `FRONTEND_PORT`: Frontend dev port (default: 3000) - `HOST`: Backend host (default: 127.0.0.1) -- `DISABLE_WORKTREE_ORPHAN_CLEANUP`: Debug flag for worktrees \ No newline at end of file +- `DISABLE_WORKTREE_ORPHAN_CLEANUP`: Debug flag for worktrees diff --git a/Cargo.lock b/Cargo.lock index 07ad65d3..827c14b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,9 +202,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -230,12 +230,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "arc-swap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" - [[package]] name = "async-broadcast" version = "0.7.2" @@ -329,7 +323,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -369,7 +363,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -386,7 +380,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -410,13 +404,41 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" dependencies = [ - "axum-core", + "axum-core 0.5.5", "axum-macros", "base64", "bytes", @@ -428,7 +450,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "multer", @@ -442,7 +464,28 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite", - "tower 0.5.2", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -467,6 +510,53 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum 0.7.9", + "axum-core 0.4.5", + "bytes", + "fastrand", + "futures-util", + "headers", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum 0.8.6", + "axum-core 0.5.5", + "bytes", + "futures-util", + "headers", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-macros" version = "0.5.0" @@ -475,7 +565,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -504,6 +594,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -531,9 +627,9 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -604,14 +700,14 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] name = "bstr" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "serde", @@ -646,9 +742,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" dependencies = [ "find-msvc-tools", "jobserver", @@ -783,16 +879,6 @@ dependencies = [ "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" @@ -866,6 +952,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -876,6 +974,33 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "darling" version = "0.21.3" @@ -897,7 +1022,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -908,7 +1033,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -936,22 +1061,15 @@ name = "db" version = "0.0.116" dependencies = [ "anyhow", - "async-trait", "chrono", "executors", - "futures-util", - "regex", - "sentry-tracing", "serde", "serde_json", "sqlx", "strum", "strum_macros", "thiserror 2.0.17", - "tokio", - "tokio-util", "tracing", - "tracing-subscriber", "ts-rs 11.0.1", "utils", "uuid", @@ -973,7 +1091,7 @@ version = "0.0.116" dependencies = [ "anyhow", "async-trait", - "axum", + "axum 0.8.6", "db", "executors", "futures", @@ -1000,9 +1118,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", "serde_core", @@ -1036,7 +1154,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "unicode-xid", ] @@ -1121,7 +1239,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -1148,6 +1266,44 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1157,6 +1313,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1181,7 +1358,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -1202,7 +1379,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -1265,7 +1442,7 @@ version = "0.0.116" dependencies = [ "agent-client-protocol", "async-trait", - "axum", + "axum 0.8.6", "bon", "bytes", "chrono", @@ -1280,14 +1457,13 @@ dependencies = [ "fork_stream", "futures", "futures-io", + "icu_provider", "json-patch", "lazy_static", "mcp-types", "os_pipe", "regex", - "rust-embed", "schemars 1.0.4", - "sentry-tracing", "serde", "serde_json", "sha2", @@ -1302,7 +1478,6 @@ dependencies = [ "tokio-util", "toml", "tracing", - "tracing-subscriber", "ts-rs 11.0.1", "utils", "uuid", @@ -1316,6 +1491,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "file-id" version = "0.2.3" @@ -1345,9 +1536,9 @@ dependencies = [ [[package]] name = "fixed_decimal" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35943d22b2f19c0cb198ecf915910a8158e94541c89dcc63300d7799d46c2c5e" +checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" dependencies = [ "displaydoc", "smallvec", @@ -1515,7 +1706,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -1556,6 +1747,7 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1578,9 +1770,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1635,6 +1829,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.12" @@ -1692,6 +1897,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http 1.3.1", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.3.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1730,11 +1959,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1793,6 +2022,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1837,26 +2072,12 @@ dependencies = [ "http 1.3.1", "hyper", "hyper-util", - "log", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", + "webpki-roots 1.0.4", ] [[package]] @@ -1927,9 +2148,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -1940,34 +2161,31 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec61c43fdc4e368a9f450272833123a8ef0d7083a44597660ce94d791b8a2e2" +checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" dependencies = [ - "displaydoc", "fixed_decimal", "icu_decimal_data", "icu_locale", "icu_locale_core", "icu_provider", - "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_decimal_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b70963bc35f9bdf1bc66a5c1f458f4991c1dc71760e00fa06016b2c76b2738d5" +checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" [[package]] name = "icu_locale" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae5921528335e91da1b6c695dbf1ec37df5ac13faa3f91e5640be93aa2fbefd" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_locale_data", @@ -1979,12 +2197,13 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1992,17 +2211,16 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fdef0c124749d06a743c69e938350816554eb63ac979166590e2b4ee4252765" +checksum = "f03e2fcaefecdf05619f3d6f91740e79ab969b4dd54f77cbf546b1d0d28e3147" [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2013,42 +2231,40 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -2085,9 +2301,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.24" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81776e6f9464432afcc28d03e52eb101c93b6f0566f52aef2427663e700f0403" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -2210,9 +2426,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -2256,6 +2472,29 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "jsonwebtoken" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d119c6924272d16f0ab9ce41f7aa0bfef9340c00b0bb7ca3dd3b263d4a9150b" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -2390,9 +2629,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "local-channel" @@ -2412,34 +2651,26 @@ dependencies = [ "anyhow", "async-stream", "async-trait", - "axum", "bytes", - "chrono", "command-group", "db", "deployment", "executors", "futures", - "ignore", "json-patch", "nix 0.29.0", "notify", "notify-debouncer-full", - "notify-rust", "openssl-sys", - "regex", "reqwest", - "rust-embed", - "serde", + "sentry", "serde_json", "services", "sqlx", + "thiserror 2.0.17", "tokio", - "tokio-stream", "tokio-util", "tracing", - "tracing-subscriber", - "ts-rs 11.0.1", "utils", "uuid", ] @@ -2466,10 +2697,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] -name = "mac-notification-sys" -version = "0.6.6" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-notification-sys" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee70bb2bba058d58e252d2944582d634fc884fc9c489a966d428dedcf653e97" dependencies = [ "cc", "objc2", @@ -2486,6 +2723,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -2620,7 +2863,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -2743,11 +2986,10 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -2842,46 +3084,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "octocrab" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86996964f8b721067b6ed238aa0ccee56ecad6ee5e714468aa567992d05d2b91" -dependencies = [ - "arc-swap", - "async-trait", - "base64", - "bytes", - "cfg-if", - "chrono", - "either", - "futures", - "futures-util", - "http 1.3.1", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-timeout", - "hyper-util", - "jsonwebtoken", - "once_cell", - "percent-encoding", - "pin-project", - "secrecy", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "snafu", - "tokio", - "tower 0.5.2", - "tower-http", - "tracing", - "url", - "web-time", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2922,7 +3124,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -2991,6 +3193,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3074,7 +3300,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -3162,11 +3388,12 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ - "serde", + "serde_core", + "writeable", "zerovec", ] @@ -3192,7 +3419,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.107", + "syn 2.0.108", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", ] [[package]] @@ -3206,9 +3442,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -3231,6 +3467,61 @@ dependencies = [ "memchr", ] +[[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 2.1.1", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.17", + "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 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "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.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.41" @@ -3353,7 +3644,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -3391,6 +3682,41 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "remote" +version = "0.0.1" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.6", + "axum-extra 0.10.3", + "base64", + "chrono", + "futures", + "hmac", + "jsonwebtoken 9.3.1", + "rand 0.9.2", + "reqwest", + "secrecy", + "sentry", + "sentry-tracing", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tower-http 0.5.2", + "tracing", + "tracing-error", + "tracing-subscriber", + "url", + "utils", + "uuid", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -3417,6 +3743,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3424,13 +3752,25 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tower 0.5.2", - "tower-http", + "tokio-rustls", + "tower", + "tower-http 0.6.6", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.4", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", ] [[package]] @@ -3478,7 +3818,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -3521,7 +3861,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.107", + "syn 2.0.108", "walkdir", ] @@ -3547,6 +3887,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[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" @@ -3575,7 +3921,6 @@ version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -3584,18 +3929,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework 3.5.1", -] - [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -3607,18 +3940,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ + "web-time", "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", @@ -3691,7 +4025,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -3700,6 +4034,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -3716,20 +4064,7 @@ 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", "core-foundation-sys", "libc", "security-framework-sys", @@ -3910,7 +4245,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -3921,7 +4256,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -3957,7 +4292,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4009,7 +4344,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4017,13 +4352,11 @@ name = "server" version = "0.0.116" dependencies = [ "anyhow", - "async-trait", - "axum", + "axum 0.8.6", + "axum-extra 0.9.6", "chrono", - "command-group", "db", "deployment", - "dirs 5.0.1", "dotenv", "executors", "futures-util", @@ -4032,29 +4365,29 @@ dependencies = [ "local-deployment", "mime_guess", "nix 0.29.0", - "octocrab", "openssl-sys", "os_info", - "regex", + "rand 0.8.5", "reqwest", "rmcp", "rust-embed", "schemars 1.0.4", + "secrecy", + "sentry", "serde", "serde_json", "services", + "sha2", "shlex", "sqlx", "strip-ansi-escapes", - "tempfile", "thiserror 2.0.17", "tokio", "tokio-util", - "toml", - "tower 0.4.13", "tracing", "tracing-subscriber", "ts-rs 11.0.1", + "url", "utils", "uuid", ] @@ -4065,14 +4398,12 @@ version = "0.0.116" dependencies = [ "anyhow", "async-trait", - "axum", + "axum 0.8.6", "backon", "base64", "chrono", - "command-group", "dashmap", "db", - "directories", "dirs 5.0.1", "dunce", "executors", @@ -4083,20 +4414,19 @@ dependencies = [ "ignore", "json-patch", "lazy_static", - "libc", "moka", "notify", "notify-debouncer-full", "notify-rust", - "octocrab", "once_cell", - "open", "openssl-sys", "os_info", "regex", + "remote", "reqwest", "rust-embed", "secrecy", + "security-framework", "serde", "serde_json", "sha2", @@ -4107,13 +4437,13 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "tracing", - "tracing-subscriber", "ts-rs 11.0.1", + "url", "utils", "uuid", - "xdg", ] [[package]] @@ -4214,27 +4544,6 @@ dependencies = [ "serde", ] -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.107", -] - [[package]] name = "socket2" version = "0.5.10" @@ -4335,7 +4644,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4358,7 +4667,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.107", + "syn 2.0.108", "tokio", "url", ] @@ -4525,7 +4834,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4547,9 +4856,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.107" +version = "2.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" dependencies = [ "proc-macro2", "quote", @@ -4573,7 +4882,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4592,7 +4901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", - "core-foundation 0.9.4", + "core-foundation", "system-configuration-sys", ] @@ -4672,7 +4981,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4683,7 +4992,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4728,11 +5037,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4776,7 +5086,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -4819,8 +5129,12 @@ checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -4908,21 +5222,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower" version = "0.5.2" @@ -4934,10 +5233,35 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", "tokio-util", "tower-layer", "tower-service", "tracing", + "uuid", ] [[package]] @@ -4953,10 +5277,9 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower 0.5.2", + "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -4991,7 +5314,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -5004,6 +5327,16 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -5015,6 +5348,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.20" @@ -5025,12 +5368,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -5070,7 +5416,7 @@ source = "git+https://github.com/xazukx/ts-rs.git?branch=use-ts-enum#b5c8277ac9f dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "termcolor", ] @@ -5082,7 +5428,7 @@ checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "termcolor", ] @@ -5098,8 +5444,11 @@ dependencies = [ "httparse", "log", "rand 0.9.2", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.17", + "url", "utf-8", ] @@ -5143,24 +5492,24 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" @@ -5239,19 +5588,21 @@ name = "utils" version = "0.0.116" dependencies = [ "async-stream", - "axum", - "base64", + "async-trait", + "axum 0.8.6", "bytes", "chrono", + "dashmap", "directories", "dirs 5.0.1", "futures", "futures-util", "git2", "json-patch", - "libc", + "jsonwebtoken 10.1.0", "open", "regex", + "reqwest", "rust-embed", "sentry", "sentry-tracing", @@ -5260,12 +5611,16 @@ dependencies = [ "shellexpand", "shlex", "similar", + "sqlx", + "thiserror 2.0.17", "tokio", "tokio-stream", + "tokio-tungstenite", "tokio-util", "tracing", "tracing-subscriber", "ts-rs 11.0.1", + "url", "uuid", "which", "windows-sys 0.61.2", @@ -5353,9 +5708,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -5364,25 +5719,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.107", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -5393,9 +5734,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5403,31 +5744,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.107", - "wasm-bindgen-backend", + "syn 2.0.108", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -5440,15 +5781,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", - "serde", "wasm-bindgen", ] [[package]] name = "webpki-root-certs" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05d651ec480de84b762e7be71e6efa7461699c19d9e2c272c8d93455f567786e" +checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" dependencies = [ "rustls-pki-types", ] @@ -5459,14 +5799,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.3", + "webpki-roots 1.0.4", ] [[package]] name = "webpki-roots" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] @@ -5590,7 +5930,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -5601,7 +5941,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -5961,9 +6301,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xdg" @@ -5973,11 +6313,10 @@ checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -5985,13 +6324,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "synstructure", ] @@ -6038,7 +6377,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "zbus_names", "zvariant", "zvariant_utils", @@ -6073,7 +6412,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -6093,7 +6432,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "synstructure", ] @@ -6105,9 +6444,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -6116,10 +6455,11 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -6127,13 +6467,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", ] [[package]] @@ -6159,7 +6499,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.108", "zvariant_utils", ] @@ -6172,6 +6512,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.107", + "syn 2.0.108", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 0ccc07cb..9d8830db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,30 @@ [workspace] resolver = "2" -members = ["crates/server", "crates/db", "crates/executors", "crates/services", "crates/utils", "crates/local-deployment", "crates/deployment"] +members = [ + "crates/server", + "crates/db", + "crates/executors", + "crates/services", + "crates/utils", + "crates/local-deployment", + "crates/deployment", + "crates/remote" +] [workspace.dependencies] tokio = { version = "1.0", features = ["full"] } axum = { version = "0.8.4", features = ["macros", "multipart", "ws"] } -tower-http = { version = "0.5", features = ["cors"] } +tower-http = { version = "0.5", features = ["cors", "request-id", "trace", "fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } anyhow = "1.0" +openssl-sys = { version = "0.9", features = ["vendored"] } thiserror = "2.0.12" tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -openssl-sys = { version = "0.9", features = ["vendored"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } ts-rs = { git = "https://github.com/xazukx/ts-rs.git", branch = "use-ts-enum", features = ["uuid-impl", "chrono-impl", "no-serde-warnings", "serde-json-impl"] } schemars = { version = "1.0.4", features = ["derive", "chrono04", "uuid1", "preserve_order"] } +async-trait = "0.1" [profile.release] debug = true diff --git a/README.md b/README.md index f94c0a2d..e42358f5 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,6 @@ The following environment variables can be configured at build time or runtime: | Variable | Type | Default | Description | |----------|------|---------|-------------| -| `GITHUB_CLIENT_ID` | Build-time | `Ov23li9bxz3kKfPOIsGm` | GitHub OAuth app client ID for authentication | | `POSTHOG_API_KEY` | Build-time | Empty | PostHog analytics API key (disables analytics if empty) | | `POSTHOG_API_ENDPOINT` | Build-time | Empty | PostHog analytics endpoint (disables analytics if empty) | | `BACKEND_PORT` | Runtime | `0` (auto-assign) | Backend server port | @@ -109,18 +108,6 @@ The following environment variables can be configured at build time or runtime: **Build-time variables** must be set when running `pnpm run build`. **Runtime variables** are read when the application starts. -#### Custom GitHub OAuth App (Optional) - -By default, Vibe Kanban uses Bloop AI's GitHub OAuth app for authentication. To use your own GitHub app for self-hosting or custom branding: - -1. Create a GitHub OAuth App at [GitHub Developer Settings](https://github.com/settings/developers) -2. Enable "Device Flow" in the app settings -3. Set scopes to include `user:email,repo` -4. Build with your client ID: - ```bash - GITHUB_CLIENT_ID=your_client_id_here pnpm run build - ``` - ### Remote Deployment When running Vibe Kanban on a remote server (e.g., via systemctl, Docker, or cloud hosting), you can configure your editor to open projects via SSH: diff --git a/crates/db/.sqlx/query-00e71b6e31b432be788fe5c8a1b8954560a3bc52910da2b93a6a816032d8d0fd.json b/crates/db/.sqlx/query-00e71b6e31b432be788fe5c8a1b8954560a3bc52910da2b93a6a816032d8d0fd.json new file mode 100644 index 00000000..b0c1b9f7 --- /dev/null +++ b/crates/db/.sqlx/query-00e71b6e31b432be788fe5c8a1b8954560a3bc52910da2b93a6a816032d8d0fd.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n remote_project_id AS \"remote_project_id!: Uuid\",\n title AS title,\n description AS description,\n status AS \"status!: TaskStatus\",\n assignee_user_id AS \"assignee_user_id: Uuid\",\n assignee_first_name AS \"assignee_first_name: String\",\n assignee_last_name AS \"assignee_last_name: String\",\n assignee_username AS \"assignee_username: String\",\n version AS \"version!: i64\",\n last_event_seq AS \"last_event_seq: i64\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM shared_tasks\n WHERE rowid = $1\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "remote_project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "assignee_user_id: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "assignee_first_name: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "assignee_last_name: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "assignee_username: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "version!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_event_seq: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "created_at!: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "00e71b6e31b432be788fe5c8a1b8954560a3bc52910da2b93a6a816032d8d0fd" +} diff --git a/crates/db/.sqlx/query-18a4eb409f5d5ea419c98fabcfaa024126074d3b29202195c6e3b12a75c32338.json b/crates/db/.sqlx/query-18a4eb409f5d5ea419c98fabcfaa024126074d3b29202195c6e3b12a75c32338.json new file mode 100644 index 00000000..1d62afc5 --- /dev/null +++ b/crates/db/.sqlx/query-18a4eb409f5d5ea419c98fabcfaa024126074d3b29202195c6e3b12a75c32338.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO projects (\n id,\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n RETURNING id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "git_repo_path", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "setup_script", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "dev_script", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "cleanup_script", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "copy_files", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "remote_project_id: Uuid", + "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 9, + "type_info": "Text" + } + ], + "parameters": { + "Right": 7 + }, + "nullable": [ + true, + false, + false, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "18a4eb409f5d5ea419c98fabcfaa024126074d3b29202195c6e3b12a75c32338" +} diff --git a/crates/db/.sqlx/query-1c6b836c28f8068506f3582bc56fcf2c7e6e784c73fac5fc174fe299902ca4cb.json b/crates/db/.sqlx/query-1c6b836c28f8068506f3582bc56fcf2c7e6e784c73fac5fc174fe299902ca4cb.json new file mode 100644 index 00000000..b4e74613 --- /dev/null +++ b/crates/db/.sqlx/query-1c6b836c28f8068506f3582bc56fcf2c7e6e784c73fac5fc174fe299902ca4cb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE tasks\n SET shared_task_id = NULL\n WHERE shared_task_id IN (\n SELECT id FROM shared_tasks WHERE remote_project_id = $1\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1c6b836c28f8068506f3582bc56fcf2c7e6e784c73fac5fc174fe299902ca4cb" +} diff --git a/crates/db/.sqlx/query-59d178b298ba60d490a9081a40064a5acb06fecbc0b164c0de2fe502d02b13a7.json b/crates/db/.sqlx/query-2330097afa4816aaf7d98e083eac80558ecb9a355384e5076aa744fab27d2f7e.json similarity index 59% rename from crates/db/.sqlx/query-59d178b298ba60d490a9081a40064a5acb06fecbc0b164c0de2fe502d02b13a7.json rename to crates/db/.sqlx/query-2330097afa4816aaf7d98e083eac80558ecb9a355384e5076aa744fab27d2f7e.json index 4b2a935c..34aab75d 100644 --- a/crates/db/.sqlx/query-59d178b298ba60d490a9081a40064a5acb06fecbc0b164c0de2fe502d02b13a7.json +++ b/crates/db/.sqlx/query-2330097afa4816aaf7d98e083eac80558ecb9a355384e5076aa744fab27d2f7e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO projects (id, name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "SELECT id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n WHERE remote_project_id = $1\n LIMIT 1", "describe": { "columns": [ { @@ -39,18 +39,23 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], "parameters": { - "Right": 7 + "Right": 1 }, "nullable": [ true, @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "59d178b298ba60d490a9081a40064a5acb06fecbc0b164c0de2fe502d02b13a7" + "hash": "2330097afa4816aaf7d98e083eac80558ecb9a355384e5076aa744fab27d2f7e" } diff --git a/crates/db/.sqlx/query-72769cc30de13bb250687b26609ee95660cb4b716615406ecb6f45c4562c3f97.json b/crates/db/.sqlx/query-24fc0f4f51e4080aebf6131c47eb241ef5c35440b23cfa712311692143be53f3.json similarity index 61% rename from crates/db/.sqlx/query-72769cc30de13bb250687b26609ee95660cb4b716615406ecb6f45c4562c3f97.json rename to crates/db/.sqlx/query-24fc0f4f51e4080aebf6131c47eb241ef5c35440b23cfa712311692143be53f3.json index 32a0cbca..e4903bd1 100644 --- a/crates/db/.sqlx/query-72769cc30de13bb250687b26609ee95660cb4b716615406ecb6f45c4562c3f97.json +++ b/crates/db/.sqlx/query-24fc0f4f51e4080aebf6131c47eb241ef5c35440b23cfa712311692143be53f3.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\" FROM projects ORDER BY created_at DESC", + "query": "SELECT id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n ORDER BY created_at DESC", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "72769cc30de13bb250687b26609ee95660cb4b716615406ecb6f45c4562c3f97" + "hash": "24fc0f4f51e4080aebf6131c47eb241ef5c35440b23cfa712311692143be53f3" } diff --git a/crates/db/.sqlx/query-253a2292b461b964c792ff97adc6e01646a888e221290d312e2773609f97a6c4.json b/crates/db/.sqlx/query-253a2292b461b964c792ff97adc6e01646a888e221290d312e2773609f97a6c4.json new file mode 100644 index 00000000..14ee162a --- /dev/null +++ b/crates/db/.sqlx/query-253a2292b461b964c792ff97adc6e01646a888e221290d312e2773609f97a6c4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM shared_tasks WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "253a2292b461b964c792ff97adc6e01646a888e221290d312e2773609f97a6c4" +} diff --git a/crates/db/.sqlx/query-2a49be016c5999f4069823fc7aa1cd0eeed1b1b1743f50e89ceb2d310c5f18bb.json b/crates/db/.sqlx/query-2a49be016c5999f4069823fc7aa1cd0eeed1b1b1743f50e89ceb2d310c5f18bb.json new file mode 100644 index 00000000..c53db1c5 --- /dev/null +++ b/crates/db/.sqlx/query-2a49be016c5999f4069823fc7aa1cd0eeed1b1b1743f50e89ceb2d310c5f18bb.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_project_id AS \"remote_project_id!: Uuid\",\n last_seq AS \"last_seq!: i64\",\n updated_at AS \"updated_at!: DateTime\"\n FROM shared_activity_cursors\n WHERE remote_project_id = $1\n ", + "describe": { + "columns": [ + { + "name": "remote_project_id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "last_seq!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "2a49be016c5999f4069823fc7aa1cd0eeed1b1b1743f50e89ceb2d310c5f18bb" +} diff --git a/crates/db/.sqlx/query-283a8ef6493346c9ee3bf649e977849eb361d801cdfc8180a8f082269a6bd649.json b/crates/db/.sqlx/query-2d49b016e3d5872a71d07525a9d15637c9799e8918f125058413028ecb931a5c.json similarity index 54% rename from crates/db/.sqlx/query-283a8ef6493346c9ee3bf649e977849eb361d801cdfc8180a8f082269a6bd649.json rename to crates/db/.sqlx/query-2d49b016e3d5872a71d07525a9d15637c9799e8918f125058413028ecb931a5c.json index e3fc3b31..b115ab27 100644 --- a/crates/db/.sqlx/query-283a8ef6493346c9ee3bf649e977849eb361d801cdfc8180a8f082269a6bd649.json +++ b/crates/db/.sqlx/query-2d49b016e3d5872a71d07525a9d15637c9799e8918f125058413028ecb931a5c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4, dev_script = $5, cleanup_script = $6, copy_files = $7 WHERE id = $1 RETURNING id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "UPDATE projects\n SET name = $2,\n git_repo_path = $3,\n setup_script = $4,\n dev_script = $5,\n cleanup_script = $6,\n copy_files = $7\n WHERE id = $1\n RETURNING id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "283a8ef6493346c9ee3bf649e977849eb361d801cdfc8180a8f082269a6bd649" + "hash": "2d49b016e3d5872a71d07525a9d15637c9799e8918f125058413028ecb931a5c" } diff --git a/crates/db/.sqlx/query-b95cb59154da69213dea2ded3646d2df2f68293be211cc4f9db0582ea691efee.json b/crates/db/.sqlx/query-3c370bbd5b58c1e5de1ca4799c7fe2b3202173a9211c2d1493d79d93493754a3.json similarity index 60% rename from crates/db/.sqlx/query-b95cb59154da69213dea2ded3646d2df2f68293be211cc4f9db0582ea691efee.json rename to crates/db/.sqlx/query-3c370bbd5b58c1e5de1ca4799c7fe2b3202173a9211c2d1493d79d93493754a3.json index 689763bd..dce637ac 100644 --- a/crates/db/.sqlx/query-b95cb59154da69213dea2ded3646d2df2f68293be211cc4f9db0582ea691efee.json +++ b/crates/db/.sqlx/query-3c370bbd5b58c1e5de1ca4799c7fe2b3202173a9211c2d1493d79d93493754a3.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\" FROM projects WHERE git_repo_path = $1 AND id != $2", + "query": "SELECT id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n WHERE git_repo_path = $1 AND id != $2", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "b95cb59154da69213dea2ded3646d2df2f68293be211cc4f9db0582ea691efee" + "hash": "3c370bbd5b58c1e5de1ca4799c7fe2b3202173a9211c2d1493d79d93493754a3" } diff --git a/crates/db/.sqlx/query-3cbd8fd4383a9f0899a12783be95972dec2ff6b9d0f3e3ed05bb5a07ea8c6ef0.json b/crates/db/.sqlx/query-3cbd8fd4383a9f0899a12783be95972dec2ff6b9d0f3e3ed05bb5a07ea8c6ef0.json new file mode 100644 index 00000000..3f30e445 --- /dev/null +++ b/crates/db/.sqlx/query-3cbd8fd4383a9f0899a12783be95972dec2ff6b9d0f3e3ed05bb5a07ea8c6ef0.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n remote_project_id AS \"remote_project_id!: Uuid\",\n title AS title,\n description AS description,\n status AS \"status!: TaskStatus\",\n assignee_user_id AS \"assignee_user_id: Uuid\",\n assignee_first_name AS \"assignee_first_name: String\",\n assignee_last_name AS \"assignee_last_name: String\",\n assignee_username AS \"assignee_username: String\",\n version AS \"version!: i64\",\n last_event_seq AS \"last_event_seq: i64\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM shared_tasks\n WHERE remote_project_id = $1\n ORDER BY updated_at DESC\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "remote_project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "assignee_user_id: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "assignee_first_name: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "assignee_last_name: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "assignee_username: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "version!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_event_seq: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "created_at!: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "3cbd8fd4383a9f0899a12783be95972dec2ff6b9d0f3e3ed05bb5a07ea8c6ef0" +} diff --git a/crates/db/.sqlx/query-01a0f9724e5fce7d3312a742e72cded85605ee540150972e2a8364919f56d5c0.json b/crates/db/.sqlx/query-45d9e8ce02b17dbab3531d26eaa46b8aa0c3f9db13802bc368d66f5122df081f.json similarity index 89% rename from crates/db/.sqlx/query-01a0f9724e5fce7d3312a742e72cded85605ee540150972e2a8364919f56d5c0.json rename to crates/db/.sqlx/query-45d9e8ce02b17dbab3531d26eaa46b8aa0c3f9db13802bc368d66f5122df081f.json index 26076bd5..2b9d43a7 100644 --- a/crates/db/.sqlx/query-01a0f9724e5fce7d3312a742e72cded85605ee540150972e2a8364919f56d5c0.json +++ b/crates/db/.sqlx/query-45d9e8ce02b17dbab3531d26eaa46b8aa0c3f9db13802bc368d66f5122df081f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT\n t.id AS \"id!: Uuid\",\n t.project_id AS \"project_id!: Uuid\",\n t.title,\n t.description,\n t.status AS \"status!: TaskStatus\",\n t.parent_task_attempt AS \"parent_task_attempt: Uuid\",\n t.created_at AS \"created_at!: DateTime\",\n t.updated_at AS \"updated_at!: DateTime\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.task_id = t.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"has_in_progress_attempt!: i64\",\n \n CASE WHEN (\n SELECT ep.status\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.task_id = t.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END\n AS \"last_attempt_failed!: i64\",\n\n ( SELECT ta.executor\n FROM task_attempts ta\n WHERE ta.task_id = t.id\n ORDER BY ta.created_at DESC\n LIMIT 1\n ) AS \"executor!: String\"\n\nFROM tasks t\nWHERE t.project_id = $1\nORDER BY t.created_at DESC", + "query": "SELECT\n t.id AS \"id!: Uuid\",\n t.project_id AS \"project_id!: Uuid\",\n t.title,\n t.description,\n t.status AS \"status!: TaskStatus\",\n t.parent_task_attempt AS \"parent_task_attempt: Uuid\",\n t.shared_task_id AS \"shared_task_id: Uuid\",\n t.created_at AS \"created_at!: DateTime\",\n t.updated_at AS \"updated_at!: DateTime\",\n\n CASE WHEN EXISTS (\n SELECT 1\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.task_id = t.id\n AND ep.status = 'running'\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n LIMIT 1\n ) THEN 1 ELSE 0 END AS \"has_in_progress_attempt!: i64\",\n \n CASE WHEN (\n SELECT ep.status\n FROM task_attempts ta\n JOIN execution_processes ep\n ON ep.task_attempt_id = ta.id\n WHERE ta.task_id = t.id\n AND ep.run_reason IN ('setupscript','cleanupscript','codingagent')\n ORDER BY ep.created_at DESC\n LIMIT 1\n ) IN ('failed','killed') THEN 1 ELSE 0 END\n AS \"last_attempt_failed!: i64\",\n\n ( SELECT ta.executor\n FROM task_attempts ta\n WHERE ta.task_id = t.id\n ORDER BY ta.created_at DESC\n LIMIT 1\n ) AS \"executor!: String\"\n\nFROM tasks t\nWHERE t.project_id = $1\nORDER BY t.created_at DESC", "describe": { "columns": [ { @@ -34,28 +34,33 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, - "type_info": "Text" + "type_info": "Blob" }, { - "name": "updated_at!: DateTime", + "name": "created_at!: DateTime", "ordinal": 7, "type_info": "Text" }, { - "name": "has_in_progress_attempt!: i64", + "name": "updated_at!: DateTime", "ordinal": 8, - "type_info": "Null" + "type_info": "Text" }, { - "name": "last_attempt_failed!: i64", + "name": "has_in_progress_attempt!: i64", "ordinal": 9, "type_info": "Null" }, { - "name": "executor!: String", + "name": "last_attempt_failed!: i64", "ordinal": 10, + "type_info": "Null" + }, + { + "name": "executor!: String", + "ordinal": 11, "type_info": "Text" } ], @@ -69,6 +74,7 @@ true, false, true, + true, false, false, null, @@ -76,5 +82,5 @@ true ] }, - "hash": "01a0f9724e5fce7d3312a742e72cded85605ee540150972e2a8364919f56d5c0" + "hash": "45d9e8ce02b17dbab3531d26eaa46b8aa0c3f9db13802bc368d66f5122df081f" } diff --git a/crates/db/.sqlx/query-69234edbfb4ec9fad3e3411fccae611558bc1940dcec18221657bd3a3ad45aee.json b/crates/db/.sqlx/query-4c8cc854d7f9ff93fb86a5a1a99cb99c86c50e062281bf3e52e2ebc6537192f0.json similarity index 64% rename from crates/db/.sqlx/query-69234edbfb4ec9fad3e3411fccae611558bc1940dcec18221657bd3a3ad45aee.json rename to crates/db/.sqlx/query-4c8cc854d7f9ff93fb86a5a1a99cb99c86c50e062281bf3e52e2ebc6537192f0.json index eceba465..ff47ab7c 100644 --- a/crates/db/.sqlx/query-69234edbfb4ec9fad3e3411fccae611558bc1940dcec18221657bd3a3ad45aee.json +++ b/crates/db/.sqlx/query-4c8cc854d7f9ff93fb86a5a1a99cb99c86c50e062281bf3e52e2ebc6537192f0.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT p.id as \"id!: Uuid\", p.name, p.git_repo_path, p.setup_script, p.dev_script, p.cleanup_script, p.copy_files, \n p.created_at as \"created_at!: DateTime\", p.updated_at as \"updated_at!: DateTime\"\n FROM projects p\n WHERE p.id IN (\n SELECT DISTINCT t.project_id\n FROM tasks t\n INNER JOIN task_attempts ta ON ta.task_id = t.id\n ORDER BY ta.updated_at DESC\n )\n LIMIT $1\n ", + "query": "\n SELECT p.id as \"id!: Uuid\", p.name, p.git_repo_path, p.setup_script, p.dev_script, p.cleanup_script, p.copy_files, \n p.remote_project_id as \"remote_project_id: Uuid\",\n p.created_at as \"created_at!: DateTime\", p.updated_at as \"updated_at!: DateTime\"\n FROM projects p\n WHERE p.id IN (\n SELECT DISTINCT t.project_id\n FROM tasks t\n INNER JOIN task_attempts ta ON ta.task_id = t.id\n ORDER BY ta.updated_at DESC\n )\n LIMIT $1\n ", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "69234edbfb4ec9fad3e3411fccae611558bc1940dcec18221657bd3a3ad45aee" + "hash": "4c8cc854d7f9ff93fb86a5a1a99cb99c86c50e062281bf3e52e2ebc6537192f0" } diff --git a/crates/db/.sqlx/query-5393ad53affc4e19668d3b522f038fe0dd01993e236c5964ea7671ff22f697c8.json b/crates/db/.sqlx/query-5393ad53affc4e19668d3b522f038fe0dd01993e236c5964ea7671ff22f697c8.json new file mode 100644 index 00000000..59913131 --- /dev/null +++ b/crates/db/.sqlx/query-5393ad53affc4e19668d3b522f038fe0dd01993e236c5964ea7671ff22f697c8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO tasks (\n id,\n project_id,\n title,\n description,\n status,\n shared_task_id\n )\n SELECT\n $1,\n $2,\n $3,\n $4,\n $5,\n $6\n WHERE $7\n OR EXISTS (\n SELECT 1 FROM tasks WHERE shared_task_id = $6\n )\n ON CONFLICT(shared_task_id) WHERE shared_task_id IS NOT NULL DO UPDATE SET\n project_id = excluded.project_id,\n title = excluded.title,\n description = excluded.description,\n status = excluded.status,\n updated_at = datetime('now', 'subsec')\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "5393ad53affc4e19668d3b522f038fe0dd01993e236c5964ea7671ff22f697c8" +} diff --git a/crates/db/.sqlx/query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json b/crates/db/.sqlx/query-56eaca51977f005572a2205fd8e4b65c237aeae8407acf4fa2f0f317f760b2cd.json similarity index 71% rename from crates/db/.sqlx/query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json rename to crates/db/.sqlx/query-56eaca51977f005572a2205fd8e4b65c237aeae8407acf4fa2f0f317f760b2cd.json index 96f1fad3..3a93a9f2 100644 --- a/crates/db/.sqlx/query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json +++ b/crates/db/.sqlx/query-56eaca51977f005572a2205fd8e4b65c237aeae8407acf4fa2f0f317f760b2cd.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1 AND project_id = $2", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1 AND project_id = $2", "describe": { "columns": [ { @@ -34,13 +34,18 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], @@ -54,9 +59,10 @@ true, false, true, + true, false, false ] }, - "hash": "216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e" + "hash": "56eaca51977f005572a2205fd8e4b65c237aeae8407acf4fa2f0f317f760b2cd" } diff --git a/crates/db/.sqlx/query-6a4e4fd60ae727839029a4d00c0626d0f8d0d78edb1d76af3be11dcb788f34aa.json b/crates/db/.sqlx/query-6a4e4fd60ae727839029a4d00c0626d0f8d0d78edb1d76af3be11dcb788f34aa.json new file mode 100644 index 00000000..0c91a17b --- /dev/null +++ b/crates/db/.sqlx/query-6a4e4fd60ae727839029a4d00c0626d0f8d0d78edb1d76af3be11dcb788f34aa.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO shared_activity_cursors (\n remote_project_id,\n last_seq,\n updated_at\n )\n VALUES (\n $1,\n $2,\n datetime('now', 'subsec')\n )\n ON CONFLICT(remote_project_id) DO UPDATE SET\n last_seq = excluded.last_seq,\n updated_at = excluded.updated_at\n RETURNING\n remote_project_id AS \"remote_project_id!: Uuid\",\n last_seq AS \"last_seq!: i64\",\n updated_at AS \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "remote_project_id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "last_seq!: i64", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "6a4e4fd60ae727839029a4d00c0626d0f8d0d78edb1d76af3be11dcb788f34aa" +} diff --git a/crates/db/.sqlx/query-6d3443d4f96369fa72df0ddd2f06d1fbb36b22a46ed421865d699907e5e71451.json b/crates/db/.sqlx/query-6d3443d4f96369fa72df0ddd2f06d1fbb36b22a46ed421865d699907e5e71451.json new file mode 100644 index 00000000..3613da90 --- /dev/null +++ b/crates/db/.sqlx/query-6d3443d4f96369fa72df0ddd2f06d1fbb36b22a46ed421865d699907e5e71451.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO shared_tasks (\n id,\n remote_project_id,\n title,\n description,\n status,\n assignee_user_id,\n assignee_first_name,\n assignee_last_name,\n assignee_username,\n version,\n last_event_seq,\n created_at,\n updated_at\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13\n )\n ON CONFLICT(id) DO UPDATE SET\n remote_project_id = excluded.remote_project_id,\n title = excluded.title,\n description = excluded.description,\n status = excluded.status,\n assignee_user_id = excluded.assignee_user_id,\n assignee_first_name = excluded.assignee_first_name,\n assignee_last_name = excluded.assignee_last_name,\n assignee_username = excluded.assignee_username,\n version = excluded.version,\n last_event_seq = excluded.last_event_seq,\n created_at = excluded.created_at,\n updated_at = excluded.updated_at\n RETURNING\n id AS \"id!: Uuid\",\n remote_project_id AS \"remote_project_id!: Uuid\",\n title AS title,\n description AS description,\n status AS \"status!: TaskStatus\",\n assignee_user_id AS \"assignee_user_id: Uuid\",\n assignee_first_name AS \"assignee_first_name: String\",\n assignee_last_name AS \"assignee_last_name: String\",\n assignee_username AS \"assignee_username: String\",\n version AS \"version!: i64\",\n last_event_seq AS \"last_event_seq: i64\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "remote_project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "assignee_user_id: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "assignee_first_name: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "assignee_last_name: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "assignee_username: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "version!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_event_seq: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "created_at!: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 13 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "6d3443d4f96369fa72df0ddd2f06d1fbb36b22a46ed421865d699907e5e71451" +} diff --git a/crates/db/.sqlx/query-8cc087f95fb55426ee6481bdd0f74b2083ceaf6c5cf82456a7d83c18323c5cec.json b/crates/db/.sqlx/query-74c7ce5735a4ff8d4bc5e26ba813377a51489744268a69c6f088265ec1d6ebe5.json similarity index 72% rename from crates/db/.sqlx/query-8cc087f95fb55426ee6481bdd0f74b2083ceaf6c5cf82456a7d83c18323c5cec.json rename to crates/db/.sqlx/query-74c7ce5735a4ff8d4bc5e26ba813377a51489744268a69c6f088265ec1d6ebe5.json index 83753159..3b171fc7 100644 --- a/crates/db/.sqlx/query-8cc087f95fb55426ee6481bdd0f74b2083ceaf6c5cf82456a7d83c18323c5cec.json +++ b/crates/db/.sqlx/query-74c7ce5735a4ff8d4bc5e26ba813377a51489744268a69c6f088265ec1d6ebe5.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE rowid = $1", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1", "describe": { "columns": [ { @@ -34,13 +34,18 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], @@ -54,9 +59,10 @@ true, false, true, + true, false, false ] }, - "hash": "8cc087f95fb55426ee6481bdd0f74b2083ceaf6c5cf82456a7d83c18323c5cec" + "hash": "74c7ce5735a4ff8d4bc5e26ba813377a51489744268a69c6f088265ec1d6ebe5" } diff --git a/crates/db/.sqlx/query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json b/crates/db/.sqlx/query-89183bb8218a438295232aea9c596778a31a103958158d5404ee097de2255be8.json similarity index 66% rename from crates/db/.sqlx/query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json rename to crates/db/.sqlx/query-89183bb8218a438295232aea9c596778a31a103958158d5404ee097de2255be8.json index d8b022eb..b218c47f 100644 --- a/crates/db/.sqlx/query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json +++ b/crates/db/.sqlx/query-89183bb8218a438295232aea9c596778a31a103958158d5404ee097de2255be8.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt) \n VALUES ($1, $2, $3, $4, $5, $6) \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt, shared_task_id) \n VALUES ($1, $2, $3, $4, $5, $6, $7) \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -34,18 +34,23 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], "parameters": { - "Right": 6 + "Right": 7 }, "nullable": [ true, @@ -54,9 +59,10 @@ true, false, true, + true, false, false ] }, - "hash": "5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb" + "hash": "89183bb8218a438295232aea9c596778a31a103958158d5404ee097de2255be8" } diff --git a/crates/db/.sqlx/query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json b/crates/db/.sqlx/query-907660cd05b8c9e6ba5198e973dc2baf1b895d4f21bf81ec06dbbbc122df6a38.json similarity index 72% rename from crates/db/.sqlx/query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json rename to crates/db/.sqlx/query-907660cd05b8c9e6ba5198e973dc2baf1b895d4f21bf81ec06dbbbc122df6a38.json index daae994b..2f091884 100644 --- a/crates/db/.sqlx/query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json +++ b/crates/db/.sqlx/query-907660cd05b8c9e6ba5198e973dc2baf1b895d4f21bf81ec06dbbbc122df6a38.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE rowid = $1", "describe": { "columns": [ { @@ -34,13 +34,18 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], @@ -54,9 +59,10 @@ true, false, true, + true, false, false ] }, - "hash": "2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f" + "hash": "907660cd05b8c9e6ba5198e973dc2baf1b895d4f21bf81ec06dbbbc122df6a38" } diff --git a/crates/db/.sqlx/query-9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb.json b/crates/db/.sqlx/query-9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb.json new file mode 100644 index 00000000..81a93760 --- /dev/null +++ b/crates/db/.sqlx/query-9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE projects\n SET remote_project_id = $2\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "9dd37bd520d651339fa13078ea5cb76847c8c74970b195b0e5ee33e4c5a777fb" +} diff --git a/crates/db/.sqlx/query-821192d8d8a8fba8ce0f144a32e7e500aaa2b6e527b7e7f082a1c73b1f9f9eb8.json b/crates/db/.sqlx/query-a6ee0cb1535be5f414429a26c1534afa3f859f87c291b33769049b922ab8ff86.json similarity index 61% rename from crates/db/.sqlx/query-821192d8d8a8fba8ce0f144a32e7e500aaa2b6e527b7e7f082a1c73b1f9f9eb8.json rename to crates/db/.sqlx/query-a6ee0cb1535be5f414429a26c1534afa3f859f87c291b33769049b922ab8ff86.json index d3b1aad8..4d421fa1 100644 --- a/crates/db/.sqlx/query-821192d8d8a8fba8ce0f144a32e7e500aaa2b6e527b7e7f082a1c73b1f9f9eb8.json +++ b/crates/db/.sqlx/query-a6ee0cb1535be5f414429a26c1534afa3f859f87c291b33769049b922ab8ff86.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\" FROM projects WHERE id = $1", + "query": "SELECT id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n WHERE id = $1", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "821192d8d8a8fba8ce0f144a32e7e500aaa2b6e527b7e7f082a1c73b1f9f9eb8" + "hash": "a6ee0cb1535be5f414429a26c1534afa3f859f87c291b33769049b922ab8ff86" } diff --git a/crates/db/.sqlx/query-ada2508575f7f5fd4b9159aa8690f44a84c07dbf28ba1d23fb1041b43f4ccc13.json b/crates/db/.sqlx/query-ada2508575f7f5fd4b9159aa8690f44a84c07dbf28ba1d23fb1041b43f4ccc13.json new file mode 100644 index 00000000..055397d8 --- /dev/null +++ b/crates/db/.sqlx/query-ada2508575f7f5fd4b9159aa8690f44a84c07dbf28ba1d23fb1041b43f4ccc13.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE tasks SET shared_task_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ada2508575f7f5fd4b9159aa8690f44a84c07dbf28ba1d23fb1041b43f4ccc13" +} diff --git a/crates/db/.sqlx/query-024b53c73eda9f79c65997261d5cc3b35ce19c27b22dcc03dbb3fd11ad7bbfe2.json b/crates/db/.sqlx/query-ae8e284c805801a381ba6b700717884e6701e6e18db4bf019684ace8d8941edc.json similarity index 71% rename from crates/db/.sqlx/query-024b53c73eda9f79c65997261d5cc3b35ce19c27b22dcc03dbb3fd11ad7bbfe2.json rename to crates/db/.sqlx/query-ae8e284c805801a381ba6b700717884e6701e6e18db4bf019684ace8d8941edc.json index 9491f529..10fbd8fb 100644 --- a/crates/db/.sqlx/query-024b53c73eda9f79c65997261d5cc3b35ce19c27b22dcc03dbb3fd11ad7bbfe2.json +++ b/crates/db/.sqlx/query-ae8e284c805801a381ba6b700717884e6701e6e18db4bf019684ace8d8941edc.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE parent_task_attempt = $1\n ORDER BY created_at DESC", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE shared_task_id = $1\n LIMIT 1", "describe": { "columns": [ { @@ -34,13 +34,18 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], @@ -54,9 +59,10 @@ true, false, true, + true, false, false ] }, - "hash": "024b53c73eda9f79c65997261d5cc3b35ce19c27b22dcc03dbb3fd11ad7bbfe2" + "hash": "ae8e284c805801a381ba6b700717884e6701e6e18db4bf019684ace8d8941edc" } diff --git a/crates/db/.sqlx/query-b742031d1362f7fd7c63ab183af04be8fa79f8f6340d3e27c703a9c58b7c7805.json b/crates/db/.sqlx/query-b742031d1362f7fd7c63ab183af04be8fa79f8f6340d3e27c703a9c58b7c7805.json new file mode 100644 index 00000000..b88c24b4 --- /dev/null +++ b/crates/db/.sqlx/query-b742031d1362f7fd7c63ab183af04be8fa79f8f6340d3e27c703a9c58b7c7805.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n remote_project_id AS \"remote_project_id!: Uuid\",\n title AS title,\n description AS description,\n status AS \"status!: TaskStatus\",\n assignee_user_id AS \"assignee_user_id: Uuid\",\n assignee_first_name AS \"assignee_first_name: String\",\n assignee_last_name AS \"assignee_last_name: String\",\n assignee_username AS \"assignee_username: String\",\n version AS \"version!: i64\",\n last_event_seq AS \"last_event_seq: i64\",\n created_at AS \"created_at!: DateTime\",\n updated_at AS \"updated_at!: DateTime\"\n FROM shared_tasks\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "remote_project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "assignee_user_id: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "assignee_first_name: String", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "assignee_last_name: String", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "assignee_username: String", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "version!: i64", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "last_event_seq: i64", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "created_at!: DateTime", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + true, + true, + true, + false, + true, + false, + false + ] + }, + "hash": "b742031d1362f7fd7c63ab183af04be8fa79f8f6340d3e27c703a9c58b7c7805" +} diff --git a/crates/db/.sqlx/query-71c7befa63391ca211eb69036ff0e4aabe92932fd8bb7ba8c52b2ae8bf411ac8.json b/crates/db/.sqlx/query-c53e0af00938e45ba437e81cdb6c3e3d5d0ccaf7122c3830d9935dd10111ea70.json similarity index 61% rename from crates/db/.sqlx/query-71c7befa63391ca211eb69036ff0e4aabe92932fd8bb7ba8c52b2ae8bf411ac8.json rename to crates/db/.sqlx/query-c53e0af00938e45ba437e81cdb6c3e3d5d0ccaf7122c3830d9935dd10111ea70.json index 6dec9ab5..b19c84c2 100644 --- a/crates/db/.sqlx/query-71c7befa63391ca211eb69036ff0e4aabe92932fd8bb7ba8c52b2ae8bf411ac8.json +++ b/crates/db/.sqlx/query-c53e0af00938e45ba437e81cdb6c3e3d5d0ccaf7122c3830d9935dd10111ea70.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\" FROM projects WHERE git_repo_path = $1", + "query": "SELECT id as \"id!: Uuid\",\n name,\n git_repo_path,\n setup_script,\n dev_script,\n cleanup_script,\n copy_files,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n WHERE git_repo_path = $1", "describe": { "columns": [ { @@ -39,13 +39,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "remote_project_id: Uuid", "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 8, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 8, + "ordinal": 9, "type_info": "Text" } ], @@ -60,9 +65,10 @@ true, true, true, + true, false, false ] }, - "hash": "71c7befa63391ca211eb69036ff0e4aabe92932fd8bb7ba8c52b2ae8bf411ac8" + "hash": "c53e0af00938e45ba437e81cdb6c3e3d5d0ccaf7122c3830d9935dd10111ea70" } diff --git a/crates/db/.sqlx/query-d4e3852cd9b482155c4b448adbac722a4dbb69a91ce309f39f9aa39368c30182.json b/crates/db/.sqlx/query-d4e3852cd9b482155c4b448adbac722a4dbb69a91ce309f39f9aa39368c30182.json new file mode 100644 index 00000000..0f987413 --- /dev/null +++ b/crates/db/.sqlx/query-d4e3852cd9b482155c4b448adbac722a4dbb69a91ce309f39f9aa39368c30182.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE parent_task_attempt = $1\n ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "parent_task_attempt: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "shared_task_id: Uuid", + "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + true, + false, + false + ] + }, + "hash": "d4e3852cd9b482155c4b448adbac722a4dbb69a91ce309f39f9aa39368c30182" +} diff --git a/crates/db/.sqlx/query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json b/crates/db/.sqlx/query-d96a07b7d30b520e4a1a5a3d0a49434bd919dc9557f18f79c39788a69f6a84b8.json similarity index 78% rename from crates/db/.sqlx/query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json rename to crates/db/.sqlx/query-d96a07b7d30b520e4a1a5a3d0a49434bd919dc9557f18f79c39788a69f6a84b8.json index 2eb7de4e..f1b56d2d 100644 --- a/crates/db/.sqlx/query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json +++ b/crates/db/.sqlx/query-d96a07b7d30b520e4a1a5a3d0a49434bd919dc9557f18f79c39788a69f6a84b8.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE tasks \n SET title = $3, description = $4, status = $5, parent_task_attempt = $6 \n WHERE id = $1 AND project_id = $2 \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "UPDATE tasks \n SET title = $3, description = $4, status = $5, parent_task_attempt = $6 \n WHERE id = $1 AND project_id = $2 \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", shared_task_id as \"shared_task_id: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -34,13 +34,18 @@ "type_info": "Blob" }, { - "name": "created_at!: DateTime", + "name": "shared_task_id: Uuid", "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 7, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 7, + "ordinal": 8, "type_info": "Text" } ], @@ -54,9 +59,10 @@ true, false, true, + true, false, false ] }, - "hash": "00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d" + "hash": "d96a07b7d30b520e4a1a5a3d0a49434bd919dc9557f18f79c39788a69f6a84b8" } diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index 87eef8e8..4ece31b9 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -6,22 +6,15 @@ edition = "2024" [dependencies] utils = { path = "../utils" } executors = { path = "../executors" } -tokio = { workspace = true } -tokio-util = { version = "0.7", features = ["io"] } thiserror = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "sqlite-preupdate-hook", "chrono", "uuid"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } -async-trait = "0.1" -regex = "1.11.1" -sentry-tracing = { version = "0.41.0", features = ["backtrace"] } -futures-util = "0.3" strum = "0.27.2" strum_macros = "0.27.2" diff --git a/crates/db/migrations/20251114000000_create_shared_tasks.sql b/crates/db/migrations/20251114000000_create_shared_tasks.sql new file mode 100644 index 00000000..750952ea --- /dev/null +++ b/crates/db/migrations/20251114000000_create_shared_tasks.sql @@ -0,0 +1,44 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS shared_tasks ( + id BLOB PRIMARY KEY, + remote_project_id BLOB NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'todo' + CHECK (status IN ('todo','inprogress','done','cancelled','inreview')), + assignee_user_id BLOB, + assignee_first_name TEXT, + assignee_last_name TEXT, + assignee_username TEXT, + version INTEGER NOT NULL DEFAULT 1, + last_event_seq INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) +); + +CREATE INDEX IF NOT EXISTS idx_shared_tasks_remote_project + ON shared_tasks (remote_project_id); + +CREATE INDEX IF NOT EXISTS idx_shared_tasks_status + ON shared_tasks (status); + +CREATE TABLE IF NOT EXISTS shared_activity_cursors ( + remote_project_id BLOB PRIMARY KEY, + last_seq INTEGER NOT NULL CHECK (last_seq >= 0), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')) +); + +ALTER TABLE tasks + ADD COLUMN shared_task_id BLOB REFERENCES shared_tasks(id) ON DELETE SET NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_shared_task_unique + ON tasks(shared_task_id) + WHERE shared_task_id IS NOT NULL; + +ALTER TABLE projects + ADD COLUMN remote_project_id BLOB; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_remote_project_id + ON projects(remote_project_id) + WHERE remote_project_id IS NOT NULL; diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 1b585df5..1d5eda49 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -5,6 +5,7 @@ pub mod executor_session; pub mod image; pub mod merge; pub mod project; +pub mod shared_task; pub mod tag; pub mod task; pub mod task_attempt; diff --git a/crates/db/src/models/project.rs b/crates/db/src/models/project.rs index 9c0cdc6f..78b8dda9 100644 --- a/crates/db/src/models/project.rs +++ b/crates/db/src/models/project.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, SqlitePool}; +use sqlx::{Executor, FromRow, Sqlite, SqlitePool}; use thiserror::Error; use ts_rs::TS; use uuid::Uuid; @@ -30,7 +30,7 @@ pub struct Project { pub dev_script: Option, pub cleanup_script: Option, pub copy_files: Option, - + pub remote_project_id: Option, #[ts(type = "Date")] pub created_at: DateTime, #[ts(type = "Date")] @@ -82,7 +82,18 @@ impl Project { pub async fn find_all(pool: &SqlitePool) -> Result, sqlx::Error> { sqlx::query_as!( Project, - r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM projects ORDER BY created_at DESC"# + r#"SELECT id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM projects + ORDER BY created_at DESC"# ) .fetch_all(pool) .await @@ -94,6 +105,7 @@ impl Project { Project, r#" SELECT p.id as "id!: Uuid", p.name, p.git_repo_path, p.setup_script, p.dev_script, p.cleanup_script, p.copy_files, + p.remote_project_id as "remote_project_id: Uuid", p.created_at as "created_at!: DateTime", p.updated_at as "updated_at!: DateTime" FROM projects p WHERE p.id IN ( @@ -113,20 +125,67 @@ impl Project { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Project, - r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM projects WHERE id = $1"#, + r#"SELECT id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM projects + WHERE id = $1"#, id ) .fetch_optional(pool) .await } + pub async fn find_by_remote_project_id( + pool: &SqlitePool, + remote_project_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + Project, + r#"SELECT id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM projects + WHERE remote_project_id = $1 + LIMIT 1"#, + remote_project_id + ) + .fetch_optional(pool) + .await + } + pub async fn find_by_git_repo_path( pool: &SqlitePool, git_repo_path: &str, ) -> Result, sqlx::Error> { sqlx::query_as!( Project, - r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM projects WHERE git_repo_path = $1"#, + r#"SELECT id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM projects + WHERE git_repo_path = $1"#, git_repo_path ) .fetch_optional(pool) @@ -140,7 +199,18 @@ impl Project { ) -> Result, sqlx::Error> { sqlx::query_as!( Project, - r#"SELECT id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM projects WHERE git_repo_path = $1 AND id != $2"#, + r#"SELECT id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM projects + WHERE git_repo_path = $1 AND id != $2"#, git_repo_path, exclude_id ) @@ -155,14 +225,34 @@ impl Project { ) -> Result { sqlx::query_as!( Project, - r#"INSERT INTO projects (id, name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + r#"INSERT INTO projects ( + id, + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + RETURNING id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime""#, project_id, data.name, data.git_repo_path, data.setup_script, data.dev_script, data.cleanup_script, - data.copy_files + data.copy_files, ) .fetch_one(pool) .await @@ -181,19 +271,76 @@ impl Project { ) -> Result { sqlx::query_as!( Project, - r#"UPDATE projects SET name = $2, git_repo_path = $3, setup_script = $4, dev_script = $5, cleanup_script = $6, copy_files = $7 WHERE id = $1 RETURNING id as "id!: Uuid", name, git_repo_path, setup_script, dev_script, cleanup_script, copy_files, created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + r#"UPDATE projects + SET name = $2, + git_repo_path = $3, + setup_script = $4, + dev_script = $5, + cleanup_script = $6, + copy_files = $7 + WHERE id = $1 + RETURNING id as "id!: Uuid", + name, + git_repo_path, + setup_script, + dev_script, + cleanup_script, + copy_files, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime""#, id, name, git_repo_path, setup_script, dev_script, cleanup_script, - copy_files + copy_files, ) .fetch_one(pool) .await } + pub async fn set_remote_project_id( + pool: &SqlitePool, + id: Uuid, + remote_project_id: Option, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + r#"UPDATE projects + SET remote_project_id = $2 + WHERE id = $1"#, + id, + remote_project_id + ) + .execute(pool) + .await?; + + Ok(()) + } + + /// Transaction-compatible version of set_remote_project_id + pub async fn set_remote_project_id_tx<'e, E>( + executor: E, + id: Uuid, + remote_project_id: Option, + ) -> Result<(), sqlx::Error> + where + E: Executor<'e, Database = Sqlite>, + { + sqlx::query!( + r#"UPDATE projects + SET remote_project_id = $2 + WHERE id = $1"#, + id, + remote_project_id + ) + .execute(executor) + .await?; + + Ok(()) + } + pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result { let result = sqlx::query!("DELETE FROM projects WHERE id = $1", id) .execute(pool) diff --git a/crates/db/src/models/shared_task.rs b/crates/db/src/models/shared_task.rs new file mode 100644 index 00000000..b2a8dae9 --- /dev/null +++ b/crates/db/src/models/shared_task.rs @@ -0,0 +1,297 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{Executor, FromRow, QueryBuilder, Sqlite, SqlitePool}; +use ts_rs::TS; +use uuid::Uuid; + +use super::task::TaskStatus; + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +pub struct SharedTask { + pub id: Uuid, + pub remote_project_id: Uuid, + pub title: String, + pub description: Option, + pub status: TaskStatus, + pub assignee_user_id: Option, + pub assignee_first_name: Option, + pub assignee_last_name: Option, + pub assignee_username: Option, + pub version: i64, + pub last_event_seq: Option, + #[ts(type = "Date")] + pub created_at: DateTime, + #[ts(type = "Date")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct SharedTaskInput { + pub id: Uuid, + pub remote_project_id: Uuid, + pub title: String, + pub description: Option, + pub status: TaskStatus, + pub assignee_user_id: Option, + pub assignee_first_name: Option, + pub assignee_last_name: Option, + pub assignee_username: Option, + pub version: i64, + pub last_event_seq: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl SharedTask { + pub async fn list_by_remote_project_id( + pool: &SqlitePool, + remote_project_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + SharedTask, + r#" + SELECT + id AS "id!: Uuid", + remote_project_id AS "remote_project_id!: Uuid", + title AS title, + description AS description, + status AS "status!: TaskStatus", + assignee_user_id AS "assignee_user_id: Uuid", + assignee_first_name AS "assignee_first_name: String", + assignee_last_name AS "assignee_last_name: String", + assignee_username AS "assignee_username: String", + version AS "version!: i64", + last_event_seq AS "last_event_seq: i64", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM shared_tasks + WHERE remote_project_id = $1 + ORDER BY updated_at DESC + "#, + remote_project_id + ) + .fetch_all(pool) + .await + } + + pub async fn upsert<'e, E>(executor: E, data: SharedTaskInput) -> Result + where + E: Executor<'e, Database = Sqlite>, + { + let status = data.status.clone(); + sqlx::query_as!( + SharedTask, + r#" + INSERT INTO shared_tasks ( + id, + remote_project_id, + title, + description, + status, + assignee_user_id, + assignee_first_name, + assignee_last_name, + assignee_username, + version, + last_event_seq, + created_at, + updated_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 + ) + ON CONFLICT(id) DO UPDATE SET + remote_project_id = excluded.remote_project_id, + title = excluded.title, + description = excluded.description, + status = excluded.status, + assignee_user_id = excluded.assignee_user_id, + assignee_first_name = excluded.assignee_first_name, + assignee_last_name = excluded.assignee_last_name, + assignee_username = excluded.assignee_username, + version = excluded.version, + last_event_seq = excluded.last_event_seq, + created_at = excluded.created_at, + updated_at = excluded.updated_at + RETURNING + id AS "id!: Uuid", + remote_project_id AS "remote_project_id!: Uuid", + title AS title, + description AS description, + status AS "status!: TaskStatus", + assignee_user_id AS "assignee_user_id: Uuid", + assignee_first_name AS "assignee_first_name: String", + assignee_last_name AS "assignee_last_name: String", + assignee_username AS "assignee_username: String", + version AS "version!: i64", + last_event_seq AS "last_event_seq: i64", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + "#, + data.id, + data.remote_project_id, + data.title, + data.description, + status, + data.assignee_user_id, + data.assignee_first_name, + data.assignee_last_name, + data.assignee_username, + data.version, + data.last_event_seq, + data.created_at, + data.updated_at + ) + .fetch_one(executor) + .await + } + + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + SharedTask, + r#" + SELECT + id AS "id!: Uuid", + remote_project_id AS "remote_project_id!: Uuid", + title AS title, + description AS description, + status AS "status!: TaskStatus", + assignee_user_id AS "assignee_user_id: Uuid", + assignee_first_name AS "assignee_first_name: String", + assignee_last_name AS "assignee_last_name: String", + assignee_username AS "assignee_username: String", + version AS "version!: i64", + last_event_seq AS "last_event_seq: i64", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM shared_tasks + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await + } + + pub async fn remove<'e, E>(executor: E, id: Uuid) -> Result<(), sqlx::Error> + where + E: Executor<'e, Database = Sqlite>, + { + sqlx::query!("DELETE FROM shared_tasks WHERE id = $1", id) + .execute(executor) + .await?; + Ok(()) + } + + pub async fn remove_many<'e, E>(executor: E, ids: &[Uuid]) -> Result<(), sqlx::Error> + where + E: Executor<'e, Database = Sqlite>, + { + if ids.is_empty() { + return Ok(()); + } + + let mut builder = QueryBuilder::::new("DELETE FROM shared_tasks WHERE id IN ("); + { + let mut separated = builder.separated(", "); + for id in ids { + separated.push_bind(id); + } + } + builder.push(")"); + builder.build().execute(executor).await?; + Ok(()) + } + + pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { + sqlx::query_as!( + SharedTask, + r#" + SELECT + id AS "id!: Uuid", + remote_project_id AS "remote_project_id!: Uuid", + title AS title, + description AS description, + status AS "status!: TaskStatus", + assignee_user_id AS "assignee_user_id: Uuid", + assignee_first_name AS "assignee_first_name: String", + assignee_last_name AS "assignee_last_name: String", + assignee_username AS "assignee_username: String", + version AS "version!: i64", + last_event_seq AS "last_event_seq: i64", + created_at AS "created_at!: DateTime", + updated_at AS "updated_at!: DateTime" + FROM shared_tasks + WHERE rowid = $1 + "#, + rowid + ) + .fetch_optional(pool) + .await + } +} + +#[derive(Debug, Clone, FromRow)] +pub struct SharedActivityCursor { + pub remote_project_id: Uuid, + pub last_seq: i64, + pub updated_at: DateTime, +} + +impl SharedActivityCursor { + pub async fn get( + pool: &SqlitePool, + remote_project_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + SharedActivityCursor, + r#" + SELECT + remote_project_id AS "remote_project_id!: Uuid", + last_seq AS "last_seq!: i64", + updated_at AS "updated_at!: DateTime" + FROM shared_activity_cursors + WHERE remote_project_id = $1 + "#, + remote_project_id + ) + .fetch_optional(pool) + .await + } + + pub async fn upsert<'e, E>( + executor: E, + remote_project_id: Uuid, + last_seq: i64, + ) -> Result + where + E: Executor<'e, Database = Sqlite>, + { + sqlx::query_as!( + SharedActivityCursor, + r#" + INSERT INTO shared_activity_cursors ( + remote_project_id, + last_seq, + updated_at + ) + VALUES ( + $1, + $2, + datetime('now', 'subsec') + ) + ON CONFLICT(remote_project_id) DO UPDATE SET + last_seq = excluded.last_seq, + updated_at = excluded.updated_at + RETURNING + remote_project_id AS "remote_project_id!: Uuid", + last_seq AS "last_seq!: i64", + updated_at AS "updated_at!: DateTime" + "#, + remote_project_id, + last_seq + ) + .fetch_one(executor) + .await + } +} diff --git a/crates/db/src/models/task.rs b/crates/db/src/models/task.rs index 44236b26..1da63ea2 100644 --- a/crates/db/src/models/task.rs +++ b/crates/db/src/models/task.rs @@ -7,11 +7,14 @@ use uuid::Uuid; use super::{project::Project, task_attempt::TaskAttempt}; -#[derive(Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS, EnumString, Display)] +#[derive( + Debug, Clone, Type, Serialize, Deserialize, PartialEq, TS, EnumString, Display, Default, +)] #[sqlx(type_name = "task_status", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "kebab_case")] pub enum TaskStatus { + #[default] Todo, InProgress, InReview, @@ -27,6 +30,7 @@ pub struct Task { pub description: Option, pub status: TaskStatus, pub parent_task_attempt: Option, // Foreign key to parent TaskAttempt + pub shared_task_id: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -67,8 +71,10 @@ pub struct CreateTask { pub project_id: Uuid, pub title: String, pub description: Option, + pub status: Option, pub parent_task_attempt: Option, pub image_ids: Option>, + pub shared_task_id: Option, } impl CreateTask { @@ -81,10 +87,39 @@ impl CreateTask { project_id, title, description, + status: Some(TaskStatus::Todo), parent_task_attempt: None, image_ids: None, + shared_task_id: None, } } + + pub fn from_shared_task( + project_id: Uuid, + title: String, + description: Option, + status: TaskStatus, + shared_task_id: Uuid, + ) -> Self { + Self { + project_id, + title, + description, + status: Some(status), + parent_task_attempt: None, + image_ids: None, + shared_task_id: Some(shared_task_id), + } + } +} + +#[derive(Debug, Clone)] +pub struct SyncTask { + pub shared_task_id: Uuid, + pub project_id: Uuid, + pub title: String, + pub description: Option, + pub status: TaskStatus, } #[derive(Debug, Serialize, Deserialize, TS)] @@ -121,6 +156,7 @@ impl Task { t.description, t.status AS "status!: TaskStatus", t.parent_task_attempt AS "parent_task_attempt: Uuid", + t.shared_task_id AS "shared_task_id: Uuid", t.created_at AS "created_at!: DateTime", t.updated_at AS "updated_at!: DateTime", @@ -172,6 +208,7 @@ ORDER BY t.created_at DESC"#, description: rec.description, status: rec.status, parent_task_attempt: rec.parent_task_attempt, + shared_task_id: rec.shared_task_id, created_at: rec.created_at, updated_at: rec.updated_at, }, @@ -188,7 +225,7 @@ ORDER BY t.created_at DESC"#, pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE id = $1"#, id @@ -200,7 +237,7 @@ ORDER BY t.created_at DESC"#, pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE rowid = $1"#, rowid @@ -216,7 +253,7 @@ ORDER BY t.created_at DESC"#, ) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE id = $1 AND project_id = $2"#, id, @@ -226,22 +263,43 @@ ORDER BY t.created_at DESC"#, .await } + pub async fn find_by_shared_task_id<'e, E>( + executor: E, + shared_task_id: Uuid, + ) -> Result, sqlx::Error> + where + E: Executor<'e, Database = Sqlite>, + { + sqlx::query_as!( + Task, + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + FROM tasks + WHERE shared_task_id = $1 + LIMIT 1"#, + shared_task_id + ) + .fetch_optional(executor) + .await + } + pub async fn create( pool: &SqlitePool, data: &CreateTask, task_id: Uuid, ) -> Result { + let status = data.status.clone().unwrap_or_default(); sqlx::query_as!( Task, - r#"INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + r#"INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt, shared_task_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, task_id, data.project_id, data.title, data.description, - TaskStatus::Todo as TaskStatus, - data.parent_task_attempt + status, + data.parent_task_attempt, + data.shared_task_id ) .fetch_one(pool) .await @@ -261,7 +319,7 @@ ORDER BY t.created_at DESC"#, r#"UPDATE tasks SET title = $3, description = $4, status = $5, parent_task_attempt = $6 WHERE id = $1 AND project_id = $2 - RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, project_id, title, @@ -273,6 +331,58 @@ ORDER BY t.created_at DESC"#, .await } + pub async fn sync_from_shared_task<'e, E>( + executor: E, + data: SyncTask, + create_if_not_exists: bool, + ) -> Result + where + E: Executor<'e, Database = Sqlite>, + { + let new_task_id = Uuid::new_v4(); + + let result = sqlx::query!( + r#" + INSERT INTO tasks ( + id, + project_id, + title, + description, + status, + shared_task_id + ) + SELECT + $1, + $2, + $3, + $4, + $5, + $6 + WHERE $7 + OR EXISTS ( + SELECT 1 FROM tasks WHERE shared_task_id = $6 + ) + ON CONFLICT(shared_task_id) WHERE shared_task_id IS NOT NULL DO UPDATE SET + project_id = excluded.project_id, + title = excluded.title, + description = excluded.description, + status = excluded.status, + updated_at = datetime('now', 'subsec') + "#, + new_task_id, + data.project_id, + data.title, + data.description, + data.status, + data.shared_task_id, + create_if_not_exists + ) + .execute(executor) + .await?; + + Ok(result.rows_affected() > 0) + } + pub async fn update_status( pool: &SqlitePool, id: Uuid, @@ -306,6 +416,28 @@ ORDER BY t.created_at DESC"#, Ok(result.rows_affected()) } + /// Clear shared_task_id for all tasks that reference shared tasks belonging to a remote project + /// This breaks the link between local tasks and shared tasks when a project is unlinked + pub async fn clear_shared_task_ids_for_remote_project<'e, E>( + executor: E, + remote_project_id: Uuid, + ) -> Result + where + E: Executor<'e, Database = Sqlite>, + { + let result = sqlx::query!( + r#"UPDATE tasks + SET shared_task_id = NULL + WHERE shared_task_id IN ( + SELECT id FROM shared_tasks WHERE remote_project_id = $1 + )"#, + remote_project_id + ) + .execute(executor) + .await?; + Ok(result.rows_affected()) + } + pub async fn delete<'e, E>(executor: E, id: Uuid) -> Result where E: Executor<'e, Database = Sqlite>, @@ -316,6 +448,24 @@ ORDER BY t.created_at DESC"#, Ok(result.rows_affected()) } + pub async fn set_shared_task_id<'e, E>( + executor: E, + id: Uuid, + shared_task_id: Option, + ) -> Result<(), sqlx::Error> + where + E: Executor<'e, Database = Sqlite>, + { + sqlx::query!( + "UPDATE tasks SET shared_task_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1", + id, + shared_task_id + ) + .execute(executor) + .await?; + Ok(()) + } + pub async fn exists( pool: &SqlitePool, id: Uuid, @@ -338,7 +488,7 @@ ORDER BY t.created_at DESC"#, // Find only child tasks that have this attempt as their parent sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", shared_task_id as "shared_task_id: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE parent_task_attempt = $1 ORDER BY created_at DESC"#, diff --git a/crates/deployment/Cargo.toml b/crates/deployment/Cargo.toml index 775a7d37..332025e1 100644 --- a/crates/deployment/Cargo.toml +++ b/crates/deployment/Cargo.toml @@ -8,7 +8,7 @@ db = { path = "../db" } utils = { path = "../utils" } services = { path = "../services" } executors = { path = "../executors" } -async-trait = "0.1" +async-trait = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true } diff --git a/crates/deployment/src/lib.rs b/crates/deployment/src/lib.rs index 9c1c9048..d5bbe00c 100644 --- a/crates/deployment/src/lib.rs +++ b/crates/deployment/src/lib.rs @@ -19,7 +19,7 @@ use serde_json::Value; use services::services::{ analytics::{AnalyticsContext, AnalyticsService}, approvals::Approvals, - auth::{AuthError, AuthService}, + auth::AuthContext, config::{Config, ConfigError}, container::{ContainerError, ContainerService}, drafts::DraftsService, @@ -30,13 +30,18 @@ use services::services::{ git::{GitService, GitServiceError}, image::{ImageError, ImageService}, pr_monitor::PrMonitorService, + share::{RemoteSync, RemoteSyncHandle, ShareConfig, SharePublisher}, worktree_manager::WorktreeError, }; use sqlx::{Error as SqlxError, types::Uuid}; use thiserror::Error; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use utils::{msg_store::MsgStore, sentry as sentry_utils}; +#[derive(Debug, Clone, Copy, Error)] +#[error("Remote client not configured")] +pub struct RemoteClientNotConfigured; + #[derive(Debug, Error)] pub enum DeploymentError { #[error(transparent)] @@ -56,8 +61,6 @@ pub enum DeploymentError { #[error(transparent)] Executor(#[from] ExecutorError), #[error(transparent)] - Auth(#[from] AuthError), - #[error(transparent)] Image(#[from] ImageError), #[error(transparent)] Filesystem(#[from] FilesystemError), @@ -67,6 +70,8 @@ pub enum DeploymentError { Event(#[from] EventError), #[error(transparent)] Config(#[from] ConfigError), + #[error("Remote client not configured")] + RemoteClientNotConfigured, #[error(transparent)] Other(#[from] AnyhowError), } @@ -87,8 +92,6 @@ pub trait Deployment: Clone + Send + Sync + 'static { fn container(&self) -> &impl ContainerService; - fn auth(&self) -> &AuthService; - fn git(&self) -> &GitService; fn image(&self) -> &ImageService; @@ -105,6 +108,30 @@ pub trait Deployment: Clone + Send + Sync + 'static { fn drafts(&self) -> &DraftsService; + fn auth_context(&self) -> &AuthContext; + + fn share_publisher(&self) -> Result; + + fn share_sync_handle(&self) -> &Arc>>; + + fn spawn_remote_sync(&self, config: ShareConfig) { + let deployment = self.clone(); + let handle_slot = self.share_sync_handle().clone(); + tokio::spawn(async move { + tracing::info!("Starting shared task sync"); + + let remote_sync_handle = RemoteSync::spawn( + deployment.db().clone(), + config, + deployment.auth_context().clone(), + ); + { + let mut guard = handle_slot.lock().await; + *guard = Some(remote_sync_handle); + } + }); + } + async fn update_sentry_scope(&self) -> Result<(), DeploymentError> { let user_id = self.user_id(); let config = self.config().read().await; @@ -117,7 +144,6 @@ pub trait Deployment: Clone + Send + Sync + 'static { async fn spawn_pr_monitor_service(&self) -> tokio::task::JoinHandle<()> { let db = self.db().clone(); - let config = self.config().clone(); let analytics = self .analytics() .as_ref() @@ -125,16 +151,14 @@ pub trait Deployment: Clone + Send + Sync + 'static { user_id: self.user_id().to_string(), analytics_service: analytics_service.clone(), }); - PrMonitorService::spawn(db, config, analytics).await + let publisher = self.share_publisher().ok(); + PrMonitorService::spawn(db, analytics, publisher).await } async fn track_if_analytics_allowed(&self, event_name: &str, properties: Value) { let analytics_enabled = self.config().read().await.analytics_enabled; - // Only skip tracking if user explicitly opted out (Some(false)) - // Send for None (undecided) and Some(true) (opted in) - if analytics_enabled != Some(false) - && let Some(analytics) = self.analytics() - { + // Track events unless user has explicitly opted out + if analytics_enabled && let Some(analytics) = self.analytics() { analytics.track_event(self.user_id(), event_name, Some(properties.clone())); } } @@ -190,13 +214,26 @@ pub trait Deployment: Clone + Send + Sync + 'static { ) && let Ok(Some(task_attempt)) = TaskAttempt::find_by_id(&self.db().pool, process.task_attempt_id).await && let Ok(Some(task)) = task_attempt.parent_task(&self.db().pool).await - && let Err(e) = - Task::update_status(&self.db().pool, task.id, TaskStatus::InReview).await { - tracing::error!( - "Failed to update task status to InReview for orphaned attempt: {}", - e - ); + match Task::update_status(&self.db().pool, task.id, TaskStatus::InReview).await { + Ok(_) => { + if let Ok(publisher) = self.share_publisher() + && let Err(err) = publisher.update_shared_task_by_id(task.id).await + { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + task.id + ); + } + } + Err(e) => { + tracing::error!( + "Failed to update task status to InReview for orphaned attempt: {}", + e + ); + } + } } } Ok(()) @@ -288,6 +325,7 @@ pub trait Deployment: Clone + Send + Sync + 'static { // Create project (ignore individual failures) let project_id = Uuid::new_v4(); + match Project::create(&self.db().pool, &create_data, project_id).await { Ok(project) => { tracing::info!( diff --git a/crates/executors/Cargo.toml b/crates/executors/Cargo.toml index 45582a41..26acada8 100644 --- a/crates/executors/Cargo.toml +++ b/crates/executors/Cargo.toml @@ -12,19 +12,16 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tracing = { workspace = true } toml = "0.8" -tracing-subscriber = { workspace = true } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } schemars = { workspace = true } dirs = "5.0" xdg = "3.0" -async-trait = "0.1" -rust-embed = "8.2" +async-trait = { workspace = true } directories = "6.0.0" command-group = { version = "5.0", features = ["with-tokio"] } regex = "1.11.1" -sentry-tracing = { version = "0.41.0", features = ["backtrace"] } lazy_static = "1.4" json-patch = "2.0" thiserror = { workspace = true } @@ -48,6 +45,7 @@ codex-app-server-protocol = { git = "https://github.com/openai/codex.git", packa codex-mcp-types = { git = "https://github.com/openai/codex.git", package = "mcp-types", rev = "488ec061bf4d36916b8f477c700ea4fde4162a7a" } sha2 = "0.10" derivative = "2.2.0" +icu_provider = { version = "2.1.1", default-features = false, features = ["sync"] } [target.'cfg(windows)'.dependencies] winsplit = "0.1.0" diff --git a/crates/executors/src/actions/script.rs b/crates/executors/src/actions/script.rs index 8747f85e..47e1733f 100644 --- a/crates/executors/src/actions/script.rs +++ b/crates/executors/src/actions/script.rs @@ -23,6 +23,7 @@ pub enum ScriptContext { SetupScript, CleanupScript, DevServer, + GithubCliSetupScript, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] diff --git a/crates/local-deployment/Cargo.toml b/crates/local-deployment/Cargo.toml index 6833cad3..bb2b6ed2 100644 --- a/crates/local-deployment/Cargo.toml +++ b/crates/local-deployment/Cargo.toml @@ -11,29 +11,21 @@ services = { path = "../services" } utils = { path = "../utils" } tokio-util = { version = "0.7", features = ["io"] } bytes = "1.0" -axum = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "sqlite-preupdate-hook", "chrono", "uuid"] } -chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } -ts-rs = { workspace = true } -async-trait = "0.1" -rust-embed = "8.2" -ignore = "0.4" +async-trait = { workspace = true } +thiserror = { workspace = true } command-group = { version = "5.0", features = ["with-tokio"] } nix = { version = "0.29", features = ["signal", "process"] } openssl-sys = { workspace = true } -regex = "1.11.1" -notify-rust = "4.11" notify = "8.2.0" notify-debouncer-full = "0.5.0" reqwest = { version = "0.12", features = ["json"] } +sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debug-images"] } futures = "0.3" async-stream = "0.3" json-patch = "2.0" tokio = { workspace = true } -tokio-stream = { version = "0.1.17", features = ["sync"] } diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index fac8bdb8..690b84ad 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -24,7 +24,7 @@ use db::{ task_attempt::TaskAttempt, }, }; -use deployment::DeploymentError; +use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::{ actions::{Executable, ExecutorAction}, approvals::{ExecutorApprovalService, NoopExecutorApprovalService}, @@ -48,6 +48,7 @@ use services::services::{ git::{Commit, DiffTarget, GitService}, image::ImageService, notification::NotificationService, + share::SharePublisher, worktree_manager::WorktreeManager, }; use tokio::{sync::RwLock, task::JoinHandle}; @@ -71,9 +72,11 @@ pub struct LocalContainerService { image_service: ImageService, analytics: Option, approvals: Approvals, + publisher: Result, } impl LocalContainerService { + #[allow(clippy::too_many_arguments)] pub fn new( db: DBService, msg_stores: Arc>>>, @@ -82,6 +85,7 @@ impl LocalContainerService { image_service: ImageService, analytics: Option, approvals: Approvals, + publisher: Result, ) -> Self { let child_store = Arc::new(RwLock::new(HashMap::new())); @@ -94,6 +98,7 @@ impl LocalContainerService { image_service, analytics, approvals, + publisher, } } @@ -128,9 +133,27 @@ impl LocalContainerService { } /// Finalize task execution by updating status to InReview and sending notifications - async fn finalize_task(db: &DBService, config: &Arc>, ctx: &ExecutionContext) { - if let Err(e) = Task::update_status(&db.pool, ctx.task.id, TaskStatus::InReview).await { - tracing::error!("Failed to update task status to InReview: {e}"); + async fn finalize_task( + db: &DBService, + config: &Arc>, + share: &Result, + ctx: &ExecutionContext, + ) { + match Task::update_status(&db.pool, ctx.task.id, TaskStatus::InReview).await { + Ok(_) => { + if let Ok(publisher) = share + && let Err(err) = publisher.update_shared_task_by_id(ctx.task.id).await + { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + ctx.task.id + ); + } + } + Err(e) => { + tracing::error!("Failed to update task status to InReview: {e}"); + } } let notify_cfg = config.read().await.notifications.clone(); NotificationService::notify_execution_halted(notify_cfg, ctx).await; @@ -303,6 +326,7 @@ impl LocalContainerService { let config = self.config.clone(); let container = self.clone(); let analytics = self.analytics.clone(); + let publisher = self.publisher.clone(); let mut process_exit_rx = self.spawn_os_exit_watcher(exec_id); @@ -405,12 +429,12 @@ impl LocalContainerService { ); // Manually finalize task since we're bypassing normal execution flow - Self::finalize_task(&db, &config, &ctx).await; + Self::finalize_task(&db, &config, &publisher, &ctx).await; } } if Self::should_finalize(&ctx) { - Self::finalize_task(&db, &config, &ctx).await; + Self::finalize_task(&db, &config, &publisher, &ctx).await; // After finalization, check if a queued follow-up exists and start it if let Err(e) = container.try_consume_queued_followup(&ctx).await { tracing::error!( @@ -422,7 +446,7 @@ impl LocalContainerService { } // Fire analytics event when CodingAgent execution has finished - if config.read().await.analytics_enabled == Some(true) + if config.read().await.analytics_enabled && matches!( &ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent @@ -656,6 +680,10 @@ impl ContainerService for LocalContainerService { &self.git } + fn share_publisher(&self) -> Option<&SharePublisher> { + self.publisher.as_ref().ok() + } + async fn git_branch_prefix(&self) -> String { self.config.read().await.git_branch_prefix.clone() } @@ -819,10 +847,17 @@ impl ContainerService for LocalContainerService { _ => Arc::new(NoopExecutorApprovalService {}), }; - // Create the child and stream, add to execution tracker - let mut spawned = executor_action - .spawn(¤t_dir, approvals_service) - .await?; + // Create the child and stream, add to execution tracker with timeout + let mut spawned = tokio::time::timeout( + Duration::from_secs(30), + executor_action.spawn(¤t_dir, approvals_service), + ) + .await + .map_err(|_| { + ContainerError::Other(anyhow!( + "Timeout: process took more than 30 seconds to start" + )) + })??; self.track_child_msgs_in_store(execution_process.id, &mut spawned.child) .await; @@ -881,10 +916,23 @@ impl ContainerService for LocalContainerService { ctx.execution_process.run_reason, ExecutionProcessRunReason::DevServer ) - && let Err(e) = - Task::update_status(&self.db.pool, ctx.task.id, TaskStatus::InReview).await { - tracing::error!("Failed to update task status to InReview: {e}"); + match Task::update_status(&self.db.pool, ctx.task.id, TaskStatus::InReview).await { + Ok(_) => { + if let Some(publisher) = self.share_publisher() + && let Err(err) = publisher.update_shared_task_by_id(ctx.task.id).await + { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + ctx.task.id + ); + } + } + Err(e) => { + tracing::error!("Failed to update task status to InReview: {e}"); + } + } } tracing::debug!( diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index 12aa4b3a..dde1a804 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -2,12 +2,12 @@ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use db::DBService; -use deployment::{Deployment, DeploymentError}; +use deployment::{Deployment, DeploymentError, RemoteClientNotConfigured}; use executors::profile::ExecutorConfigs; use services::services::{ analytics::{AnalyticsConfig, AnalyticsContext, AnalyticsService, generate_user_id}, approvals::Approvals, - auth::AuthService, + auth::AuthContext, config::{Config, load_config_from_file, save_config_to_file}, container::ContainerService, drafts::DraftsService, @@ -16,9 +16,16 @@ use services::services::{ filesystem::FilesystemService, git::GitService, image::ImageService, + oauth_credentials::OAuthCredentials, + remote_client::{RemoteClient, RemoteClientError}, + share::{RemoteSyncHandle, ShareConfig, SharePublisher}, +}; +use tokio::sync::{Mutex, RwLock}; +use utils::{ + api::oauth::LoginStatus, + assets::{config_path, credentials_path}, + msg_store::MsgStore, }; -use tokio::sync::RwLock; -use utils::{assets::config_path, msg_store::MsgStore}; use uuid::Uuid; use crate::container::LocalContainerService; @@ -34,13 +41,24 @@ pub struct LocalDeployment { msg_stores: Arc>>>, container: LocalContainerService, git: GitService, - auth: AuthService, image: ImageService, filesystem: FilesystemService, events: EventService, file_search_cache: Arc, approvals: Approvals, drafts: DraftsService, + share_publisher: Result, + share_sync_handle: Arc>>, + share_config: Option, + remote_client: Result, + auth_context: AuthContext, + oauth_handoffs: Arc>>, +} + +#[derive(Debug, Clone)] +struct PendingHandoff { + provider: String, + app_verifier: String, } #[async_trait] @@ -75,7 +93,6 @@ impl Deployment for LocalDeployment { let analytics = AnalyticsConfig::new().map(AnalyticsService::new); let git = GitService::new(); let msg_stores = Arc::new(RwLock::new(HashMap::new())); - let auth = AuthService::new(); let filesystem = FilesystemService::new(); // Create shared components for EventService @@ -105,6 +122,48 @@ impl Deployment for LocalDeployment { let approvals = Approvals::new(msg_stores.clone()); + let share_config = ShareConfig::from_env(); + + let oauth_credentials = Arc::new(OAuthCredentials::new(credentials_path())); + if let Err(e) = oauth_credentials.load().await { + tracing::warn!(?e, "failed to load OAuth credentials"); + } + + let profile_cache = Arc::new(RwLock::new(None)); + let auth_context = AuthContext::new(oauth_credentials.clone(), profile_cache.clone()); + + let remote_client = match std::env::var("VK_SHARED_API_BASE") { + Ok(url) => match RemoteClient::new(&url, auth_context.clone()) { + Ok(client) => { + tracing::info!("Remote client initialized with URL: {}", url); + Ok(client) + } + Err(e) => { + tracing::error!(?e, "failed to create remote client"); + Err(RemoteClientNotConfigured) + } + }, + Err(_) => { + tracing::info!("VK_SHARED_API_BASE not set; remote features disabled"); + Err(RemoteClientNotConfigured) + } + }; + + let share_publisher = remote_client + .as_ref() + .map(|client| SharePublisher::new(db.clone(), client.clone())) + .map_err(|e| *e); + + let oauth_handoffs = Arc::new(RwLock::new(HashMap::new())); + let share_sync_handle = Arc::new(Mutex::new(None)); + + let mut share_sync_config: Option = None; + if let (Some(sc_ref), Ok(_)) = (share_config.as_ref(), &share_publisher) + && oauth_credentials.get().await.is_some() + { + share_sync_config = Some(sc_ref.clone()); + } + // We need to make analytics accessible to the ContainerService // TODO: Handle this more gracefully let analytics_ctx = analytics.as_ref().map(|s| AnalyticsContext { @@ -119,14 +178,16 @@ impl Deployment for LocalDeployment { image.clone(), analytics_ctx, approvals.clone(), + share_publisher.clone(), ); container.spawn_worktree_cleanup().await; let events = EventService::new(db.clone(), events_msg_store, events_entry_count); + let drafts = DraftsService::new(db.clone(), image.clone()); let file_search_cache = Arc::new(FileSearchCache::new()); - Ok(Self { + let deployment = Self { config, user_id, db, @@ -134,14 +195,25 @@ impl Deployment for LocalDeployment { msg_stores, container, git, - auth, image, filesystem, events, file_search_cache, approvals, drafts, - }) + share_publisher, + share_sync_handle: share_sync_handle.clone(), + share_config: share_config.clone(), + remote_client, + auth_context, + oauth_handoffs, + }; + + if let Some(sc) = share_sync_config { + deployment.spawn_remote_sync(sc); + } + + Ok(deployment) } fn user_id(&self) -> &str { @@ -167,9 +239,6 @@ impl Deployment for LocalDeployment { fn container(&self) -> &impl ContainerService { &self.container } - fn auth(&self) -> &AuthService { - &self.auth - } fn git(&self) -> &GitService { &self.git @@ -202,4 +271,88 @@ impl Deployment for LocalDeployment { fn drafts(&self) -> &DraftsService { &self.drafts } + + fn share_publisher(&self) -> Result { + self.share_publisher.clone() + } + + fn share_sync_handle(&self) -> &Arc>> { + &self.share_sync_handle + } + + fn auth_context(&self) -> &AuthContext { + &self.auth_context + } +} + +impl LocalDeployment { + pub fn remote_client(&self) -> Result { + self.remote_client.clone() + } + + /// Convenience method to get the current JWT auth token. + /// Returns None if the user is not authenticated. + pub async fn auth_token(&self) -> Option { + self.auth_context + .get_credentials() + .await + .map(|c| c.access_token) + } + + pub async fn get_login_status(&self) -> LoginStatus { + if self.auth_context.get_credentials().await.is_none() { + self.auth_context.clear_profile().await; + return LoginStatus::LoggedOut; + }; + + if let Some(cached_profile) = self.auth_context.cached_profile().await { + return LoginStatus::LoggedIn { + profile: cached_profile, + }; + } + + let Ok(client) = self.remote_client() else { + return LoginStatus::LoggedOut; + }; + + match client.profile().await { + Ok(profile) => { + self.auth_context.set_profile(profile.clone()).await; + LoginStatus::LoggedIn { profile } + } + Err(RemoteClientError::Auth) => { + let _ = self.auth_context.clear_credentials().await; + self.auth_context.clear_profile().await; + LoginStatus::LoggedOut + } + Err(_) => LoginStatus::LoggedOut, + } + } + + pub async fn store_oauth_handoff( + &self, + handoff_id: Uuid, + provider: String, + app_verifier: String, + ) { + self.oauth_handoffs.write().await.insert( + handoff_id, + PendingHandoff { + provider, + app_verifier, + }, + ); + } + + pub async fn take_oauth_handoff(&self, handoff_id: &Uuid) -> Option<(String, String)> { + self.oauth_handoffs + .write() + .await + .remove(handoff_id) + .map(|state| (state.provider, state.app_verifier)) + } + + pub fn share_config(&self) -> Option<&ShareConfig> { + self.share_config.as_ref() + } } diff --git a/crates/remote/.sqlx/query-0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e.json b/crates/remote/.sqlx/query-0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e.json new file mode 100644 index 00000000..e858b36d --- /dev/null +++ b/crates/remote/.sqlx/query-0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organization_invitations\n SET status = 'expired'\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0802e4b755645e959d1a2d9b5b13fb087d0b5b162726a09487df18139e707c5e" +} diff --git a/crates/remote/.sqlx/query-10767be278b11853c4ba86e0abf6934b901f41c72ee122c0ff50e508c48f220b.json b/crates/remote/.sqlx/query-10767be278b11853c4ba86e0abf6934b901f41c72ee122c0ff50e508c48f220b.json new file mode 100644 index 00000000..28522e99 --- /dev/null +++ b/crates/remote/.sqlx/query-10767be278b11853c4ba86e0abf6934b901f41c72ee122c0ff50e508c48f220b.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n o.id AS \"id!: Uuid\",\n o.name AS \"name!\",\n o.slug AS \"slug!\",\n o.is_personal AS \"is_personal!\",\n o.created_at AS \"created_at!\",\n o.updated_at AS \"updated_at!\",\n m.role AS \"user_role!: MemberRole\"\n FROM organizations o\n JOIN organization_member_metadata m ON m.organization_id = o.id\n WHERE m.user_id = $1\n ORDER BY o.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_personal!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "user_role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "10767be278b11853c4ba86e0abf6934b901f41c72ee122c0ff50e508c48f220b" +} diff --git a/crates/remote/.sqlx/query-128bb938e490a07d9b567f483f1e8f1b004a267c32cfe14bc88c752f61fcc083.json b/crates/remote/.sqlx/query-128bb938e490a07d9b567f483f1e8f1b004a267c32cfe14bc88c752f61fcc083.json new file mode 100644 index 00000000..fdcee97c --- /dev/null +++ b/crates/remote/.sqlx/query-128bb938e490a07d9b567f483f1e8f1b004a267c32cfe14bc88c752f61fcc083.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_handoffs\n SET\n status = 'authorized',\n error_code = NULL,\n user_id = $2,\n session_id = $3,\n app_code_hash = $4,\n authorized_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "128bb938e490a07d9b567f483f1e8f1b004a267c32cfe14bc88c752f61fcc083" +} diff --git a/crates/remote/.sqlx/query-13b1cf3d350af65f983aeab1e8c43faf3edc10c6403279f8450f2f9ae835cc18.json b/crates/remote/.sqlx/query-13b1cf3d350af65f983aeab1e8c43faf3edc10c6403279f8450f2f9ae835cc18.json new file mode 100644 index 00000000..4191c30d --- /dev/null +++ b/crates/remote/.sqlx/query-13b1cf3d350af65f983aeab1e8c43faf3edc10c6403279f8450f2f9ae835cc18.json @@ -0,0 +1,118 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_tasks (\n organization_id,\n project_id,\n creator_user_id,\n assignee_user_id,\n title,\n description,\n shared_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, NOW())\n RETURNING id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n project_id AS \"project_id!\",\n creator_user_id AS \"creator_user_id?: Uuid\",\n assignee_user_id AS \"assignee_user_id?: Uuid\",\n deleted_by_user_id AS \"deleted_by_user_id?: Uuid\",\n title AS \"title!\",\n description AS \"description?\",\n status AS \"status!: TaskStatus\",\n version AS \"version!\",\n deleted_at AS \"deleted_at?\",\n shared_at AS \"shared_at?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "project_id!", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "creator_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "assignee_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "deleted_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "title!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "status!: TaskStatus", + "type_info": { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "version!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "deleted_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "shared_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "13b1cf3d350af65f983aeab1e8c43faf3edc10c6403279f8450f2f9ae835cc18" +} diff --git a/crates/remote/.sqlx/query-174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e.json b/crates/remote/.sqlx/query-174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e.json new file mode 100644 index 00000000..e6a97e4f --- /dev/null +++ b/crates/remote/.sqlx/query-174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT role AS \"role!: MemberRole\"\n FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "174295c848146ecd7d9b542e1cad3243d19f58f1c338dbcc63d52573e05cb25e" +} diff --git a/crates/remote/.sqlx/query-18516efb04980a7dec85bb00d33f3d663e0e44f89812c19557d094e529ac9280.json b/crates/remote/.sqlx/query-18516efb04980a7dec85bb00d33f3d663e0e44f89812c19557d094e529ac9280.json new file mode 100644 index 00000000..2db893c0 --- /dev/null +++ b/crates/remote/.sqlx/query-18516efb04980a7dec85bb00d33f3d663e0e44f89812c19557d094e529ac9280.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_accounts\n WHERE user_id = $1\n ORDER BY provider\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "provider!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "provider_user_id!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "display_name?", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "avatar_url?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "18516efb04980a7dec85bb00d33f3d663e0e44f89812c19557d094e529ac9280" +} diff --git a/crates/remote/.sqlx/query-1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5.json b/crates/remote/.sqlx/query-1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5.json new file mode 100644 index 00000000..02b01a36 --- /dev/null +++ b/crates/remote/.sqlx/query-1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1ba653e8d80e8eec3b86e805d37a89b836274b47861f0b5921fe3e0b963ed1f5" +} diff --git a/crates/remote/.sqlx/query-1d691b943af2d90feaace911403fbb158839b4359f91fd5c05166ecee82b13a8.json b/crates/remote/.sqlx/query-1d691b943af2d90feaace911403fbb158839b4359f91fd5c05166ecee82b13a8.json new file mode 100644 index 00000000..2161ca6d --- /dev/null +++ b/crates/remote/.sqlx/query-1d691b943af2d90feaace911403fbb158839b4359f91fd5c05166ecee82b13a8.json @@ -0,0 +1,131 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_tasks AS t\n SET title = COALESCE($2, t.title),\n description = COALESCE($3, t.description),\n status = COALESCE($4, t.status),\n version = t.version + 1,\n updated_at = NOW()\n WHERE t.id = $1\n AND t.version = COALESCE($5, t.version)\n AND t.assignee_user_id = $6\n AND t.deleted_at IS NULL\n RETURNING\n t.id AS \"id!\",\n t.organization_id AS \"organization_id!: Uuid\",\n t.project_id AS \"project_id!\",\n t.creator_user_id AS \"creator_user_id?: Uuid\",\n t.assignee_user_id AS \"assignee_user_id?: Uuid\",\n t.deleted_by_user_id AS \"deleted_by_user_id?: Uuid\",\n t.title AS \"title!\",\n t.description AS \"description?\",\n t.status AS \"status!: TaskStatus\",\n t.version AS \"version!\",\n t.deleted_at AS \"deleted_at?\",\n t.shared_at AS \"shared_at?\",\n t.created_at AS \"created_at!\",\n t.updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "project_id!", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "creator_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "assignee_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "deleted_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "title!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "status!: TaskStatus", + "type_info": { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "version!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "deleted_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "shared_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + }, + "Int8", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "1d691b943af2d90feaace911403fbb158839b4359f91fd5c05166ecee82b13a8" +} diff --git a/crates/remote/.sqlx/query-27fde1a3270d9d32ae7030c632cdff851d02533c924d4cbb908748b33c88030e.json b/crates/remote/.sqlx/query-27fde1a3270d9d32ae7030c632cdff851d02533c924d4cbb908748b33c88030e.json new file mode 100644 index 00000000..1bd332e7 --- /dev/null +++ b/crates/remote/.sqlx/query-27fde1a3270d9d32ae7030c632cdff851d02533c924d4cbb908748b33c88030e.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organizations\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_personal!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "27fde1a3270d9d32ae7030c632cdff851d02533c924d4cbb908748b33c88030e" +} diff --git a/crates/remote/.sqlx/query-2a9a7c649ededf8772f750bb42c5144f4ab5e74dc905fb8a63340f09fd55a3d7.json b/crates/remote/.sqlx/query-2a9a7c649ededf8772f750bb42c5144f4ab5e74dc905fb8a63340f09fd55a3d7.json new file mode 100644 index 00000000..634990e3 --- /dev/null +++ b/crates/remote/.sqlx/query-2a9a7c649ededf8772f750bb42c5144f4ab5e74dc905fb8a63340f09fd55a3d7.json @@ -0,0 +1,113 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n project_id AS \"project_id!\",\n creator_user_id AS \"creator_user_id?: Uuid\",\n assignee_user_id AS \"assignee_user_id?: Uuid\",\n deleted_by_user_id AS \"deleted_by_user_id?: Uuid\",\n title AS \"title!\",\n description AS \"description?\",\n status AS \"status!: TaskStatus\",\n version AS \"version!\",\n deleted_at AS \"deleted_at?\",\n shared_at AS \"shared_at?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM shared_tasks\n WHERE id = $1\n AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "project_id!", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "creator_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "assignee_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "deleted_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "title!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "status!: TaskStatus", + "type_info": { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "version!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "deleted_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "shared_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "2a9a7c649ededf8772f750bb42c5144f4ab5e74dc905fb8a63340f09fd55a3d7" +} diff --git a/crates/remote/.sqlx/query-3a32c3e1e517a81ebf65e5ec3c80b7b557639f8041ef9a890a94f38ea6f9c3cb.json b/crates/remote/.sqlx/query-3a32c3e1e517a81ebf65e5ec3c80b7b557639f8041ef9a890a94f38ea6f9c3cb.json new file mode 100644 index 00000000..07adbff7 --- /dev/null +++ b/crates/remote/.sqlx/query-3a32c3e1e517a81ebf65e5ec3c80b7b557639f8041ef9a890a94f38ea6f9c3cb.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n provider AS \"provider!\",\n state AS \"state!\",\n return_to AS \"return_to!\",\n app_challenge AS \"app_challenge!\",\n app_code_hash AS \"app_code_hash?\",\n status AS \"status!\",\n error_code AS \"error_code?\",\n expires_at AS \"expires_at!\",\n authorized_at AS \"authorized_at?\",\n redeemed_at AS \"redeemed_at?\",\n user_id AS \"user_id?\",\n session_id AS \"session_id?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_handoffs\n WHERE state = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "provider!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "state!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "return_to!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "app_challenge!", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "app_code_hash?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "status!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "error_code?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "authorized_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "redeemed_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "user_id?", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "session_id?", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "3a32c3e1e517a81ebf65e5ec3c80b7b557639f8041ef9a890a94f38ea6f9c3cb" +} diff --git a/crates/remote/.sqlx/query-3bb0fc47179fc3468b5157bc764611ca0b088a190866fa8b60835a5a3ee9ad94.json b/crates/remote/.sqlx/query-3bb0fc47179fc3468b5157bc764611ca0b088a190866fa8b60835a5a3ee9ad94.json new file mode 100644 index 00000000..bb981944 --- /dev/null +++ b/crates/remote/.sqlx/query-3bb0fc47179fc3468b5157bc764611ca0b088a190866fa8b60835a5a3ee9ad94.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organizations\n WHERE slug = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_personal!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3bb0fc47179fc3468b5157bc764611ca0b088a190866fa8b60835a5a3ee9ad94" +} diff --git a/crates/remote/.sqlx/query-4153afb5c59d76df7c880d2f427cdba11d2eaf2fe26193043947a45bcda46f45.json b/crates/remote/.sqlx/query-4153afb5c59d76df7c880d2f427cdba11d2eaf2fe26193043947a45bcda46f45.json new file mode 100644 index 00000000..4e29553a --- /dev/null +++ b/crates/remote/.sqlx/query-4153afb5c59d76df7c880d2f427cdba11d2eaf2fe26193043947a45bcda46f45.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT st.id AS \"id!: Uuid\"\n FROM shared_tasks st\n WHERE st.project_id = $1\n AND st.deleted_at IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4153afb5c59d76df7c880d2f427cdba11d2eaf2fe26193043947a45bcda46f45" +} diff --git a/crates/remote/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json b/crates/remote/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json new file mode 100644 index 00000000..864c47b0 --- /dev/null +++ b/crates/remote/.sqlx/query-422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE auth_sessions\n SET revoked_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "422fce71b9df8d2d68d5aabe22d8299f596f77a09069e350138f5a5b72204dfe" +} diff --git a/crates/remote/.sqlx/query-4297d2fa8fd3d037243b8794a5ccfc33af057bcb6c9dc1ac601f82bb65130721.json b/crates/remote/.sqlx/query-4297d2fa8fd3d037243b8794a5ccfc33af057bcb6c9dc1ac601f82bb65130721.json new file mode 100644 index 00000000..8c99342c --- /dev/null +++ b/crates/remote/.sqlx/query-4297d2fa8fd3d037243b8794a5ccfc33af057bcb6c9dc1ac601f82bb65130721.json @@ -0,0 +1,110 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_handoffs (\n provider,\n state,\n return_to,\n app_challenge,\n expires_at\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING\n id AS \"id!\",\n provider AS \"provider!\",\n state AS \"state!\",\n return_to AS \"return_to!\",\n app_challenge AS \"app_challenge!\",\n app_code_hash AS \"app_code_hash?\",\n status AS \"status!\",\n error_code AS \"error_code?\",\n expires_at AS \"expires_at!\",\n authorized_at AS \"authorized_at?\",\n redeemed_at AS \"redeemed_at?\",\n user_id AS \"user_id?\",\n session_id AS \"session_id?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "provider!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "state!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "return_to!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "app_challenge!", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "app_code_hash?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "status!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "error_code?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "authorized_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "redeemed_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "user_id?", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "session_id?", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "4297d2fa8fd3d037243b8794a5ccfc33af057bcb6c9dc1ac601f82bb65130721" +} diff --git a/crates/remote/.sqlx/query-48ffc1dc566aeb6ea7c674aec6884579424cc9230c7d9a10ac91667f0cf931c3.json b/crates/remote/.sqlx/query-48ffc1dc566aeb6ea7c674aec6884579424cc9230c7d9a10ac91667f0cf931c3.json new file mode 100644 index 00000000..4fe138c7 --- /dev/null +++ b/crates/remote/.sqlx/query-48ffc1dc566aeb6ea7c674aec6884579424cc9230c7d9a10ac91667f0cf931c3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT organization_id\n FROM shared_tasks\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "organization_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "48ffc1dc566aeb6ea7c674aec6884579424cc9230c7d9a10ac91667f0cf931c3" +} diff --git a/crates/remote/.sqlx/query-57e4e923c756fcc30d1460c584da60a9c4040a09908d300ae37989b3ac81dc1a.json b/crates/remote/.sqlx/query-57e4e923c756fcc30d1460c584da60a9c4040a09908d300ae37989b3ac81dc1a.json new file mode 100644 index 00000000..49a58cc5 --- /dev/null +++ b/crates/remote/.sqlx/query-57e4e923c756fcc30d1460c584da60a9c4040a09908d300ae37989b3ac81dc1a.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO organizations (name, slug, is_personal)\n VALUES ($1, $2, TRUE)\n RETURNING\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_personal!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "57e4e923c756fcc30d1460c584da60a9c4040a09908d300ae37989b3ac81dc1a" +} diff --git a/crates/remote/.sqlx/query-5c2b33e0128b6584090c09ebe18761532c0e5a3e233f316591ebdcd5c1fcd42d.json b/crates/remote/.sqlx/query-5c2b33e0128b6584090c09ebe18761532c0e5a3e233f316591ebdcd5c1fcd42d.json new file mode 100644 index 00000000..7c81c294 --- /dev/null +++ b/crates/remote/.sqlx/query-5c2b33e0128b6584090c09ebe18761532c0e5a3e233f316591ebdcd5c1fcd42d.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO projects (\n organization_id,\n name,\n metadata\n )\n VALUES ($1, $2, $3)\n RETURNING\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n metadata AS \"metadata!: Value\",\n created_at AS \"created_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "metadata!: Value", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "5c2b33e0128b6584090c09ebe18761532c0e5a3e233f316591ebdcd5c1fcd42d" +} diff --git a/crates/remote/.sqlx/query-5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8.json b/crates/remote/.sqlx/query-5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8.json new file mode 100644 index 00000000..0f270c1d --- /dev/null +++ b/crates/remote/.sqlx/query-5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organization_invitations\n WHERE organization_id = $1\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "invited_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "status!: InvitationStatus", + "type_info": { + "Custom": { + "name": "invitation_status", + "kind": { + "Enum": [ + "pending", + "accepted", + "declined", + "expired" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "token!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5cc635c1e2ceaad3edcec3a471a04f17071c5719f4ad0626491aa6a3b67057b8" +} diff --git a/crates/remote/.sqlx/query-5daf988360a61a4a4ca402e74f86f6a6f880e805f716ec2953e0d960a3e8131f.json b/crates/remote/.sqlx/query-5daf988360a61a4a4ca402e74f86f6a6f880e805f716ec2953e0d960a3e8131f.json new file mode 100644 index 00000000..7e55acd4 --- /dev/null +++ b/crates/remote/.sqlx/query-5daf988360a61a4a4ca402e74f86f6a6f880e805f716ec2953e0d960a3e8131f.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET name = $2\n WHERE id = $1\n RETURNING\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_personal!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5daf988360a61a4a4ca402e74f86f6a6f880e805f716ec2953e0d960a3e8131f" +} diff --git a/crates/remote/.sqlx/query-60d236bec7602bd4e01b515ea17aa4f0b5b5c21249bd8de0400894ad63a50108.json b/crates/remote/.sqlx/query-60d236bec7602bd4e01b515ea17aa4f0b5b5c21249bd8de0400894ad63a50108.json new file mode 100644 index 00000000..2c63e736 --- /dev/null +++ b/crates/remote/.sqlx/query-60d236bec7602bd4e01b515ea17aa4f0b5b5c21249bd8de0400894ad63a50108.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_accounts (\n user_id,\n provider,\n provider_user_id,\n email,\n username,\n display_name,\n avatar_url\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (provider, provider_user_id) DO UPDATE\n SET\n email = EXCLUDED.email,\n username = EXCLUDED.username,\n display_name = EXCLUDED.display_name,\n avatar_url = EXCLUDED.avatar_url\n RETURNING\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "provider!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "provider_user_id!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "display_name?", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "avatar_url?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "60d236bec7602bd4e01b515ea17aa4f0b5b5c21249bd8de0400894ad63a50108" +} diff --git a/crates/remote/.sqlx/query-65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec.json b/crates/remote/.sqlx/query-65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec.json new file mode 100644 index 00000000..be6e6992 --- /dev/null +++ b/crates/remote/.sqlx/query-65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organization_invitations\n SET status = 'accepted'\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "65f7a21a932662220579276b648b4866ecb76a8d7a4b36d2178b0328cf12f7ec" +} diff --git a/crates/remote/.sqlx/query-6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c.json b/crates/remote/.sqlx/query-6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c.json new file mode 100644 index 00000000..53d4715d --- /dev/null +++ b/crates/remote/.sqlx/query-6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT organization_id\n FROM projects\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "organization_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6c5c2a580b7be0465ecd2e86ff92282c0947576fbb09cb23c4b9a2189a38747c" +} diff --git a/crates/remote/.sqlx/query-75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb.json b/crates/remote/.sqlx/query-75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb.json new file mode 100644 index 00000000..9d9c0861 --- /dev/null +++ b/crates/remote/.sqlx/query-75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO organization_member_metadata (organization_id, user_id, role)\n VALUES ($1, $2, $3)\n ON CONFLICT (organization_id, user_id) DO UPDATE\n SET role = EXCLUDED.role\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "75e67eb14d42e5c1003060931a7d6ff7c957f024d1d200c2321de693ddf56ecb" +} diff --git a/crates/remote/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json b/crates/remote/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json new file mode 100644 index 00000000..77171073 --- /dev/null +++ b/crates/remote/.sqlx/query-775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE auth_sessions\n SET last_used_at = date_trunc('day', NOW())\n WHERE id = $1\n AND (\n last_used_at IS NULL\n OR last_used_at < date_trunc('day', NOW())\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "775151df9d9be456f8a86a1826fd4b7c4ea6ada452dfc89f30c7b6d0135c9e2e" +} diff --git a/crates/remote/.sqlx/query-79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838.json b/crates/remote/.sqlx/query-79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838.json new file mode 100644 index 00000000..a69bb3b1 --- /dev/null +++ b/crates/remote/.sqlx/query-79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id\n FROM organization_member_metadata\n WHERE organization_id = $1 AND role = 'admin'\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "79f211832f75b3711706ffb94edb091f6288aa2aaea4ffebcce04ff9a27ab838" +} diff --git a/crates/remote/.sqlx/query-7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17.json b/crates/remote/.sqlx/query-7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17.json new file mode 100644 index 00000000..0c8fe4c6 --- /dev/null +++ b/crates/remote/.sqlx/query-7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17.json @@ -0,0 +1,113 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO organization_invitations (\n organization_id, invited_by_user_id, email, role, token, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "invited_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "status!: InvitationStatus", + "type_info": { + "Custom": { + "name": "invitation_status", + "kind": { + "Enum": [ + "pending", + "accepted", + "declined", + "expired" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "token!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + }, + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "7def4e455b1290e624cf7bb52819074dadebc72a22ddfc8f4ba2513eb2992c17" +} diff --git a/crates/remote/.sqlx/query-814e3c0507a86c04008e08104176c3c552833f518b2e880e649ad7fc10c0721c.json b/crates/remote/.sqlx/query-814e3c0507a86c04008e08104176c3c552833f518b2e880e649ad7fc10c0721c.json new file mode 100644 index 00000000..b5d63c22 --- /dev/null +++ b/crates/remote/.sqlx/query-814e3c0507a86c04008e08104176c3c552833f518b2e880e649ad7fc10c0721c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH next AS (\n INSERT INTO project_activity_counters AS counters (project_id, last_seq)\n VALUES ($1, 1)\n ON CONFLICT (project_id)\n DO UPDATE SET last_seq = counters.last_seq + 1\n RETURNING last_seq\n )\n INSERT INTO activity (\n project_id,\n seq,\n assignee_user_id,\n event_type,\n payload\n )\n SELECT $1, next.last_seq, $2, $3, $4\n FROM next\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "814e3c0507a86c04008e08104176c3c552833f518b2e880e649ad7fc10c0721c" +} diff --git a/crates/remote/.sqlx/query-815acb1e55a78b1f79fcc6cdd7aef7b728e43055c70b47de3ab2ace849e020ff.json b/crates/remote/.sqlx/query-815acb1e55a78b1f79fcc6cdd7aef7b728e43055c70b47de3ab2ace849e020ff.json new file mode 100644 index 00000000..b0250bfd --- /dev/null +++ b/crates/remote/.sqlx/query-815acb1e55a78b1f79fcc6cdd7aef7b728e43055c70b47de3ab2ace849e020ff.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO organizations (name, slug)\n VALUES ($1, $2)\n RETURNING\n id AS \"id!: Uuid\",\n name AS \"name!\",\n slug AS \"slug!\",\n is_personal AS \"is_personal!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slug!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_personal!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "815acb1e55a78b1f79fcc6cdd7aef7b728e43055c70b47de3ab2ace849e020ff" +} diff --git a/crates/remote/.sqlx/query-862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4.json b/crates/remote/.sqlx/query-862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4.json new file mode 100644 index 00000000..fc8c857d --- /dev/null +++ b/crates/remote/.sqlx/query-862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organization_member_metadata\n SET role = $3\n WHERE organization_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "862eb483016735e02aad5e9d7e14584d1db4f2b7517b246d73bbea45f2edead4" +} diff --git a/crates/remote/.sqlx/query-8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c.json b/crates/remote/.sqlx/query-8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c.json new file mode 100644 index 00000000..b99a915d --- /dev/null +++ b/crates/remote/.sqlx/query-8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\"\n FROM users\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "first_name?", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "last_name?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "username?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + true + ] + }, + "hash": "8700e0ec6e6832a658fc2e52381c6e165d6129b275ed6ddf2e0f073b9488a31c" +} diff --git a/crates/remote/.sqlx/query-8a3b2f2adde045e2c9bc89f4a0b948b319057bfe2246e0250298d23af0442431.json b/crates/remote/.sqlx/query-8a3b2f2adde045e2c9bc89f4a0b948b319057bfe2246e0250298d23af0442431.json new file mode 100644 index 00000000..fa44cd2a --- /dev/null +++ b/crates/remote/.sqlx/query-8a3b2f2adde045e2c9bc89f4a0b948b319057bfe2246e0250298d23af0442431.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT is_personal\n FROM organizations\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_personal", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8a3b2f2adde045e2c9bc89f4a0b948b319057bfe2246e0250298d23af0442431" +} diff --git a/crates/remote/.sqlx/query-8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c.json b/crates/remote/.sqlx/query-8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c.json new file mode 100644 index 00000000..d82f0567 --- /dev/null +++ b/crates/remote/.sqlx/query-8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_handoffs\n SET\n status = $2,\n error_code = $3\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8e19324c386abf1aa443d861d68290bec42e4c532d63b8528f6d8d5082335a1c" +} diff --git a/crates/remote/.sqlx/query-9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41.json b/crates/remote/.sqlx/query-9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41.json new file mode 100644 index 00000000..8ebd2a91 --- /dev/null +++ b/crates/remote/.sqlx/query-9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM organization_invitations\n WHERE id = $1 AND organization_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9110860adef3796e2aefb3e48bbb9651149f3707b75ecdd12c25879983130a41" +} diff --git a/crates/remote/.sqlx/query-92d13927cde8ac62cb0cfd3c3410aa4d42717d6a3a219926ddc34ca1d2520306.json b/crates/remote/.sqlx/query-92d13927cde8ac62cb0cfd3c3410aa4d42717d6a3a219926ddc34ca1d2520306.json new file mode 100644 index 00000000..d7fbc30b --- /dev/null +++ b/crates/remote/.sqlx/query-92d13927cde8ac62cb0cfd3c3410aa4d42717d6a3a219926ddc34ca1d2520306.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE auth_sessions\n SET session_secret_hash = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "92d13927cde8ac62cb0cfd3c3410aa4d42717d6a3a219926ddc34ca1d2520306" +} diff --git a/crates/remote/.sqlx/query-94d0724ca8fdf2bf1c965d70ea3db976f1154439fd6299365b27d12f992e8862.json b/crates/remote/.sqlx/query-94d0724ca8fdf2bf1c965d70ea3db976f1154439fd6299365b27d12f992e8862.json new file mode 100644 index 00000000..879f7d18 --- /dev/null +++ b/crates/remote/.sqlx/query-94d0724ca8fdf2bf1c965d70ea3db976f1154439fd6299365b27d12f992e8862.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_handoffs\n SET\n status = 'redeemed',\n redeemed_at = NOW()\n WHERE id = $1\n AND status = 'authorized'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "94d0724ca8fdf2bf1c965d70ea3db976f1154439fd6299365b27d12f992e8862" +} diff --git a/crates/remote/.sqlx/query-97132a5a3f0c0f9ca404d8517dd77a3e55a6933d8b7afad5296d9a63ec43d1e0.json b/crates/remote/.sqlx/query-97132a5a3f0c0f9ca404d8517dd77a3e55a6933d8b7afad5296d9a63ec43d1e0.json new file mode 100644 index 00000000..c23c2dbe --- /dev/null +++ b/crates/remote/.sqlx/query-97132a5a3f0c0f9ca404d8517dd77a3e55a6933d8b7afad5296d9a63ec43d1e0.json @@ -0,0 +1,116 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_tasks AS t\n SET assignee_user_id = $2,\n version = t.version + 1\n WHERE t.id = $1\n AND t.version = COALESCE($4, t.version)\n AND ($3::uuid IS NULL OR t.assignee_user_id = $3::uuid)\n AND t.deleted_at IS NULL\n RETURNING\n t.id AS \"id!\",\n t.organization_id AS \"organization_id!: Uuid\",\n t.project_id AS \"project_id!\",\n t.creator_user_id AS \"creator_user_id?: Uuid\",\n t.assignee_user_id AS \"assignee_user_id?: Uuid\",\n t.deleted_by_user_id AS \"deleted_by_user_id?: Uuid\",\n t.title AS \"title!\",\n t.description AS \"description?\",\n t.status AS \"status!: TaskStatus\",\n t.version AS \"version!\",\n t.deleted_at AS \"deleted_at?\",\n t.shared_at AS \"shared_at?\",\n t.created_at AS \"created_at!\",\n t.updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "project_id!", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "creator_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "assignee_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "deleted_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "title!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "status!: TaskStatus", + "type_info": { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "version!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "deleted_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "shared_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "97132a5a3f0c0f9ca404d8517dd77a3e55a6933d8b7afad5296d9a63ec43d1e0" +} diff --git a/crates/remote/.sqlx/query-ae5afb54ca4316801148a697d31965c714f87b84840d93195443fa1df9375543.json b/crates/remote/.sqlx/query-ae5afb54ca4316801148a697d31965c714f87b84840d93195443fa1df9375543.json new file mode 100644 index 00000000..66c958a5 --- /dev/null +++ b/crates/remote/.sqlx/query-ae5afb54ca4316801148a697d31965c714f87b84840d93195443fa1df9375543.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT pg_try_advisory_lock(hashtextextended($1, 0))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_try_advisory_lock", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ae5afb54ca4316801148a697d31965c714f87b84840d93195443fa1df9375543" +} diff --git a/crates/remote/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json b/crates/remote/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json new file mode 100644 index 00000000..042d6357 --- /dev/null +++ b/crates/remote/.sqlx/query-b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM users\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "first_name?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "last_name?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "b2c8a0820366a696d4425720bacec9c694398e2f9ff101753c8833cbf0152d9d" +} diff --git a/crates/remote/.sqlx/query-b4ca0d7fada2acae624ec6a26fdf0354f3d4c1e0d24a6685bfdb8d594c882430.json b/crates/remote/.sqlx/query-b4ca0d7fada2acae624ec6a26fdf0354f3d4c1e0d24a6685bfdb8d594c882430.json new file mode 100644 index 00000000..b3272803 --- /dev/null +++ b/crates/remote/.sqlx/query-b4ca0d7fada2acae624ec6a26fdf0354f3d4c1e0d24a6685bfdb8d594c882430.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n provider AS \"provider!\",\n state AS \"state!\",\n return_to AS \"return_to!\",\n app_challenge AS \"app_challenge!\",\n app_code_hash AS \"app_code_hash?\",\n status AS \"status!\",\n error_code AS \"error_code?\",\n expires_at AS \"expires_at!\",\n authorized_at AS \"authorized_at?\",\n redeemed_at AS \"redeemed_at?\",\n user_id AS \"user_id?\",\n session_id AS \"session_id?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_handoffs\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "provider!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "state!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "return_to!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "app_challenge!", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "app_code_hash?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "status!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "error_code?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "authorized_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "redeemed_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "user_id?", + "type_info": "Uuid" + }, + { + "ordinal": 12, + "name": "session_id?", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "b4ca0d7fada2acae624ec6a26fdf0354f3d4c1e0d24a6685bfdb8d594c882430" +} diff --git a/crates/remote/.sqlx/query-b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee.json b/crates/remote/.sqlx/query-b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee.json new file mode 100644 index 00000000..724817eb --- /dev/null +++ b/crates/remote/.sqlx/query-b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organization_invitations\n WHERE token = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "invited_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "status!: InvitationStatus", + "type_info": { + "Custom": { + "name": "invitation_status", + "kind": { + "Enum": [ + "pending", + "accepted", + "declined", + "expired" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "token!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "b9ca641c1f698d0ade94f50ecc78ac9fb75cf12b55f36556741a8a3adeffe7ee" +} diff --git a/crates/remote/.sqlx/query-b9ed7772c3b0b599f8b5021f9a05a3bf58371a49aa41905aa7096cc0ae915b73.json b/crates/remote/.sqlx/query-b9ed7772c3b0b599f8b5021f9a05a3bf58371a49aa41905aa7096cc0ae915b73.json new file mode 100644 index 00000000..3b86d935 --- /dev/null +++ b/crates/remote/.sqlx/query-b9ed7772c3b0b599f8b5021f9a05a3bf58371a49aa41905aa7096cc0ae915b73.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n user_id AS \"user_id!: Uuid\",\n provider AS \"provider!\",\n provider_user_id AS \"provider_user_id!\",\n email AS \"email?\",\n username AS \"username?\",\n display_name AS \"display_name?\",\n avatar_url AS \"avatar_url?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM oauth_accounts\n WHERE provider = $1\n AND provider_user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "provider!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "provider_user_id!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "display_name?", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "avatar_url?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "b9ed7772c3b0b599f8b5021f9a05a3bf58371a49aa41905aa7096cc0ae915b73" +} diff --git a/crates/remote/.sqlx/query-ba222a6989447b36de700fa211af240fcf59603cf2bf50eb8c2be8a37fcfc565.json b/crates/remote/.sqlx/query-ba222a6989447b36de700fa211af240fcf59603cf2bf50eb8c2be8a37fcfc565.json new file mode 100644 index 00000000..10d47488 --- /dev/null +++ b/crates/remote/.sqlx/query-ba222a6989447b36de700fa211af240fcf59603cf2bf50eb8c2be8a37fcfc565.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT MAX(seq)\n FROM activity\n WHERE project_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "max", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ba222a6989447b36de700fa211af240fcf59603cf2bf50eb8c2be8a37fcfc565" +} diff --git a/crates/remote/.sqlx/query-c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668.json b/crates/remote/.sqlx/query-c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668.json new file mode 100644 index 00000000..84a1748e --- /dev/null +++ b/crates/remote/.sqlx/query-c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668.json @@ -0,0 +1,98 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n organization_id AS \"organization_id!: Uuid\",\n invited_by_user_id AS \"invited_by_user_id?: Uuid\",\n email AS \"email!\",\n role AS \"role!: MemberRole\",\n status AS \"status!: InvitationStatus\",\n token AS \"token!\",\n expires_at AS \"expires_at!\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM organization_invitations\n WHERE token = $1 AND status = 'pending'\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "invited_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "status!: InvitationStatus", + "type_info": { + "Custom": { + "name": "invitation_status", + "kind": { + "Enum": [ + "pending", + "accepted", + "declined", + "expired" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "token!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "expires_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c665891a58a9b19de71114e24e7162bfc0c1b5b3bfc41a9e9193e8e3e70d0668" +} diff --git a/crates/remote/.sqlx/query-c8aa60c6bfbdc7c471fec520a958d6718bc60876a28b92b49fe11169b23c2966.json b/crates/remote/.sqlx/query-c8aa60c6bfbdc7c471fec520a958d6718bc60876a28b92b49fe11169b23c2966.json new file mode 100644 index 00000000..0bf3aef6 --- /dev/null +++ b/crates/remote/.sqlx/query-c8aa60c6bfbdc7c471fec520a958d6718bc60876a28b92b49fe11169b23c2966.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT pg_advisory_unlock(hashtextextended($1, 0))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_unlock", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c8aa60c6bfbdc7c471fec520a958d6718bc60876a28b92b49fe11169b23c2966" +} diff --git a/crates/remote/.sqlx/query-c9e755d05954681e0698d6287ad5cd11592d117083baf43e859961e5c4e5d10f.json b/crates/remote/.sqlx/query-c9e755d05954681e0698d6287ad5cd11592d117083baf43e859961e5c4e5d10f.json new file mode 100644 index 00000000..3caf417f --- /dev/null +++ b/crates/remote/.sqlx/query-c9e755d05954681e0698d6287ad5cd11592d117083baf43e859961e5c4e5d10f.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH s AS (\n SELECT\n COUNT(*) FILTER (WHERE role = 'admin') AS admin_count,\n BOOL_OR(user_id = $2 AND role = 'admin') AS is_admin\n FROM organization_member_metadata\n WHERE organization_id = $1\n )\n DELETE FROM organizations o\n USING s\n WHERE o.id = $1\n AND s.is_admin = true\n AND s.admin_count > 1\n RETURNING o.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c9e755d05954681e0698d6287ad5cd11592d117083baf43e859961e5c4e5d10f" +} diff --git a/crates/remote/.sqlx/query-d12fbd108d36c817c94997744b50cafd08407c0e207e2cacd43c50d28e886b19.json b/crates/remote/.sqlx/query-d12fbd108d36c817c94997744b50cafd08407c0e207e2cacd43c50d28e886b19.json new file mode 100644 index 00000000..5cb36b30 --- /dev/null +++ b/crates/remote/.sqlx/query-d12fbd108d36c817c94997744b50cafd08407c0e207e2cacd43c50d28e886b19.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!\",\n user_id AS \"user_id!: Uuid\",\n session_secret_hash AS \"session_secret_hash?\",\n created_at AS \"created_at!\",\n last_used_at AS \"last_used_at?\",\n revoked_at AS \"revoked_at?\"\n FROM auth_sessions\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "session_secret_hash?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "revoked_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true + ] + }, + "hash": "d12fbd108d36c817c94997744b50cafd08407c0e207e2cacd43c50d28e886b19" +} diff --git a/crates/remote/.sqlx/query-d37f5255b90438fe6b5584659e4358817629a909e3949874d2fdeb4aa9928fe3.json b/crates/remote/.sqlx/query-d37f5255b90438fe6b5584659e4358817629a909e3949874d2fdeb4aa9928fe3.json new file mode 100644 index 00000000..80a82e44 --- /dev/null +++ b/crates/remote/.sqlx/query-d37f5255b90438fe6b5584659e4358817629a909e3949874d2fdeb4aa9928fe3.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n metadata AS \"metadata!: Value\",\n created_at AS \"created_at!: DateTime\"\n FROM projects\n WHERE organization_id = $1\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "metadata!: Value", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "d37f5255b90438fe6b5584659e4358817629a909e3949874d2fdeb4aa9928fe3" +} diff --git a/crates/remote/.sqlx/query-d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06.json b/crates/remote/.sqlx/query-d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06.json new file mode 100644 index 00000000..6999cfa6 --- /dev/null +++ b/crates/remote/.sqlx/query-d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT role AS \"role!: MemberRole\"\n FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d78735cb49612be9fdf5a7e90c5e70cd050bc001533f388ae73e4bf64ea52a06" +} diff --git a/crates/remote/.sqlx/query-dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613.json b/crates/remote/.sqlx/query-dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613.json new file mode 100644 index 00000000..193299a1 --- /dev/null +++ b/crates/remote/.sqlx/query-dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (id, email, first_name, last_name, username)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (id) DO UPDATE\n SET email = EXCLUDED.email,\n first_name = EXCLUDED.first_name,\n last_name = EXCLUDED.last_name,\n username = EXCLUDED.username\n RETURNING\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "first_name?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "last_name?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "dc063653a33231264dadc3971c2a0715759b8e3ef198d7325e83935a70698613" +} diff --git a/crates/remote/.sqlx/query-e185c68e4809dddb5dd1e59f1cb123c4e02499d42d97df65fc7a625568d4d234.json b/crates/remote/.sqlx/query-e185c68e4809dddb5dd1e59f1cb123c4e02499d42d97df65fc7a625568d4d234.json new file mode 100644 index 00000000..5974a2f4 --- /dev/null +++ b/crates/remote/.sqlx/query-e185c68e4809dddb5dd1e59f1cb123c4e02499d42d97df65fc7a625568d4d234.json @@ -0,0 +1,115 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_tasks AS t\n SET deleted_at = NOW(),\n deleted_by_user_id = $3,\n version = t.version + 1\n WHERE t.id = $1\n AND t.version = COALESCE($2, t.version)\n AND t.assignee_user_id = $3\n AND t.deleted_at IS NULL\n RETURNING\n t.id AS \"id!\",\n t.organization_id AS \"organization_id!: Uuid\",\n t.project_id AS \"project_id!\",\n t.creator_user_id AS \"creator_user_id?: Uuid\",\n t.assignee_user_id AS \"assignee_user_id?: Uuid\",\n t.deleted_by_user_id AS \"deleted_by_user_id?: Uuid\",\n t.title AS \"title!\",\n t.description AS \"description?\",\n t.status AS \"status!: TaskStatus\",\n t.version AS \"version!\",\n t.deleted_at AS \"deleted_at?\",\n t.shared_at AS \"shared_at?\",\n t.created_at AS \"created_at!\",\n t.updated_at AS \"updated_at!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "project_id!", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "creator_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "assignee_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "deleted_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "title!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "status!: TaskStatus", + "type_info": { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "version!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "deleted_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "shared_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "e185c68e4809dddb5dd1e59f1cb123c4e02499d42d97df65fc7a625568d4d234" +} diff --git a/crates/remote/.sqlx/query-ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401.json b/crates/remote/.sqlx/query-ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401.json new file mode 100644 index 00000000..e7850a09 --- /dev/null +++ b/crates/remote/.sqlx/query-ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n omm.user_id AS \"user_id!: Uuid\",\n omm.role AS \"role!: MemberRole\",\n omm.joined_at AS \"joined_at!\",\n u.first_name AS \"first_name?\",\n u.last_name AS \"last_name?\",\n u.username AS \"username?\",\n u.email AS \"email?\",\n oa.avatar_url AS \"avatar_url?\"\n FROM organization_member_metadata omm\n INNER JOIN users u ON omm.user_id = u.id\n LEFT JOIN LATERAL (\n SELECT avatar_url\n FROM oauth_accounts\n WHERE user_id = omm.user_id\n ORDER BY created_at ASC\n LIMIT 1\n ) oa ON true\n WHERE omm.organization_id = $1\n ORDER BY omm.joined_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "role!: MemberRole", + "type_info": { + "Custom": { + "name": "member_role", + "kind": { + "Enum": [ + "admin", + "member" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "joined_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "first_name?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "last_name?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "email?", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "avatar_url?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true + ] + }, + "hash": "ec5c77c1afea022848e52039e1c681e39dca08568992ec67770b3ef973b40401" +} diff --git a/crates/remote/.sqlx/query-ec71b554ba448df64bec37a2de1e199e12146ad2ead40ab8c850a613d0d2b764.json b/crates/remote/.sqlx/query-ec71b554ba448df64bec37a2de1e199e12146ad2ead40ab8c850a613d0d2b764.json new file mode 100644 index 00000000..116f4a78 --- /dev/null +++ b/crates/remote/.sqlx/query-ec71b554ba448df64bec37a2de1e199e12146ad2ead40ab8c850a613d0d2b764.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n organization_id AS \"organization_id!: Uuid\",\n name AS \"name!\",\n metadata AS \"metadata!: Value\",\n created_at AS \"created_at!: DateTime\"\n FROM projects\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "metadata!: Value", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at!: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ec71b554ba448df64bec37a2de1e199e12146ad2ead40ab8c850a613d0d2b764" +} diff --git a/crates/remote/.sqlx/query-f084eebbcd2ba73ab4783bccc0b665b47bf2dd72b82c08847f0de58425d9eb6a.json b/crates/remote/.sqlx/query-f084eebbcd2ba73ab4783bccc0b665b47bf2dd72b82c08847f0de58425d9eb6a.json new file mode 100644 index 00000000..befb75c9 --- /dev/null +++ b/crates/remote/.sqlx/query-f084eebbcd2ba73ab4783bccc0b665b47bf2dd72b82c08847f0de58425d9eb6a.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id AS \"id!: Uuid\",\n email AS \"email!\",\n first_name AS \"first_name?\",\n last_name AS \"last_name?\",\n username AS \"username?\",\n created_at AS \"created_at!\",\n updated_at AS \"updated_at!\"\n FROM users\n WHERE lower(email) = lower($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "first_name?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "last_name?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "username?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at!", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "f084eebbcd2ba73ab4783bccc0b665b47bf2dd72b82c08847f0de58425d9eb6a" +} diff --git a/crates/remote/.sqlx/query-f40c7ea0e0692e2ee7eead2027260104616026d32f312f8633236cc9438cd958.json b/crates/remote/.sqlx/query-f40c7ea0e0692e2ee7eead2027260104616026d32f312f8633236cc9438cd958.json new file mode 100644 index 00000000..feeb9b6e --- /dev/null +++ b/crates/remote/.sqlx/query-f40c7ea0e0692e2ee7eead2027260104616026d32f312f8633236cc9438cd958.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO auth_sessions (user_id, session_secret_hash)\n VALUES ($1, $2)\n RETURNING\n id AS \"id!\",\n user_id AS \"user_id!: Uuid\",\n session_secret_hash AS \"session_secret_hash?\",\n created_at AS \"created_at!\",\n last_used_at AS \"last_used_at?\",\n revoked_at AS \"revoked_at?\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "session_secret_hash?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "revoked_at?", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true + ] + }, + "hash": "f40c7ea0e0692e2ee7eead2027260104616026d32f312f8633236cc9438cd958" +} diff --git a/crates/remote/.sqlx/query-f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2.json b/crates/remote/.sqlx/query-f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2.json new file mode 100644 index 00000000..d4327889 --- /dev/null +++ b/crates/remote/.sqlx/query-f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM organization_member_metadata\n WHERE organization_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f7c20c9dc1eaf61cc18cf226449b4ee8c4b082c96515a3ee261c960aa23171e2" +} diff --git a/crates/remote/.sqlx/query-fe740e5984676e9bdbdd36e9f090b00b952a31f89ae649046f3d97a9fa4913bf.json b/crates/remote/.sqlx/query-fe740e5984676e9bdbdd36e9f090b00b952a31f89ae649046f3d97a9fa4913bf.json new file mode 100644 index 00000000..bedb1a1f --- /dev/null +++ b/crates/remote/.sqlx/query-fe740e5984676e9bdbdd36e9f090b00b952a31f89ae649046f3d97a9fa4913bf.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT format('%I.%I', n.nspname, c.relname) AS qualified_name,\n split_part(\n split_part(pg_get_expr(c.relpartbound, c.oid), ' TO (''', 2),\n ''')', 1\n )::timestamptz AS upper_bound\n FROM pg_partition_tree('activity') pt\n JOIN pg_class c ON c.oid = pt.relid\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE pt.isleaf\n AND c.relname ~ '^activity_p_\\d{8}$'\n AND split_part(\n split_part(pg_get_expr(c.relpartbound, c.oid), ' TO (''', 2),\n ''')', 1\n )::timestamptz <= NOW() - INTERVAL '2 days'\n ORDER BY upper_bound\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "qualified_name", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "upper_bound", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "fe740e5984676e9bdbdd36e9f090b00b952a31f89ae649046f3d97a9fa4913bf" +} diff --git a/crates/remote/.sqlx/query-ff9b35a31210dbddd237f4234bec1411b5aa1b0be986fbe5a8ee21e6771222f2.json b/crates/remote/.sqlx/query-ff9b35a31210dbddd237f4234bec1411b5aa1b0be986fbe5a8ee21e6771222f2.json new file mode 100644 index 00000000..c20ad5eb --- /dev/null +++ b/crates/remote/.sqlx/query-ff9b35a31210dbddd237f4234bec1411b5aa1b0be986fbe5a8ee21e6771222f2.json @@ -0,0 +1,137 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n st.id AS \"id!: Uuid\",\n st.organization_id AS \"organization_id!: Uuid\",\n st.project_id AS \"project_id!: Uuid\",\n st.creator_user_id AS \"creator_user_id?: Uuid\",\n st.assignee_user_id AS \"assignee_user_id?: Uuid\",\n st.deleted_by_user_id AS \"deleted_by_user_id?: Uuid\",\n st.title AS \"title!\",\n st.description AS \"description?\",\n st.status AS \"status!: TaskStatus\",\n st.version AS \"version!\",\n st.deleted_at AS \"deleted_at?\",\n st.shared_at AS \"shared_at?\",\n st.created_at AS \"created_at!\",\n st.updated_at AS \"updated_at!\",\n u.id AS \"user_id?: Uuid\",\n u.first_name AS \"user_first_name?\",\n u.last_name AS \"user_last_name?\",\n u.username AS \"user_username?\"\n FROM shared_tasks st\n LEFT JOIN users u ON st.assignee_user_id = u.id\n WHERE st.project_id = $1\n AND st.deleted_at IS NULL\n ORDER BY st.updated_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "organization_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "project_id!: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "creator_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "assignee_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "deleted_by_user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "title!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "status!: TaskStatus", + "type_info": { + "Custom": { + "name": "task_status", + "kind": { + "Enum": [ + "todo", + "in-progress", + "in-review", + "done", + "cancelled" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "version!", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "deleted_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "shared_at?", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "created_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "user_id?: Uuid", + "type_info": "Uuid" + }, + { + "ordinal": 15, + "name": "user_first_name?", + "type_info": "Text" + }, + { + "ordinal": 16, + "name": "user_last_name?", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "user_username?", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "ff9b35a31210dbddd237f4234bec1411b5aa1b0be986fbe5a8ee21e6771222f2" +} diff --git a/crates/remote/Cargo.toml b/crates/remote/Cargo.toml new file mode 100644 index 00000000..f7fd2be7 --- /dev/null +++ b/crates/remote/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "remote" +version = "0.0.1" +edition = "2024" +publish = false + +[dependencies] +anyhow = { workspace = true } +axum = { workspace = true } +axum-extra = { version = "0.10.3", features = ["typed-header"] } +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +async-trait = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +secrecy = "0.10.3" +sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debug-images"] } +sentry-tracing = { version = "0.41.0", features = ["backtrace"] } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] } +tokio = { workspace = true } +tokio-stream = { version = "0.1.17", features = ["sync"] } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-error = "0.2" +thiserror = { workspace = true } +utils = { path = "../utils" } +uuid = { version = "1", features = ["serde", "v4"] } +jsonwebtoken = "9" +rand = "0.9" +sha2 = "0.10" +url = "2.5" +base64 = "0.22" +hmac = "0.12" +subtle = "2.6" diff --git a/crates/remote/Dockerfile b/crates/remote/Dockerfile new file mode 100644 index 00000000..700f03c1 --- /dev/null +++ b/crates/remote/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1.6 + +ARG APP_NAME=remote + +FROM node:20-alpine AS fe-builder +WORKDIR /repo + +RUN corepack enable + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +COPY frontend/package.json frontend/package.json +COPY remote-frontend/package.json remote-frontend/package.json + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --filter ./remote-frontend --frozen-lockfile + +COPY remote-frontend/ remote-frontend/ + +RUN pnpm -C remote-frontend build + +FROM rust:1.89-slim-bookworm AS builder +ARG APP_NAME + +ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse + +RUN apt-get update \ + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY crates crates +COPY shared shared +COPY assets assets + +RUN mkdir -p /app/bin + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/app/target \ + cargo build --locked --release -p "${APP_NAME}" \ + && cp target/release/${APP_NAME} /app/bin/${APP_NAME} + +FROM debian:bookworm-slim AS runtime +ARG APP_NAME + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libssl3 wget \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --system --create-home --uid 10001 appuser + +WORKDIR /srv + +COPY --from=builder /app/bin/${APP_NAME} /usr/local/bin/${APP_NAME} +COPY --from=fe-builder /repo/remote-frontend/dist /srv/static + +USER appuser + +ENV SERVER_LISTEN_ADDR=0.0.0.0:8081 \ + RUST_LOG=info + +EXPOSE 8081 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["wget","--spider","-q","http://127.0.0.1:8081/health"] + +ENTRYPOINT ["/usr/local/bin/remote"] diff --git a/crates/remote/README.md b/crates/remote/README.md new file mode 100644 index 00000000..2f6ffe8c --- /dev/null +++ b/crates/remote/README.md @@ -0,0 +1,35 @@ +# Remote service + +The `remote` crate contains the implementation of the Vibe Kanban hosted API. + +## Prerequisites + +Create a `.env.remote` file in the repository root: + +```env +VIBEKANBAN_REMOTE_JWT_SECRET=your_base64_encoded_secret +SERVER_PUBLIC_BASE_URL=http://localhost:3000 +GITHUB_OAUTH_CLIENT_ID=your_github_web_app_client_id +GITHUB_OAUTH_CLIENT_SECRET=your_github_web_app_client_secret +GOOGLE_OAUTH_CLIENT_ID=your_google_web_app_client_id +GOOGLE_OAUTH_CLIENT_SECRET=your_google_web_app_client_secret +``` + +Generate `VIBEKANBAN_REMOTE_JWT_SECRET` once using `openssl rand -base64 48` and copy the value into `.env.remote`. + +At least one OAuth provider (GitHub or Google) must be configured. + +## Run the stack locally + +```bash +docker compose --env-file .env.remote -f docker-compose.yml up --build +``` +Exposes the API on `http://localhost:8081`. The Postgres service is available at `postgres://remote:remote@localhost:5432/remote`. + +## Run Vibe Kanban + +```bash +export VK_SHARED_API_BASE=http://localhost:8081 + +pnpm run dev +``` diff --git a/crates/remote/docker-compose.yml b/crates/remote/docker-compose.yml new file mode 100644 index 00000000..6917d9d6 --- /dev/null +++ b/crates/remote/docker-compose.yml @@ -0,0 +1,44 @@ +services: + remote-db: + image: postgres:16-alpine + environment: + POSTGRES_DB: remote + POSTGRES_USER: remote + POSTGRES_PASSWORD: remote + volumes: + - remote-db-data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U remote -d remote" ] + interval: 5s + timeout: 5s + retries: 5 + start_period: 5s + ports: + - "5432:5432" + + remote-server: + build: + context: ../.. + dockerfile: crates/remote/Dockerfile + depends_on: + remote-db: + condition: service_healthy + environment: + SERVER_DATABASE_URL: postgres://remote:remote@remote-db:5432/remote + SERVER_LISTEN_ADDR: 0.0.0.0:8081 + SERVER_ACTIVITY_CHANNEL: activity + GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:?set in .env.remote} + GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:?set in .env.remote} + GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:?set in .env.remote} + GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:?set in .env.remote} + VIBEKANBAN_REMOTE_JWT_SECRET: ${VIBEKANBAN_REMOTE_JWT_SECRET:?set in .env.remote} + LOOPS_EMAIL_API_KEY: ${LOOPS_EMAIL_API_KEY:?set in .env.remote} + SERVER_PUBLIC_BASE_URL: http://localhost:3000 + VITE_APP_BASE_URL: http://localhost:3000 + VITE_API_BASE_URL: http://localhost:3000 + ports: + - "127.0.0.1:3000:8081" + restart: unless-stopped + +volumes: + remote-db-data: diff --git a/crates/remote/migrations/20251001000000_shared_tasks_activity.sql b/crates/remote/migrations/20251001000000_shared_tasks_activity.sql new file mode 100644 index 00000000..11b49fc2 --- /dev/null +++ b/crates/remote/migrations/20251001000000_shared_tasks_activity.sql @@ -0,0 +1,332 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + is_personal BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT, + username TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +DO $$ +BEGIN + CREATE TYPE member_role AS ENUM ('admin', 'member'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +CREATE TABLE IF NOT EXISTS organization_member_metadata ( + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role member_role NOT NULL DEFAULT 'member', + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ, + PRIMARY KEY (organization_id, user_id) + ); + +CREATE INDEX IF NOT EXISTS idx_member_metadata_user + ON organization_member_metadata (user_id); + +CREATE INDEX IF NOT EXISTS idx_member_metadata_org_role + ON organization_member_metadata (organization_id, role); + +DO $$ +BEGIN + CREATE TYPE task_status AS ENUM ('todo', 'in-progress', 'in-review', 'done', 'cancelled'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_projects_org_name + ON projects (organization_id, name); + +CREATE TABLE IF NOT EXISTS project_activity_counters ( + project_id UUID PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + last_seq BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS shared_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + creator_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + deleted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + title TEXT NOT NULL, + description TEXT, + status task_status NOT NULL DEFAULT 'todo'::task_status, + version BIGINT NOT NULL DEFAULT 1, + deleted_at TIMESTAMPTZ, + shared_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_tasks_org_status + ON shared_tasks (organization_id, status); + +CREATE INDEX IF NOT EXISTS idx_tasks_org_assignee + ON shared_tasks (organization_id, assignee_user_id); + +CREATE INDEX IF NOT EXISTS idx_tasks_project + ON shared_tasks (project_id); + +CREATE INDEX IF NOT EXISTS idx_shared_tasks_org_deleted_at + ON shared_tasks (organization_id, deleted_at) + WHERE deleted_at IS NOT NULL; + +-- Partitioned activity feed (24-hour range partitions on created_at). +CREATE TABLE activity ( + seq BIGINT NOT NULL, + event_id UUID NOT NULL DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + assignee_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (created_at, project_id, seq), + UNIQUE (created_at, event_id) +) PARTITION BY RANGE (created_at); + +CREATE INDEX IF NOT EXISTS idx_activity_project_seq + ON activity (project_id, seq DESC); + +-- Create partitions on demand for the 24-hour window that contains target_ts. +CREATE FUNCTION ensure_activity_partition(target_ts TIMESTAMPTZ) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE + bucket_seconds CONSTANT INTEGER := 24 * 60 * 60; + bucket_start TIMESTAMPTZ; + bucket_end TIMESTAMPTZ; + partition_name TEXT; +BEGIN + bucket_start := to_timestamp( + floor(EXTRACT(EPOCH FROM target_ts) / bucket_seconds) * bucket_seconds + ); + bucket_end := bucket_start + INTERVAL '24 hours'; + partition_name := format( + 'activity_p_%s', + to_char(bucket_start AT TIME ZONE 'UTC', 'YYYYMMDD') + ); + + BEGIN + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I PARTITION OF activity FOR VALUES FROM (%L) TO (%L)', + partition_name, + bucket_start, + bucket_end + ); + EXCEPTION + WHEN duplicate_table THEN + NULL; + END; +END; +$$; + +-- Seed partitions for the current and next 2 days (48 hours) for safety. +-- This ensures partitions exist even if cron job fails temporarily. +SELECT ensure_activity_partition(NOW()); +SELECT ensure_activity_partition(NOW() + INTERVAL '24 hours'); +SELECT ensure_activity_partition(NOW() + INTERVAL '48 hours'); + +DO $$ +BEGIN + DROP TRIGGER IF EXISTS trg_activity_notify ON activity; +EXCEPTION + WHEN undefined_object THEN NULL; +END +$$; + +DO $$ +BEGIN + DROP FUNCTION IF EXISTS activity_notify(); +EXCEPTION + WHEN undefined_function THEN NULL; +END +$$; + +CREATE FUNCTION activity_notify() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify( + 'activity', + json_build_object( + 'seq', NEW.seq, + 'event_id', NEW.event_id, + 'project_id', NEW.project_id, + 'event_type', NEW.event_type, + 'created_at', NEW.created_at + )::text + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE TRIGGER trg_activity_notify + AFTER INSERT ON activity + FOR EACH ROW + EXECUTE FUNCTION activity_notify(); + +DO $$ +BEGIN + CREATE TYPE invitation_status AS ENUM ('pending', 'accepted', 'declined', 'expired'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +CREATE TABLE IF NOT EXISTS organization_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + invited_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + email TEXT NOT NULL, + role member_role NOT NULL DEFAULT 'member', + status invitation_status NOT NULL DEFAULT 'pending', + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_org_invites_org + ON organization_invitations (organization_id); + +CREATE INDEX IF NOT EXISTS idx_org_invites_status_expires + ON organization_invitations (status, expires_at); + +CREATE UNIQUE INDEX IF NOT EXISTS uniq_pending_invite_per_email_per_org + ON organization_invitations (organization_id, lower(email)) + WHERE status = 'pending'; + +CREATE TABLE IF NOT EXISTS auth_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_secret_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_auth_sessions_user + ON auth_sessions (user_id); + +CREATE TABLE IF NOT EXISTS oauth_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + email TEXT, + username TEXT, + display_name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (provider, provider_user_id) +); + +CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user + ON oauth_accounts (user_id); + +CREATE INDEX IF NOT EXISTS idx_oauth_accounts_provider_user + ON oauth_accounts (provider, provider_user_id); + +CREATE TABLE IF NOT EXISTS oauth_handoffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider TEXT NOT NULL, + state TEXT NOT NULL, + return_to TEXT NOT NULL, + app_challenge TEXT NOT NULL, + app_code_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error_code TEXT, + expires_at TIMESTAMPTZ NOT NULL, + authorized_at TIMESTAMPTZ, + redeemed_at TIMESTAMPTZ, + user_id UUID REFERENCES users(id), + session_id UUID REFERENCES auth_sessions(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_oauth_handoffs_status + ON oauth_handoffs (status); + +CREATE INDEX IF NOT EXISTS idx_oauth_handoffs_user + ON oauth_handoffs (user_id); + +CREATE TRIGGER trg_organizations_updated_at + BEFORE UPDATE ON organizations + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_shared_tasks_updated_at + BEFORE UPDATE ON shared_tasks + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_org_invites_updated_at + BEFORE UPDATE ON organization_invitations + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_oauth_accounts_updated_at + BEFORE UPDATE ON oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_oauth_handoffs_updated_at + BEFORE UPDATE ON oauth_handoffs + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +CREATE OR REPLACE FUNCTION set_last_used_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.last_used_at = NOW(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_auth_sessions_last_used_at +BEFORE UPDATE ON auth_sessions +FOR EACH ROW +EXECUTE FUNCTION set_last_used_at(); diff --git a/crates/remote/prepare.db b/crates/remote/prepare.db new file mode 100644 index 00000000..81941b03 Binary files /dev/null and b/crates/remote/prepare.db differ diff --git a/crates/remote/scripts/prepare-db.sh b/crates/remote/scripts/prepare-db.sh new file mode 100755 index 00000000..291e48b1 --- /dev/null +++ b/crates/remote/scripts/prepare-db.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Create a temporary data directory +DATA_DIR="$(mktemp -d /tmp/sqlxpg.XXXXXX)" +PORT=54329 + +echo "Killing existing Postgres instance on port $PORT" +pids=$(lsof -t -i :"$PORT" 2>/dev/null || true) +[ -n "$pids" ] && kill $pids 2>/dev/null || true +sleep 1 + +echo "➤ Initializing temporary Postgres cluster..." +initdb -D "$DATA_DIR" > /dev/null + +echo "➤ Starting Postgres on port $PORT..." +pg_ctl -D "$DATA_DIR" -o "-p $PORT" -w start > /dev/null + +# Connection string +export DATABASE_URL="postgres://localhost:$PORT/postgres" + +echo "➤ Running migrations..." +sqlx migrate run + +echo "➤ Preparing SQLx data..." +cargo sqlx prepare + +echo "➤ Stopping Postgres..." +pg_ctl -D "$DATA_DIR" -m fast -w stop > /dev/null + +echo "➤ Cleaning up..." +rm -rf "$DATA_DIR" + +echo "✅ sqlx prepare complete using a temporary Postgres instance" + +echo "Killing existing Postgres instance on port $PORT" +pids=$(lsof -t -i :"$PORT" 2>/dev/null || true) +[ -n "$pids" ] && kill $pids 2>/dev/null || true +sleep 1 \ No newline at end of file diff --git a/crates/remote/src/activity/broker.rs b/crates/remote/src/activity/broker.rs new file mode 100644 index 00000000..42e2fc4b --- /dev/null +++ b/crates/remote/src/activity/broker.rs @@ -0,0 +1,106 @@ +use std::{ + hash::{Hash, Hasher}, + pin::Pin, + sync::Arc, +}; + +use chrono::{DateTime, Utc}; +use futures::{Stream, StreamExt, future}; +use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; +use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ActivityResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActivityEvent { + pub seq: i64, + pub event_id: uuid::Uuid, + pub project_id: uuid::Uuid, + pub event_type: String, + pub created_at: DateTime, + pub payload: Option, +} + +impl ActivityEvent { + pub fn new( + seq: i64, + event_id: uuid::Uuid, + project_id: uuid::Uuid, + event_type: String, + created_at: DateTime, + payload: Option, + ) -> Self { + Self { + seq, + event_id, + project_id, + event_type, + created_at, + payload, + } + } +} + +#[derive(Clone)] +pub struct ActivityBroker { + shards: Arc>>, +} + +pub type ActivityStream = + Pin> + Send + 'static>>; + +impl ActivityBroker { + /// Shard broadcast senders to keep busy organisations from evicting everyone else's events. + pub fn new(shard_count: usize, shard_capacity: usize) -> Self { + let shard_count = shard_count.max(1); + let shard_capacity = shard_capacity.max(1); + let shards = (0..shard_count) + .map(|_| { + let (sender, _receiver) = broadcast::channel(shard_capacity); + sender + }) + .collect(); + + Self { + shards: Arc::new(shards), + } + } + + pub fn subscribe(&self, project_id: uuid::Uuid) -> ActivityStream { + let index = self.shard_index(&project_id); + let receiver = self.shards[index].subscribe(); + + let stream = BroadcastStream::new(receiver).filter_map(move |item| { + future::ready(match item { + Ok(event) if event.project_id == project_id => Some(Ok(event)), + Ok(_) => None, + Err(err) => Some(Err(err)), + }) + }); + + Box::pin(stream) + } + + pub fn publish(&self, event: ActivityEvent) { + let index = self.shard_index(&event.project_id); + if let Err(error) = self.shards[index].send(event) { + tracing::debug!(?error, "no subscribers for activity event"); + } + } + + fn shard_index(&self, project_id: &uuid::Uuid) -> usize { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + project_id.hash(&mut hasher); + (hasher.finish() as usize) % self.shards.len() + } +} + +impl Default for ActivityBroker { + fn default() -> Self { + Self::new(16, 512) + } +} diff --git a/crates/remote/src/activity/mod.rs b/crates/remote/src/activity/mod.rs new file mode 100644 index 00000000..6f1b4397 --- /dev/null +++ b/crates/remote/src/activity/mod.rs @@ -0,0 +1,3 @@ +mod broker; + +pub use broker::{ActivityBroker, ActivityEvent, ActivityResponse, ActivityStream}; diff --git a/crates/remote/src/app.rs b/crates/remote/src/app.rs new file mode 100644 index 00000000..94b29d0c --- /dev/null +++ b/crates/remote/src/app.rs @@ -0,0 +1,116 @@ +use std::{net::SocketAddr, sync::Arc}; + +use anyhow::{Context, bail}; +use tracing::instrument; + +use crate::{ + AppState, + activity::ActivityBroker, + auth::{ + GitHubOAuthProvider, GoogleOAuthProvider, JwtService, OAuthHandoffService, ProviderRegistry, + }, + config::RemoteServerConfig, + db, + mail::LoopsMailer, + routes, +}; + +pub struct Server; + +impl Server { + #[instrument( + name = "remote_server", + skip(config), + fields(listen_addr = %config.listen_addr, activity_channel = %config.activity_channel) + )] + pub async fn run(config: RemoteServerConfig) -> anyhow::Result<()> { + let pool = db::create_pool(&config.database_url) + .await + .context("failed to create postgres pool")?; + + db::migrate(&pool) + .await + .context("failed to run database migrations")?; + + db::maintenance::spawn_activity_partition_maintenance(pool.clone()); + + let broker = ActivityBroker::new( + config.activity_broadcast_shards, + config.activity_broadcast_capacity, + ); + let auth_config = config.auth.clone(); + let jwt = Arc::new(JwtService::new(auth_config.jwt_secret().clone())); + + let mut registry = ProviderRegistry::new(); + + if let Some(github) = auth_config.github() { + registry.register(GitHubOAuthProvider::new( + github.client_id().to_string(), + github.client_secret().clone(), + )?); + } + + if let Some(google) = auth_config.google() { + registry.register(GoogleOAuthProvider::new( + google.client_id().to_string(), + google.client_secret().clone(), + )?); + } + + if registry.is_empty() { + bail!("no OAuth providers configured"); + } + + let registry = Arc::new(registry); + + let handoff_service = Arc::new(OAuthHandoffService::new( + pool.clone(), + registry.clone(), + jwt.clone(), + auth_config.public_base_url().to_string(), + )); + + let api_key = std::env::var("LOOPS_EMAIL_API_KEY") + .context("LOOPS_EMAIL_API_KEY environment variable is required")?; + let mailer = Arc::new(LoopsMailer::new(api_key)); + + let server_public_base_url = config.server_public_base_url.clone().ok_or_else(|| { + anyhow::anyhow!( + "SERVER_PUBLIC_BASE_URL is not set. Please set it in your .env.remote file." + ) + })?; + + let state = AppState::new( + pool.clone(), + broker.clone(), + config.clone(), + jwt, + handoff_service, + mailer, + server_public_base_url, + ); + + let listener = + db::ActivityListener::new(pool.clone(), broker, config.activity_channel.clone()); + tokio::spawn(listener.run()); + + let router = routes::router(state); + let addr: SocketAddr = config + .listen_addr + .parse() + .context("listen address is invalid")?; + let tcp_listener = tokio::net::TcpListener::bind(addr) + .await + .context("failed to bind tcp listener")?; + + tracing::info!(%addr, "shared sync server listening"); + + let make_service = router.into_make_service(); + + axum::serve(tcp_listener, make_service) + .await + .context("shared sync server failure")?; + + Ok(()) + } +} diff --git a/crates/remote/src/auth/handoff.rs b/crates/remote/src/auth/handoff.rs new file mode 100644 index 00000000..21e874cf --- /dev/null +++ b/crates/remote/src/auth/handoff.rs @@ -0,0 +1,579 @@ +use std::{fmt::Write, sync::Arc}; + +use anyhow::Error as AnyhowError; +use chrono::{DateTime, Duration, Utc}; +use rand::{Rng, distr::Alphanumeric}; +use reqwest::StatusCode; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +use super::{ + ProviderRegistry, + jwt::{JwtError, JwtService}, + provider::{AuthorizationGrant, AuthorizationProvider, ProviderUser}, +}; +use crate::{ + configure_user_scope, + db::{ + auth::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION}, + identity_errors::IdentityError, + oauth::{ + AuthorizationStatus, CreateOAuthHandoff, OAuthHandoff, OAuthHandoffError, + OAuthHandoffRepository, + }, + oauth_accounts::{OAuthAccountError, OAuthAccountInsert, OAuthAccountRepository}, + organizations::OrganizationRepository, + users::{UpsertUser, UserRepository}, + }, +}; + +const SESSION_SECRET_LENGTH: usize = 48; +const STATE_LENGTH: usize = 48; +const APP_CODE_LENGTH: usize = 48; +const HANDOFF_TTL: i64 = 10; // minutes +const USER_FETCH_MAX_ATTEMPTS: usize = 5; +const USER_FETCH_RETRY_DELAY_MS: u64 = 500; + +#[derive(Debug, Error)] +pub enum HandoffError { + #[error("unsupported provider `{0}`")] + UnsupportedProvider(String), + #[error("invalid return url `{0}`")] + InvalidReturnUrl(String), + #[error("invalid app verifier challenge")] + InvalidChallenge, + #[error("oauth handoff not found")] + NotFound, + #[error("oauth handoff expired")] + Expired, + #[error("oauth authorization denied")] + Denied, + #[error("oauth authorization failed: {0}")] + Failed(String), + #[error(transparent)] + Provider(#[from] AnyhowError), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Identity(#[from] IdentityError), + #[error(transparent)] + OAuthAccount(#[from] OAuthAccountError), + #[error(transparent)] + Session(#[from] AuthSessionError), + #[error(transparent)] + Jwt(#[from] JwtError), + #[error(transparent)] + Authorization(#[from] OAuthHandoffError), +} + +#[derive(Debug, Clone)] +pub struct HandoffInitResponse { + pub handoff_id: Uuid, + pub authorize_url: String, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone)] +pub enum CallbackResult { + Success { + handoff_id: Uuid, + return_to: String, + app_code: String, + }, + Error { + handoff_id: Option, + return_to: Option, + error: String, + }, +} + +#[derive(Debug, Clone)] +pub struct RedeemResponse { + pub access_token: String, +} + +pub struct OAuthHandoffService { + pool: PgPool, + providers: Arc, + jwt: Arc, + public_origin: String, +} + +impl OAuthHandoffService { + pub fn new( + pool: PgPool, + providers: Arc, + jwt: Arc, + public_origin: String, + ) -> Self { + let trimmed_origin = public_origin.trim_end_matches('/').to_string(); + Self { + pool, + providers, + jwt, + public_origin: trimmed_origin, + } + } + + pub async fn initiate( + &self, + provider: &str, + return_to: &str, + app_challenge: &str, + ) -> Result { + let provider = self + .providers + .get(provider) + .ok_or_else(|| HandoffError::UnsupportedProvider(provider.to_string()))?; + + let return_to_url = + Url::parse(return_to).map_err(|_| HandoffError::InvalidReturnUrl(return_to.into()))?; + if !is_allowed_return_to(&return_to_url, &self.public_origin) { + return Err(HandoffError::InvalidReturnUrl(return_to.into())); + } + + if !is_valid_challenge(app_challenge) { + return Err(HandoffError::InvalidChallenge); + } + + let state = generate_state(); + let expires_at = Utc::now() + Duration::minutes(HANDOFF_TTL); + let repo = OAuthHandoffRepository::new(&self.pool); + let record = repo + .create(CreateOAuthHandoff { + provider: provider.name(), + state: &state, + return_to: return_to_url.as_str(), + app_challenge, + expires_at, + }) + .await?; + + let authorize_url = format!( + "{}/v1/oauth/{}/start?handoff_id={}", + self.public_origin, + provider.name(), + record.id + ); + + Ok(HandoffInitResponse { + handoff_id: record.id, + authorize_url, + expires_at: record.expires_at, + }) + } + + pub async fn authorize_url( + &self, + provider: &str, + handoff_id: Uuid, + ) -> Result { + let provider = self + .providers + .get(provider) + .ok_or_else(|| HandoffError::UnsupportedProvider(provider.to_string()))?; + + let repo = OAuthHandoffRepository::new(&self.pool); + let record = repo.get(handoff_id).await?; + + if record.provider != provider.name() { + return Err(HandoffError::UnsupportedProvider(record.provider)); + } + + if is_expired(&record) { + repo.set_status(record.id, AuthorizationStatus::Expired, Some("expired")) + .await?; + return Err(HandoffError::Expired); + } + + if record.status() != Some(AuthorizationStatus::Pending) { + return Err(HandoffError::Failed("invalid_state".into())); + } + + let redirect_uri = format!( + "{}/v1/oauth/{}/callback", + self.public_origin, + provider.name() + ); + + provider + .authorize_url(&record.state, &redirect_uri) + .map(|url| url.into()) + .map_err(HandoffError::Provider) + } + + pub async fn handle_callback( + &self, + provider_name: &str, + state: Option<&str>, + code: Option<&str>, + error: Option<&str>, + ) -> Result { + let provider = self + .providers + .get(provider_name) + .ok_or_else(|| HandoffError::UnsupportedProvider(provider_name.to_string()))?; + + let Some(state_value) = state else { + return Ok(CallbackResult::Error { + handoff_id: None, + return_to: None, + error: "missing_state".into(), + }); + }; + + let repo = OAuthHandoffRepository::new(&self.pool); + let record = repo.get_by_state(state_value).await?; + + if record.provider != provider.name() { + return Err(HandoffError::UnsupportedProvider(record.provider)); + } + + if is_expired(&record) { + repo.set_status(record.id, AuthorizationStatus::Expired, Some("expired")) + .await?; + return Err(HandoffError::Expired); + } + + if let Some(err_code) = error { + repo.set_status(record.id, AuthorizationStatus::Error, Some(err_code)) + .await?; + return Ok(CallbackResult::Error { + handoff_id: Some(record.id), + return_to: Some(record.return_to.clone()), + error: err_code.to_string(), + }); + } + + let code = code.ok_or_else(|| HandoffError::Failed("missing_code".into()))?; + + let redirect_uri = format!( + "{}/v1/oauth/{}/callback", + self.public_origin, + provider.name() + ); + + let grant = provider + .exchange_code(code, &redirect_uri) + .await + .map_err(HandoffError::Provider)?; + + let user_profile = self.fetch_user_with_retries(&provider, &grant).await?; + + let user = self.upsert_identity(&provider, &user_profile).await?; + let session_repo = AuthSessionRepository::new(&self.pool); + let session_record = session_repo.create(user.id, None).await?; + + let app_code = generate_app_code(); + let app_code_hash = hash_sha256_hex(&app_code); + + repo.mark_authorized(record.id, user.id, session_record.id, &app_code_hash) + .await?; + + configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str())); + + Ok(CallbackResult::Success { + handoff_id: record.id, + return_to: record.return_to, + app_code, + }) + } + + pub async fn redeem( + &self, + handoff_id: Uuid, + app_code: &str, + app_verifier: &str, + ) -> Result { + let repo = OAuthHandoffRepository::new(&self.pool); + repo.ensure_redeemable(handoff_id).await?; + + let record = repo.get(handoff_id).await?; + + if is_expired(&record) { + repo.set_status(record.id, AuthorizationStatus::Expired, Some("expired")) + .await?; + return Err(HandoffError::Expired); + } + + let expected_code_hash = record + .app_code_hash + .ok_or_else(|| HandoffError::Failed("missing_app_code".into()))?; + let provided_hash = hash_sha256_hex(app_code); + if provided_hash != expected_code_hash { + return Err(HandoffError::Failed("invalid_app_code".into())); + } + + let expected_challenge = record.app_challenge; + let provided_challenge = hash_sha256_hex(app_verifier); + if provided_challenge != expected_challenge { + return Err(HandoffError::Failed("invalid_app_verifier".into())); + } + + let session_id = record + .session_id + .ok_or_else(|| HandoffError::Failed("missing_session".into()))?; + let user_id = record + .user_id + .ok_or_else(|| HandoffError::Failed("missing_user".into()))?; + + let session_repo = AuthSessionRepository::new(&self.pool); + let mut session = session_repo.get(session_id).await?; + if session.revoked_at.is_some() { + return Err(HandoffError::Denied); + } + + if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION { + session_repo.revoke(session.id).await?; + return Err(HandoffError::Denied); + } + + let session_secret = generate_session_secret(); + let session_secret_hash = self.jwt.hash_session_secret(&session_secret)?; + session_repo + .update_secret(session.id, &session_secret_hash) + .await?; + session.session_secret_hash = Some(session_secret_hash.clone()); + + let user_repo = UserRepository::new(&self.pool); + let user = user_repo.fetch_user(user_id).await?; + let org_repo = OrganizationRepository::new(&self.pool); + let _organization = org_repo + .ensure_personal_org_and_admin_membership(user.id, user.username.as_deref()) + .await?; + + let token = self.jwt.encode(&session, &user, &session_secret)?; + session_repo.touch(session.id).await?; + repo.mark_redeemed(record.id).await?; + + configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str())); + + Ok(RedeemResponse { + access_token: token, + }) + } + + async fn fetch_user_with_retries( + &self, + provider: &Arc, + grant: &AuthorizationGrant, + ) -> Result { + let mut last_error: Option = None; + for attempt in 1..=USER_FETCH_MAX_ATTEMPTS { + match provider.fetch_user(&grant.access_token).await { + Ok(user) => return Ok(user), + Err(err) => { + let retryable = attempt < USER_FETCH_MAX_ATTEMPTS && is_forbidden_error(&err); + last_error = Some(err); + if retryable { + tokio::time::sleep(std::time::Duration::from_millis( + USER_FETCH_RETRY_DELAY_MS, + )) + .await; + continue; + } + break; + } + } + } + + if let Some(err) = last_error { + Err(HandoffError::Provider(err)) + } else { + Err(HandoffError::Failed("user_fetch_failed".into())) + } + } + + async fn upsert_identity( + &self, + provider: &Arc, + profile: &ProviderUser, + ) -> Result { + let account_repo = OAuthAccountRepository::new(&self.pool); + let user_repo = UserRepository::new(&self.pool); + let org_repo = OrganizationRepository::new(&self.pool); + + let email = ensure_email(provider.name(), profile); + let username = derive_username(provider.name(), profile); + let display_name = derive_display_name(profile); + + let existing_account = account_repo + .get_by_provider_user(provider.name(), &profile.id) + .await?; + + let user_id = match existing_account { + Some(account) => account.user_id, + None => { + if let Some(found) = user_repo.find_user_by_email(&email).await? { + found.id + } else { + Uuid::new_v4() + } + } + }; + + let (first_name, last_name) = split_name(profile.name.as_deref()); + + let user = user_repo + .upsert_user(UpsertUser { + id: user_id, + email: &email, + first_name: first_name.as_deref(), + last_name: last_name.as_deref(), + username: username.as_deref(), + }) + .await?; + + org_repo + .ensure_personal_org_and_admin_membership(user.id, username.as_deref()) + .await?; + + account_repo + .upsert(OAuthAccountInsert { + user_id: user.id, + provider: provider.name(), + provider_user_id: &profile.id, + email: Some(email.as_str()), + username: username.as_deref(), + display_name: display_name.as_deref(), + avatar_url: profile.avatar_url.as_deref(), + }) + .await?; + + Ok(user) + } +} + +type IdentityUser = crate::db::users::User; + +fn is_expired(record: &OAuthHandoff) -> bool { + record.expires_at <= Utc::now() +} + +fn is_valid_challenge(challenge: &str) -> bool { + !challenge.is_empty() + && challenge.len() == 64 + && challenge.chars().all(|ch| ch.is_ascii_hexdigit()) +} + +fn is_allowed_return_to(url: &Url, public_origin: &str) -> bool { + if url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")) + { + return true; + } + + url.scheme() == "https" + && Url::parse(public_origin).ok().is_some_and(|public_url| { + public_url.scheme() == "https" + && public_url.host_str().is_some() + && url.host_str() == public_url.host_str() + }) +} + +fn hash_sha256_hex(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + let mut output = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(output, "{byte:02x}"); + } + output +} + +fn generate_state() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(STATE_LENGTH) + .map(char::from) + .collect() +} + +fn generate_app_code() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(APP_CODE_LENGTH) + .map(char::from) + .collect() +} + +fn generate_session_secret() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(SESSION_SECRET_LENGTH) + .map(char::from) + .collect() +} + +fn ensure_email(provider: &str, profile: &ProviderUser) -> String { + if let Some(email) = profile.email.clone() { + return email; + } + match provider { + "github" => format!("{}@users.noreply.github.com", profile.id), + "google" => format!("{}@users.noreply.google.com", profile.id), + _ => format!("{}@oauth.local", profile.id), + } +} + +fn derive_username(provider: &str, profile: &ProviderUser) -> Option { + if let Some(login) = profile.login.clone() { + return Some(login); + } + if let Some(email) = profile.email.as_deref() { + return email.split('@').next().map(|part| part.to_owned()); + } + Some(format!("{}-{}", provider, profile.id)) +} + +fn derive_display_name(profile: &ProviderUser) -> Option { + profile.name.clone() +} + +fn split_name(name: Option<&str>) -> (Option, Option) { + match name { + Some(value) => { + let mut iter = value.split_whitespace(); + let first = iter.next().map(|s| s.to_string()); + let remainder: Vec<&str> = iter.collect(); + let last = if remainder.is_empty() { + None + } else { + Some(remainder.join(" ")) + }; + (first, last) + } + None => (None, None), + } +} + +fn is_forbidden_error(err: &AnyhowError) -> bool { + err.chain().any(|cause| { + cause + .downcast_ref::() + .and_then(|req_err| req_err.status()) + .map(|status| status == StatusCode::FORBIDDEN) + .unwrap_or(false) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hashes_match_hex_length() { + let output = hash_sha256_hex("example"); + assert_eq!(output.len(), 64); + } + + #[test] + fn challenge_validation() { + assert!(is_valid_challenge( + "0d44b13d0112ff7c94f27f66a701d89f5cb9184160a95cace0bbd10b191ed257" + )); + assert!(!is_valid_challenge("not-hex")); + assert!(!is_valid_challenge("")); + } +} diff --git a/crates/remote/src/auth/jwt.rs b/crates/remote/src/auth/jwt.rs new file mode 100644 index 00000000..4855df81 --- /dev/null +++ b/crates/remote/src/auth/jwt.rs @@ -0,0 +1,122 @@ +use std::{collections::HashSet, sync::Arc}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use chrono::Utc; +use hmac::{Hmac, Mac}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use thiserror::Error; +use uuid::Uuid; + +use crate::db::{auth::AuthSession, users::User}; + +type HmacSha256 = Hmac; + +#[derive(Debug, Error)] +pub enum JwtError { + #[error("invalid token")] + InvalidToken, + #[error("invalid jwt secret")] + InvalidSecret, + #[error(transparent)] + Jwt(#[from] jsonwebtoken::errors::Error), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtClaims { + pub sub: Uuid, + pub session_id: Uuid, + pub nonce: String, + pub iat: i64, +} + +#[derive(Debug, Clone)] +pub struct JwtIdentity { + pub user_id: Uuid, + pub session_id: Uuid, + pub nonce: String, +} + +#[derive(Clone)] +pub struct JwtService { + secret: Arc, +} + +impl JwtService { + pub fn new(secret: SecretString) -> Self { + Self { + secret: Arc::new(secret), + } + } + + pub fn encode( + &self, + session: &AuthSession, + user: &User, + session_secret: &str, + ) -> Result { + let claims = JwtClaims { + sub: user.id, + session_id: session.id, + nonce: session_secret.to_string(), + iat: Utc::now().timestamp(), + }; + + let encoding_key = EncodingKey::from_base64_secret(self.secret.expose_secret())?; + let token = encode(&Header::new(Algorithm::HS256), &claims, &encoding_key)?; + + Ok(token) + } + + pub fn decode(&self, token: &str) -> Result { + if token.trim().is_empty() { + return Err(JwtError::InvalidToken); + } + + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.required_spec_claims = HashSet::from(["sub".to_string()]); + + let decoding_key = DecodingKey::from_base64_secret(self.secret.expose_secret())?; + let data = decode::(token, &decoding_key, &validation)?; + + let claims = data.claims; + Ok(JwtIdentity { + user_id: claims.sub, + session_id: claims.session_id, + nonce: claims.nonce, + }) + } + + fn secret_key_bytes(&self) -> Result, JwtError> { + let raw = self.secret.expose_secret(); + BASE64_STANDARD + .decode(raw.as_bytes()) + .map_err(|_| JwtError::InvalidSecret) + } + + pub fn hash_session_secret(&self, session_secret: &str) -> Result { + let key = self.secret_key_bytes()?; + let mut mac = HmacSha256::new_from_slice(&key).map_err(|_| JwtError::InvalidSecret)?; + mac.update(session_secret.as_bytes()); + let digest = mac.finalize().into_bytes(); + Ok(BASE64_STANDARD.encode(digest)) + } + + pub fn verify_session_secret( + &self, + stored_hash: Option<&str>, + candidate_secret: &str, + ) -> Result { + let stored = match stored_hash { + Some(value) => value, + None => return Ok(false), + }; + let candidate_hash = self.hash_session_secret(candidate_secret)?; + Ok(stored.as_bytes().ct_eq(candidate_hash.as_bytes()).into()) + } +} diff --git a/crates/remote/src/auth/middleware.rs b/crates/remote/src/auth/middleware.rs new file mode 100644 index 00000000..983b7376 --- /dev/null +++ b/crates/remote/src/auth/middleware.rs @@ -0,0 +1,116 @@ +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use axum_extra::headers::{Authorization, HeaderMapExt, authorization::Bearer}; +use chrono::Utc; +use tracing::warn; +use uuid::Uuid; + +use crate::{ + AppState, configure_user_scope, + db::{ + auth::{AuthSessionError, AuthSessionRepository, MAX_SESSION_INACTIVITY_DURATION}, + identity_errors::IdentityError, + users::{User, UserRepository}, + }, +}; + +#[derive(Clone)] +pub struct RequestContext { + pub user: User, + pub session_id: Uuid, + pub session_secret: String, +} + +pub async fn require_session( + State(state): State, + mut req: Request, + next: Next, +) -> Response { + let bearer = match req.headers().typed_get::>() { + Some(Authorization(token)) => token.token().to_owned(), + None => return StatusCode::UNAUTHORIZED.into_response(), + }; + + let jwt = state.jwt(); + let identity = match jwt.decode(&bearer) { + Ok(identity) => identity, + Err(error) => { + warn!(?error, "failed to decode session token"); + return StatusCode::UNAUTHORIZED.into_response(); + } + }; + + let pool = state.pool(); + let session_repo = AuthSessionRepository::new(pool); + let session = match session_repo.get(identity.session_id).await { + Ok(session) => session, + Err(AuthSessionError::NotFound) => { + warn!("session `{}` not found", identity.session_id); + return StatusCode::UNAUTHORIZED.into_response(); + } + Err(AuthSessionError::Database(error)) => { + warn!(?error, "failed to load session"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let secrets_match = jwt + .verify_session_secret(session.session_secret_hash.as_deref(), &identity.nonce) + .unwrap_or(false); + + if session.revoked_at.is_some() || !secrets_match { + warn!( + "session `{}` rejected (revoked or rotated)", + identity.session_id + ); + return StatusCode::UNAUTHORIZED.into_response(); + } + + if session.inactivity_duration(Utc::now()) > MAX_SESSION_INACTIVITY_DURATION { + warn!( + "session `{}` expired due to inactivity; revoking", + identity.session_id + ); + if let Err(error) = session_repo.revoke(session.id).await { + warn!(?error, "failed to revoke inactive session"); + } + return StatusCode::UNAUTHORIZED.into_response(); + } + + let user_repo = UserRepository::new(pool); + let user = match user_repo.fetch_user(identity.user_id).await { + Ok(user) => user, + Err(IdentityError::NotFound) => { + warn!("user `{}` missing", identity.user_id); + return StatusCode::UNAUTHORIZED.into_response(); + } + Err(IdentityError::Database(error)) => { + warn!(?error, "failed to load user"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + Err(_) => { + warn!("unexpected error loading user"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + configure_user_scope(user.id, user.username.as_deref(), Some(user.email.as_str())); + + req.extensions_mut().insert(RequestContext { + user, + session_id: session.id, + session_secret: identity.nonce, + }); + + match session_repo.touch(session.id).await { + Ok(_) => {} + Err(error) => warn!(?error, "failed to update session last-used timestamp"), + } + + next.run(req).await +} diff --git a/crates/remote/src/auth/mod.rs b/crates/remote/src/auth/mod.rs new file mode 100644 index 00000000..46c716a5 --- /dev/null +++ b/crates/remote/src/auth/mod.rs @@ -0,0 +1,9 @@ +mod handoff; +mod jwt; +mod middleware; +mod provider; + +pub use handoff::{CallbackResult, HandoffError, OAuthHandoffService}; +pub use jwt::{JwtError, JwtIdentity, JwtService}; +pub use middleware::{RequestContext, require_session}; +pub use provider::{GitHubOAuthProvider, GoogleOAuthProvider, ProviderRegistry}; diff --git a/crates/remote/src/auth/provider.rs b/crates/remote/src/auth/provider.rs new file mode 100644 index 00000000..835dbcb4 --- /dev/null +++ b/crates/remote/src/auth/provider.rs @@ -0,0 +1,389 @@ +use std::{collections::HashMap, sync::Arc}; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use chrono::Duration; +use reqwest::Client; +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; +use url::Url; + +const USER_AGENT: &str = "VibeKanbanRemote/1.0"; + +#[derive(Debug, Clone)] +pub struct AuthorizationGrant { + pub access_token: SecretString, + pub token_type: String, + pub scopes: Vec, + pub refresh_token: Option, + pub expires_in: Option, + pub id_token: Option, +} + +#[derive(Debug)] +pub struct ProviderUser { + pub id: String, + pub login: Option, + pub email: Option, + pub name: Option, + pub avatar_url: Option, +} + +#[async_trait] +pub trait AuthorizationProvider: Send + Sync { + fn name(&self) -> &'static str; + fn scopes(&self) -> &[&str]; + fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result; + async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result; + async fn fetch_user(&self, access_token: &SecretString) -> Result; +} + +#[derive(Default)] +pub struct ProviderRegistry { + providers: HashMap>, +} + +impl ProviderRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register

(&mut self, provider: P) + where + P: AuthorizationProvider + 'static, + { + let key = provider.name().to_lowercase(); + self.providers.insert(key, Arc::new(provider)); + } + + pub fn get(&self, provider: &str) -> Option> { + let key = provider.to_lowercase(); + self.providers.get(&key).cloned() + } + + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } +} + +pub struct GitHubOAuthProvider { + client: Client, + client_id: String, + client_secret: SecretString, +} + +impl GitHubOAuthProvider { + pub fn new(client_id: String, client_secret: SecretString) -> Result { + let client = Client::builder().user_agent(USER_AGENT).build()?; + Ok(Self { + client, + client_id, + client_secret, + }) + } + + fn parse_scopes(scope: Option) -> Vec { + scope + .unwrap_or_default() + .split(',') + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed.to_string()) + }) + .collect() + } +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum GitHubTokenResponse { + Success { + access_token: String, + scope: Option, + token_type: String, + }, + Error { + error: String, + error_description: Option, + }, +} + +#[derive(Debug, Deserialize)] +struct GitHubUser { + id: i64, + login: String, + email: Option, + name: Option, + avatar_url: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubEmail { + email: String, + primary: bool, + verified: bool, +} + +#[async_trait] +impl AuthorizationProvider for GitHubOAuthProvider { + fn name(&self) -> &'static str { + "github" + } + + fn scopes(&self) -> &[&str] { + &["read:user", "user:email"] + } + + fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result { + let mut url = Url::parse("https://github.com/login/oauth/authorize")?; + { + let mut qp = url.query_pairs_mut(); + qp.append_pair("client_id", &self.client_id); + qp.append_pair("state", state); + qp.append_pair("redirect_uri", redirect_uri); + qp.append_pair("allow_signup", "false"); + qp.append_pair("scope", &self.scopes().join(" ")); + } + Ok(url) + } + + async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result { + let response = self + .client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.expose_secret()), + ("code", code), + ("redirect_uri", redirect_uri), + ]) + .send() + .await? + .error_for_status()?; + + match response.json::().await? { + GitHubTokenResponse::Success { + access_token, + scope, + token_type, + } => Ok(AuthorizationGrant { + access_token: SecretString::new(access_token.into()), + token_type, + scopes: Self::parse_scopes(scope), + refresh_token: None, + expires_in: None, + id_token: None, + }), + GitHubTokenResponse::Error { + error, + error_description, + } => { + let detail = error_description.unwrap_or_else(|| error.clone()); + anyhow::bail!("github token exchange failed: {detail}") + } + } + } + + async fn fetch_user(&self, access_token: &SecretString) -> Result { + let bearer = format!("Bearer {}", access_token.expose_secret()); + + let user: GitHubUser = self + .client + .get("https://api.github.com/user") + .header("Accept", "application/vnd.github+json") + .header("Authorization", &bearer) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let email = if user.email.is_some() { + user.email + } else { + let response = self + .client + .get("https://api.github.com/user/emails") + .header("Accept", "application/vnd.github+json") + .header("Authorization", bearer) + .send() + .await?; + + if response.status().is_success() { + let emails: Vec = response + .json() + .await + .context("failed to parse GitHub email response")?; + emails + .into_iter() + .find(|entry| entry.primary && entry.verified) + .map(|entry| entry.email) + } else { + None + } + }; + + Ok(ProviderUser { + id: user.id.to_string(), + login: Some(user.login), + email, + name: user.name, + avatar_url: user.avatar_url, + }) + } +} + +pub struct GoogleOAuthProvider { + client: Client, + client_id: String, + client_secret: SecretString, +} + +impl GoogleOAuthProvider { + pub fn new(client_id: String, client_secret: SecretString) -> Result { + let client = Client::builder().user_agent(USER_AGENT).build()?; + Ok(Self { + client, + client_id, + client_secret, + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum GoogleTokenResponse { + Success { + access_token: String, + token_type: String, + scope: Option, + expires_in: Option, + refresh_token: Option, + id_token: Option, + }, + Error { + error: String, + error_description: Option, + }, +} + +#[derive(Debug, Deserialize)] +struct GoogleUser { + sub: String, + email: Option, + name: Option, + given_name: Option, + family_name: Option, + picture: Option, +} + +#[async_trait] +impl AuthorizationProvider for GoogleOAuthProvider { + fn name(&self) -> &'static str { + "google" + } + + fn scopes(&self) -> &[&str] { + &["openid", "email", "profile"] + } + + fn authorize_url(&self, state: &str, redirect_uri: &str) -> Result { + let mut url = Url::parse("https://accounts.google.com/o/oauth2/v2/auth")?; + { + let mut qp = url.query_pairs_mut(); + qp.append_pair("client_id", &self.client_id); + qp.append_pair("redirect_uri", redirect_uri); + qp.append_pair("response_type", "code"); + qp.append_pair("scope", &self.scopes().join(" ")); + qp.append_pair("state", state); + qp.append_pair("access_type", "offline"); + qp.append_pair("prompt", "consent"); + } + Ok(url) + } + + async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result { + let response = self + .client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("client_id", self.client_id.as_str()), + ("client_secret", self.client_secret.expose_secret()), + ("code", code), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri), + ]) + .send() + .await? + .error_for_status()?; + + match response.json::().await? { + GoogleTokenResponse::Success { + access_token, + token_type, + scope, + expires_in, + refresh_token, + id_token, + } => { + let scopes = scope + .unwrap_or_default() + .split_whitespace() + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed.to_string()) + }) + .collect(); + + Ok(AuthorizationGrant { + access_token: SecretString::new(access_token.into()), + token_type, + scopes, + refresh_token: refresh_token.map(|v| SecretString::new(v.into())), + expires_in: expires_in.map(Duration::seconds), + id_token: id_token.map(|v| SecretString::new(v.into())), + }) + } + GoogleTokenResponse::Error { + error, + error_description, + } => { + let detail = error_description.unwrap_or_else(|| error.clone()); + anyhow::bail!("google token exchange failed: {detail}") + } + } + } + + async fn fetch_user(&self, access_token: &SecretString) -> Result { + let bearer = format!("Bearer {}", access_token.expose_secret()); + + let profile: GoogleUser = self + .client + .get("https://openidconnect.googleapis.com/v1/userinfo") + .header("Authorization", bearer) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let login = profile.email.clone(); + let name = profile + .name + .or_else(|| match (profile.given_name, profile.family_name) { + (Some(first), Some(last)) => Some(format!("{first} {last}")), + (Some(first), None) => Some(first), + (None, Some(last)) => Some(last), + (None, None) => None, + }); + + Ok(ProviderUser { + id: profile.sub, + login, + email: profile.email, + name, + avatar_url: profile.picture, + }) + } +} diff --git a/crates/remote/src/config.rs b/crates/remote/src/config.rs new file mode 100644 index 00000000..43052074 --- /dev/null +++ b/crates/remote/src/config.rs @@ -0,0 +1,207 @@ +use std::env; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use secrecy::SecretString; +use thiserror::Error; + +// Default activity items returned in a single query +const DEFAULT_ACTIVITY_DEFAULT_LIMIT: i64 = 200; +// Max activity items that can be requested in a single query +const DEFAULT_ACTIVITY_MAX_LIMIT: i64 = 500; +const DEFAULT_ACTIVITY_BROADCAST_SHARDS: usize = 16; +const DEFAULT_ACTIVITY_BROADCAST_CAPACITY: usize = 512; +const DEFAULT_ACTIVITY_CATCHUP_BATCH_SIZE: i64 = 100; + +#[derive(Debug, Clone)] +pub struct RemoteServerConfig { + pub database_url: String, + pub listen_addr: String, + pub server_public_base_url: Option, + pub activity_channel: String, + pub activity_default_limit: i64, + pub activity_max_limit: i64, + pub activity_broadcast_shards: usize, + pub activity_broadcast_capacity: usize, + pub activity_catchup_batch_size: i64, + pub auth: AuthConfig, +} + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("environment variable `{0}` is not set")] + MissingVar(&'static str), + #[error("invalid value for environment variable `{0}`")] + InvalidVar(&'static str), + #[error("no OAuth providers configured")] + NoOAuthProviders, +} + +impl RemoteServerConfig { + pub fn from_env() -> Result { + let database_url = env::var("SERVER_DATABASE_URL") + .or_else(|_| env::var("DATABASE_URL")) + .map_err(|_| ConfigError::MissingVar("SERVER_DATABASE_URL"))?; + + let listen_addr = + env::var("SERVER_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8081".to_string()); + + let server_public_base_url = env::var("SERVER_PUBLIC_BASE_URL").ok(); + + let activity_channel = + env::var("SERVER_ACTIVITY_CHANNEL").unwrap_or_else(|_| "activity".to_string()); + + let activity_default_limit = DEFAULT_ACTIVITY_DEFAULT_LIMIT; + let activity_max_limit = DEFAULT_ACTIVITY_MAX_LIMIT; + + let activity_broadcast_shards = get_numeric_env_var( + "SERVER_ACTIVITY_BROADCAST_SHARDS", + DEFAULT_ACTIVITY_BROADCAST_SHARDS, + )? + .max(1); + + let activity_broadcast_capacity = get_numeric_env_var( + "SERVER_ACTIVITY_BROADCAST_CAPACITY", + DEFAULT_ACTIVITY_BROADCAST_CAPACITY, + )? + .max(1); + + let activity_catchup_batch_size = get_numeric_env_var( + "SERVER_ACTIVITY_CATCHUP_BATCH_SIZE", + DEFAULT_ACTIVITY_CATCHUP_BATCH_SIZE, + )? + .max(1); + + let auth = AuthConfig::from_env()?; + + Ok(Self { + database_url, + listen_addr, + server_public_base_url, + activity_channel, + activity_default_limit, + activity_max_limit, + activity_broadcast_shards, + activity_broadcast_capacity, + activity_catchup_batch_size, + auth, + }) + } +} + +fn get_numeric_env_var( + var_name: &'static str, + default: T, +) -> Result { + match env::var(var_name) { + Ok(value) => value + .parse::() + .map_err(|_| ConfigError::InvalidVar(var_name)), + Err(_) => Ok(default), + } +} + +#[derive(Debug, Clone)] +pub struct OAuthProviderConfig { + client_id: String, + client_secret: SecretString, +} + +impl OAuthProviderConfig { + fn new(client_id: String, client_secret: SecretString) -> Self { + Self { + client_id, + client_secret, + } + } + + pub fn client_id(&self) -> &str { + &self.client_id + } + + pub fn client_secret(&self) -> &SecretString { + &self.client_secret + } +} + +#[derive(Debug, Clone)] +pub struct AuthConfig { + github: Option, + google: Option, + jwt_secret: SecretString, + public_base_url: String, +} + +impl AuthConfig { + fn from_env() -> Result { + let jwt_secret = env::var("VIBEKANBAN_REMOTE_JWT_SECRET") + .map_err(|_| ConfigError::MissingVar("VIBEKANBAN_REMOTE_JWT_SECRET"))?; + validate_jwt_secret(&jwt_secret)?; + let jwt_secret = SecretString::new(jwt_secret.into()); + + let github = match env::var("GITHUB_OAUTH_CLIENT_ID") { + Ok(client_id) => { + let client_secret = env::var("GITHUB_OAUTH_CLIENT_SECRET") + .map_err(|_| ConfigError::MissingVar("GITHUB_OAUTH_CLIENT_SECRET"))?; + Some(OAuthProviderConfig::new( + client_id, + SecretString::new(client_secret.into()), + )) + } + Err(_) => None, + }; + + let google = match env::var("GOOGLE_OAUTH_CLIENT_ID") { + Ok(client_id) => { + let client_secret = env::var("GOOGLE_OAUTH_CLIENT_SECRET") + .map_err(|_| ConfigError::MissingVar("GOOGLE_OAUTH_CLIENT_SECRET"))?; + Some(OAuthProviderConfig::new( + client_id, + SecretString::new(client_secret.into()), + )) + } + Err(_) => None, + }; + + if github.is_none() && google.is_none() { + return Err(ConfigError::NoOAuthProviders); + } + + let public_base_url = + env::var("SERVER_PUBLIC_BASE_URL").unwrap_or_else(|_| "http://localhost:8081".into()); + + Ok(Self { + github, + google, + jwt_secret, + public_base_url, + }) + } + + pub fn github(&self) -> Option<&OAuthProviderConfig> { + self.github.as_ref() + } + + pub fn google(&self) -> Option<&OAuthProviderConfig> { + self.google.as_ref() + } + + pub fn jwt_secret(&self) -> &SecretString { + &self.jwt_secret + } + + pub fn public_base_url(&self) -> &str { + &self.public_base_url + } +} + +fn validate_jwt_secret(secret: &str) -> Result<(), ConfigError> { + let decoded = BASE64_STANDARD + .decode(secret.as_bytes()) + .map_err(|_| ConfigError::InvalidVar("VIBEKANBAN_REMOTE_JWT_SECRET"))?; + + if decoded.len() < 32 { + return Err(ConfigError::InvalidVar("VIBEKANBAN_REMOTE_JWT_SECRET")); + } + + Ok(()) +} diff --git a/crates/remote/src/db/activity.rs b/crates/remote/src/db/activity.rs new file mode 100644 index 00000000..f4ed3462 --- /dev/null +++ b/crates/remote/src/db/activity.rs @@ -0,0 +1,95 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::activity::ActivityEvent; + +pub struct ActivityRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> ActivityRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn fetch_since( + &self, + project_id: Uuid, + after_seq: Option, + limit: i64, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, ActivityRow>( + r#" + SELECT seq, + event_id, + project_id, + event_type, + created_at, + payload + FROM activity + WHERE project_id = $1 + AND ($2::bigint IS NULL OR seq > $2) + ORDER BY seq ASC + LIMIT $3 + "#, + ) + .bind(project_id) + .bind(after_seq) + .bind(limit) + .fetch_all(self.pool) + .await?; + + Ok(rows.into_iter().map(ActivityRow::into_event).collect()) + } + + pub async fn fetch_by_seq( + &self, + project_id: Uuid, + seq: i64, + ) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, ActivityRow>( + r#" + SELECT seq, + event_id, + project_id, + event_type, + created_at, + payload + FROM activity + WHERE project_id = $1 + AND seq = $2 + LIMIT 1 + "#, + ) + .bind(project_id) + .bind(seq) + .fetch_optional(self.pool) + .await?; + + Ok(row.map(ActivityRow::into_event)) + } +} + +#[derive(sqlx::FromRow)] +struct ActivityRow { + seq: i64, + event_id: Uuid, + project_id: Uuid, + event_type: String, + created_at: DateTime, + payload: serde_json::Value, +} + +impl ActivityRow { + fn into_event(self) -> ActivityEvent { + ActivityEvent::new( + self.seq, + self.event_id, + self.project_id, + self.event_type, + self.created_at, + Some(self.payload), + ) + } +} diff --git a/crates/remote/src/db/auth.rs b/crates/remote/src/db/auth.rs new file mode 100644 index 00000000..c368ac27 --- /dev/null +++ b/crates/remote/src/db/auth.rs @@ -0,0 +1,143 @@ +use chrono::{DateTime, Duration, Utc}; +use serde::Serialize; +use sqlx::{PgPool, query_as}; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum AuthSessionError { + #[error("auth session not found")] + NotFound, + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct AuthSession { + pub id: Uuid, + pub user_id: Uuid, + pub session_secret_hash: Option, + pub created_at: DateTime, + pub last_used_at: Option>, + pub revoked_at: Option>, +} + +pub const MAX_SESSION_INACTIVITY_DURATION: Duration = Duration::days(365); + +pub struct AuthSessionRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> AuthSessionRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn create( + &self, + user_id: Uuid, + session_secret_hash: Option<&str>, + ) -> Result { + query_as!( + AuthSession, + r#" + INSERT INTO auth_sessions (user_id, session_secret_hash) + VALUES ($1, $2) + RETURNING + id AS "id!", + user_id AS "user_id!: Uuid", + session_secret_hash AS "session_secret_hash?", + created_at AS "created_at!", + last_used_at AS "last_used_at?", + revoked_at AS "revoked_at?" + "#, + user_id, + session_secret_hash + ) + .fetch_one(self.pool) + .await + .map_err(AuthSessionError::from) + } + + pub async fn get(&self, session_id: Uuid) -> Result { + query_as!( + AuthSession, + r#" + SELECT + id AS "id!", + user_id AS "user_id!: Uuid", + session_secret_hash AS "session_secret_hash?", + created_at AS "created_at!", + last_used_at AS "last_used_at?", + revoked_at AS "revoked_at?" + FROM auth_sessions + WHERE id = $1 + "#, + session_id + ) + .fetch_optional(self.pool) + .await? + .ok_or(AuthSessionError::NotFound) + } + + pub async fn touch(&self, session_id: Uuid) -> Result<(), AuthSessionError> { + sqlx::query!( + r#" + UPDATE auth_sessions + SET last_used_at = date_trunc('day', NOW()) + WHERE id = $1 + AND ( + last_used_at IS NULL + OR last_used_at < date_trunc('day', NOW()) + ) + "#, + session_id + ) + .execute(self.pool) + .await?; + Ok(()) + } + + pub async fn revoke(&self, session_id: Uuid) -> Result<(), AuthSessionError> { + sqlx::query!( + r#" + UPDATE auth_sessions + SET revoked_at = NOW() + WHERE id = $1 + "#, + session_id + ) + .execute(self.pool) + .await?; + Ok(()) + } + + pub async fn update_secret( + &self, + session_id: Uuid, + session_secret_hash: &str, + ) -> Result<(), AuthSessionError> { + sqlx::query!( + r#" + UPDATE auth_sessions + SET session_secret_hash = $2 + WHERE id = $1 + "#, + session_id, + session_secret_hash + ) + .execute(self.pool) + .await?; + Ok(()) + } +} + +impl AuthSession { + pub fn last_activity_at(&self) -> DateTime { + self.last_used_at.unwrap_or(self.created_at) + } + + pub fn inactivity_duration(&self, now: DateTime) -> Duration { + now.signed_duration_since(self.last_activity_at()) + } +} diff --git a/crates/remote/src/db/identity_errors.rs b/crates/remote/src/db/identity_errors.rs new file mode 100644 index 00000000..6572d08e --- /dev/null +++ b/crates/remote/src/db/identity_errors.rs @@ -0,0 +1,17 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum IdentityError { + #[error("identity record not found")] + NotFound, + #[error("permission denied: admin access required")] + PermissionDenied, + #[error("invitation error: {0}")] + InvitationError(String), + #[error("cannot delete organization: {0}")] + CannotDeleteOrganization(String), + #[error("organization conflict: {0}")] + OrganizationConflict(String), + #[error(transparent)] + Database(#[from] sqlx::Error), +} diff --git a/crates/remote/src/db/invitations.rs b/crates/remote/src/db/invitations.rs new file mode 100644 index 00000000..f27e2dcb --- /dev/null +++ b/crates/remote/src/db/invitations.rs @@ -0,0 +1,287 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +pub use utils::api::organizations::InvitationStatus; +use uuid::Uuid; + +use super::{ + identity_errors::IdentityError, + organization_members::{MemberRole, add_member, assert_admin}, + organizations::{Organization, OrganizationRepository}, +}; +use crate::db::organization_members::is_member; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Invitation { + pub id: Uuid, + pub organization_id: Uuid, + pub invited_by_user_id: Option, + pub email: String, + pub role: MemberRole, + pub status: InvitationStatus, + pub token: String, + pub expires_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct InvitationRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> InvitationRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn create_invitation( + &self, + organization_id: Uuid, + invited_by_user_id: Uuid, + email: &str, + role: MemberRole, + expires_at: DateTime, + token: &str, + ) -> Result { + assert_admin(self.pool, organization_id, invited_by_user_id).await?; + + if OrganizationRepository::new(self.pool) + .is_personal(organization_id) + .await? + { + return Err(IdentityError::InvitationError( + "Cannot invite members to a personal organization".to_string(), + )); + } + + let invitation = sqlx::query_as!( + Invitation, + r#" + INSERT INTO organization_invitations ( + organization_id, invited_by_user_id, email, role, token, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING + id AS "id!", + organization_id AS "organization_id!: Uuid", + invited_by_user_id AS "invited_by_user_id?: Uuid", + email AS "email!", + role AS "role!: MemberRole", + status AS "status!: InvitationStatus", + token AS "token!", + expires_at AS "expires_at!", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + organization_id, + invited_by_user_id, + email, + role as MemberRole, + token, + expires_at + ) + .fetch_one(self.pool) + .await + .map_err(|e| { + if let Some(db_err) = e.as_database_error() + && db_err.is_unique_violation() + { + return IdentityError::InvitationError( + "A pending invitation already exists for this email".to_string(), + ); + } + IdentityError::from(e) + })?; + + Ok(invitation) + } + + pub async fn list_invitations( + &self, + organization_id: Uuid, + requesting_user_id: Uuid, + ) -> Result, IdentityError> { + assert_admin(self.pool, organization_id, requesting_user_id).await?; + + if OrganizationRepository::new(self.pool) + .is_personal(organization_id) + .await? + { + return Err(IdentityError::InvitationError( + "Personal organizations do not support invitations".to_string(), + )); + } + + let invitations = sqlx::query_as!( + Invitation, + r#" + SELECT + id AS "id!", + organization_id AS "organization_id!: Uuid", + invited_by_user_id AS "invited_by_user_id?: Uuid", + email AS "email!", + role AS "role!: MemberRole", + status AS "status!: InvitationStatus", + token AS "token!", + expires_at AS "expires_at!", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM organization_invitations + WHERE organization_id = $1 + ORDER BY created_at DESC + "#, + organization_id + ) + .fetch_all(self.pool) + .await?; + + Ok(invitations) + } + + pub async fn get_invitation_by_token(&self, token: &str) -> Result { + sqlx::query_as!( + Invitation, + r#" + SELECT + id AS "id!", + organization_id AS "organization_id!: Uuid", + invited_by_user_id AS "invited_by_user_id?: Uuid", + email AS "email!", + role AS "role!: MemberRole", + status AS "status!: InvitationStatus", + token AS "token!", + expires_at AS "expires_at!", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM organization_invitations + WHERE token = $1 + "#, + token + ) + .fetch_optional(self.pool) + .await? + .ok_or(IdentityError::NotFound) + } + + pub async fn revoke_invitation( + &self, + organization_id: Uuid, + invitation_id: Uuid, + requesting_user_id: Uuid, + ) -> Result<(), IdentityError> { + assert_admin(self.pool, organization_id, requesting_user_id).await?; + + let result = sqlx::query!( + r#" + DELETE FROM organization_invitations + WHERE id = $1 AND organization_id = $2 + "#, + invitation_id, + organization_id + ) + .execute(self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(IdentityError::NotFound); + } + + Ok(()) + } + + pub async fn accept_invitation( + &self, + token: &str, + user_id: Uuid, + ) -> Result<(Organization, MemberRole), IdentityError> { + let mut tx = self.pool.begin().await?; + + let invitation = sqlx::query_as!( + Invitation, + r#" + SELECT + id AS "id!", + organization_id AS "organization_id!: Uuid", + invited_by_user_id AS "invited_by_user_id?: Uuid", + email AS "email!", + role AS "role!: MemberRole", + status AS "status!: InvitationStatus", + token AS "token!", + expires_at AS "expires_at!", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM organization_invitations + WHERE token = $1 AND status = 'pending' + FOR UPDATE + "#, + token + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + IdentityError::InvitationError("Invitation not found or already used".to_string()) + })?; + + if OrganizationRepository::new(self.pool) + .is_personal(invitation.organization_id) + .await? + { + tx.rollback().await?; + return Err(IdentityError::InvitationError( + "Cannot accept invitations for a personal organization".to_string(), + )); + } + + if invitation.expires_at < Utc::now() { + sqlx::query!( + r#" + UPDATE organization_invitations + SET status = 'expired' + WHERE id = $1 + "#, + invitation.id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + return Err(IdentityError::InvitationError( + "Invitation has expired".to_string(), + )); + } + + if is_member(&mut *tx, invitation.organization_id, user_id).await? { + tx.rollback().await?; + return Err(IdentityError::InvitationError( + "You are already a member of the organization".to_string(), + )); + } + + add_member( + &mut *tx, + invitation.organization_id, + user_id, + invitation.role, + ) + .await?; + + sqlx::query!( + r#" + UPDATE organization_invitations + SET status = 'accepted' + WHERE id = $1 + "#, + invitation.id + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + let organization = OrganizationRepository::new(self.pool) + .fetch_organization(invitation.organization_id) + .await?; + + Ok((organization, invitation.role)) + } +} diff --git a/crates/remote/src/db/listener.rs b/crates/remote/src/db/listener.rs new file mode 100644 index 00000000..561d0b48 --- /dev/null +++ b/crates/remote/src/db/listener.rs @@ -0,0 +1,108 @@ +use std::time::Duration; + +use anyhow::Context; +use serde::Deserialize; +use sqlx::{PgPool, postgres::PgListener}; +use tokio::time::sleep; +use tracing::instrument; +use uuid::Uuid; + +use crate::{activity::ActivityBroker, db::activity::ActivityRepository}; + +pub struct ActivityListener { + pool: PgPool, + broker: ActivityBroker, + channel: String, +} + +impl ActivityListener { + pub fn new(pool: PgPool, broker: ActivityBroker, channel: String) -> Self { + Self { + pool, + broker, + channel, + } + } + + #[instrument( + name = "activity.listener", + skip(self), + fields(channel = %self.channel) + )] + pub async fn run(self) { + let mut backoff = Duration::from_secs(1); + let max_backoff = Duration::from_secs(30); + + let pool = self.pool; + let broker = self.broker; + let channel = self.channel; + + loop { + match listen_loop(&pool, &broker, &channel).await { + Ok(_) => { + backoff = Duration::from_secs(1); + } + Err(error) => { + tracing::error!(?error, ?backoff, "activity listener error; retrying"); + sleep(backoff).await; + backoff = (backoff * 2).min(max_backoff); + } + } + } + } +} + +#[instrument( + name = "activity.listen_loop", + skip(pool, broker), + fields(channel = %channel) +)] +async fn listen_loop(pool: &PgPool, broker: &ActivityBroker, channel: &str) -> anyhow::Result<()> { + let mut listener = PgListener::connect_with(pool) + .await + .context("failed to create LISTEN connection")?; + listener + .listen(channel) + .await + .with_context(|| format!("failed to LISTEN on channel {channel}"))?; + + loop { + let notification = listener + .recv() + .await + .context("failed to receive LISTEN notification")?; + + let payload: NotificationEnvelope = serde_json::from_str(notification.payload()) + .with_context(|| format!("invalid notification payload: {}", notification.payload()))?; + + tracing::trace!(%payload.seq, project_id = %payload.project_id, "received activity notification"); + + let project_uuid = payload + .project_id + .parse::() + .with_context(|| format!("invalid project_id UUID: {}", payload.project_id))?; + + let event = match ActivityRepository::new(pool) + .fetch_by_seq(project_uuid, payload.seq) + .await + { + Ok(Some(event)) => event, + Ok(None) => { + tracing::warn!(seq = payload.seq, project_id = %payload.project_id, "activity row missing for notification"); + continue; + } + Err(error) => { + tracing::error!(?error, seq = payload.seq, project_id = %payload.project_id, "failed to fetch activity payload"); + continue; + } + }; + + broker.publish(event); + } +} + +#[derive(Debug, Deserialize)] +struct NotificationEnvelope { + seq: i64, + project_id: String, +} diff --git a/crates/remote/src/db/maintenance.rs b/crates/remote/src/db/maintenance.rs new file mode 100644 index 00000000..717afe86 --- /dev/null +++ b/crates/remote/src/db/maintenance.rs @@ -0,0 +1,159 @@ +use std::{sync::OnceLock, time::Duration}; + +use chrono::{Duration as ChronoDuration, NaiveTime, TimeZone, Utc}; +use sqlx::{PgPool, error::DatabaseError}; +use tokio::time::sleep; +use tracing::{error, info, warn}; + +const PRUNE_LOCK_KEY: &str = "vibe_kanban_activity_retention_v1"; +static PROVISION_TIME: OnceLock = OnceLock::new(); +static PRUNE_TIME: OnceLock = OnceLock::new(); + +fn provision_time() -> NaiveTime { + *PROVISION_TIME.get_or_init(|| NaiveTime::from_hms_opt(0, 10, 0).expect("valid time")) +} + +fn prune_time() -> NaiveTime { + *PRUNE_TIME.get_or_init(|| NaiveTime::from_hms_opt(1, 30, 0).expect("valid time")) +} + +pub fn spawn_activity_partition_maintenance(pool: PgPool) { + let creation_pool = pool.clone(); + tokio::spawn(async move { + if let Err(err) = ensure_future_partitions_with_pool(&creation_pool).await { + error!(error = ?err, "initial activity partition provisioning failed"); + } + + loop { + sleep(duration_until(provision_time())).await; + if let Err(err) = ensure_future_partitions_with_pool(&creation_pool).await { + error!(error = ?err, "scheduled partition provisioning failed"); + } + } + }); + + tokio::spawn(async move { + if let Err(err) = prune_old_partitions(&pool).await { + error!(error = ?err, "initial activity partition pruning failed"); + } + + loop { + sleep(duration_until(prune_time())).await; + if let Err(err) = prune_old_partitions(&pool).await { + error!(error = ?err, "scheduled partition pruning failed"); + } + } + }); +} + +fn duration_until(target_time: NaiveTime) -> Duration { + let now = Utc::now(); + + let today = now.date_naive(); + let mut next = today.and_time(target_time); + + if now.time() >= target_time { + next = (today + ChronoDuration::days(1)).and_time(target_time); + } + + let next_dt = Utc.from_utc_datetime(&next); + (next_dt - now) + .to_std() + .unwrap_or_else(|_| Duration::from_secs(0)) +} + +async fn prune_old_partitions(pool: &PgPool) -> Result<(), sqlx::Error> { + let mut conn = pool.acquire().await?; + + let lock_acquired = sqlx::query_scalar!( + r#" + SELECT pg_try_advisory_lock(hashtextextended($1, 0)) + "#, + PRUNE_LOCK_KEY + ) + .fetch_one(&mut *conn) + .await? + .unwrap_or(false); + + if !lock_acquired { + warn!("skipping partition pruning because another worker holds the lock"); + return Ok(()); + } + + let result = async { + let partitions = sqlx::query!( + r#" + SELECT format('%I.%I', n.nspname, c.relname) AS qualified_name, + split_part( + split_part(pg_get_expr(c.relpartbound, c.oid), ' TO (''', 2), + ''')', 1 + )::timestamptz AS upper_bound + FROM pg_partition_tree('activity') pt + JOIN pg_class c ON c.oid = pt.relid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE pt.isleaf + AND c.relname ~ '^activity_p_\d{8}$' + AND split_part( + split_part(pg_get_expr(c.relpartbound, c.oid), ' TO (''', 2), + ''')', 1 + )::timestamptz <= NOW() - INTERVAL '2 days' + ORDER BY upper_bound + "# + ) + .fetch_all(&mut *conn) + .await?; + + for partition in partitions { + if let Some(name) = partition.qualified_name { + let detach = format!("ALTER TABLE activity DETACH PARTITION {name} CONCURRENTLY"); + sqlx::query(&detach).execute(&mut *conn).await?; + + let drop = format!("DROP TABLE {name}"); + sqlx::query(&drop).execute(&mut *conn).await?; + + info!(partition = %name, "dropped activity partition"); + } + } + + Ok(()) + } + .await; + + let _ = sqlx::query_scalar!( + r#" + SELECT pg_advisory_unlock(hashtextextended($1, 0)) + "#, + PRUNE_LOCK_KEY + ) + .fetch_one(&mut *conn) + .await; + + result +} + +pub async fn ensure_future_partitions_with_pool(pool: &PgPool) -> Result<(), sqlx::Error> { + let mut conn = pool.acquire().await?; + ensure_future_partitions(&mut conn).await +} + +pub async fn ensure_future_partitions( + executor: &mut sqlx::PgConnection, +) -> Result<(), sqlx::Error> { + sqlx::query("SELECT ensure_activity_partition(NOW())") + .execute(&mut *executor) + .await?; + sqlx::query("SELECT ensure_activity_partition(NOW() + INTERVAL '24 hours')") + .execute(&mut *executor) + .await?; + sqlx::query("SELECT ensure_activity_partition(NOW() + INTERVAL '48 hours')") + .execute(&mut *executor) + .await?; + Ok(()) +} + +pub fn is_partition_missing_error(err: &(dyn DatabaseError + Send + Sync + 'static)) -> bool { + err.code() + .as_deref() + .is_some_and(|code| code.starts_with("23")) + && err.message().contains("no partition of relation") +} diff --git a/crates/remote/src/db/mod.rs b/crates/remote/src/db/mod.rs new file mode 100644 index 00000000..b2bfe032 --- /dev/null +++ b/crates/remote/src/db/mod.rs @@ -0,0 +1,29 @@ +pub mod activity; +pub mod auth; +pub mod identity_errors; +pub mod invitations; +pub mod listener; +pub mod maintenance; +pub mod oauth; +pub mod oauth_accounts; +pub mod organization_members; +pub mod organizations; +pub mod projects; +pub mod tasks; +pub mod users; + +pub use listener::ActivityListener; +use sqlx::{PgPool, Postgres, Transaction, migrate::MigrateError, postgres::PgPoolOptions}; + +pub(crate) type Tx<'a> = Transaction<'a, Postgres>; + +pub(crate) async fn migrate(pool: &PgPool) -> Result<(), MigrateError> { + sqlx::migrate!("./migrations").run(pool).await +} + +pub(crate) async fn create_pool(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await +} diff --git a/crates/remote/src/db/oauth.rs b/crates/remote/src/db/oauth.rs new file mode 100644 index 00000000..2d6cce42 --- /dev/null +++ b/crates/remote/src/db/oauth.rs @@ -0,0 +1,285 @@ +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationStatus { + Pending, + Authorized, + Redeemed, + Error, + Expired, +} + +impl AuthorizationStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Authorized => "authorized", + Self::Redeemed => "redeemed", + Self::Error => "error", + Self::Expired => "expired", + } + } +} + +impl FromStr for AuthorizationStatus { + type Err = (); + + fn from_str(input: &str) -> Result { + match input { + "pending" => Ok(Self::Pending), + "authorized" => Ok(Self::Authorized), + "redeemed" => Ok(Self::Redeemed), + "error" => Ok(Self::Error), + "expired" => Ok(Self::Expired), + _ => Err(()), + } + } +} + +#[derive(Debug, Error)] +pub enum OAuthHandoffError { + #[error("oauth handoff not found")] + NotFound, + #[error("oauth handoff is not authorized")] + NotAuthorized, + #[error("oauth handoff already redeemed or not in authorized state")] + AlreadyRedeemed, + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct OAuthHandoff { + pub id: Uuid, + pub provider: String, + pub state: String, + pub return_to: String, + pub app_challenge: String, + pub app_code_hash: Option, + pub status: String, + pub error_code: Option, + pub expires_at: DateTime, + pub authorized_at: Option>, + pub redeemed_at: Option>, + pub user_id: Option, + pub session_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl OAuthHandoff { + pub fn status(&self) -> Option { + AuthorizationStatus::from_str(&self.status).ok() + } +} + +#[derive(Debug, Clone)] +pub struct CreateOAuthHandoff<'a> { + pub provider: &'a str, + pub state: &'a str, + pub return_to: &'a str, + pub app_challenge: &'a str, + pub expires_at: DateTime, +} + +pub struct OAuthHandoffRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> OAuthHandoffRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn create( + &self, + data: CreateOAuthHandoff<'_>, + ) -> Result { + sqlx::query_as!( + OAuthHandoff, + r#" + INSERT INTO oauth_handoffs ( + provider, + state, + return_to, + app_challenge, + expires_at + ) + VALUES ($1, $2, $3, $4, $5) + RETURNING + id AS "id!", + provider AS "provider!", + state AS "state!", + return_to AS "return_to!", + app_challenge AS "app_challenge!", + app_code_hash AS "app_code_hash?", + status AS "status!", + error_code AS "error_code?", + expires_at AS "expires_at!", + authorized_at AS "authorized_at?", + redeemed_at AS "redeemed_at?", + user_id AS "user_id?", + session_id AS "session_id?", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + data.provider, + data.state, + data.return_to, + data.app_challenge, + data.expires_at, + ) + .fetch_one(self.pool) + .await + .map_err(OAuthHandoffError::from) + } + + pub async fn get(&self, id: Uuid) -> Result { + sqlx::query_as!( + OAuthHandoff, + r#" + SELECT + id AS "id!", + provider AS "provider!", + state AS "state!", + return_to AS "return_to!", + app_challenge AS "app_challenge!", + app_code_hash AS "app_code_hash?", + status AS "status!", + error_code AS "error_code?", + expires_at AS "expires_at!", + authorized_at AS "authorized_at?", + redeemed_at AS "redeemed_at?", + user_id AS "user_id?", + session_id AS "session_id?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM oauth_handoffs + WHERE id = $1 + "#, + id + ) + .fetch_optional(self.pool) + .await? + .ok_or(OAuthHandoffError::NotFound) + } + + pub async fn get_by_state(&self, state: &str) -> Result { + sqlx::query_as!( + OAuthHandoff, + r#" + SELECT + id AS "id!", + provider AS "provider!", + state AS "state!", + return_to AS "return_to!", + app_challenge AS "app_challenge!", + app_code_hash AS "app_code_hash?", + status AS "status!", + error_code AS "error_code?", + expires_at AS "expires_at!", + authorized_at AS "authorized_at?", + redeemed_at AS "redeemed_at?", + user_id AS "user_id?", + session_id AS "session_id?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM oauth_handoffs + WHERE state = $1 + "#, + state + ) + .fetch_optional(self.pool) + .await? + .ok_or(OAuthHandoffError::NotFound) + } + + pub async fn set_status( + &self, + id: Uuid, + status: AuthorizationStatus, + error_code: Option<&str>, + ) -> Result<(), OAuthHandoffError> { + sqlx::query!( + r#" + UPDATE oauth_handoffs + SET + status = $2, + error_code = $3 + WHERE id = $1 + "#, + id, + status.as_str(), + error_code + ) + .execute(self.pool) + .await?; + Ok(()) + } + + pub async fn mark_authorized( + &self, + id: Uuid, + user_id: Uuid, + session_id: Uuid, + app_code_hash: &str, + ) -> Result<(), OAuthHandoffError> { + sqlx::query!( + r#" + UPDATE oauth_handoffs + SET + status = 'authorized', + error_code = NULL, + user_id = $2, + session_id = $3, + app_code_hash = $4, + authorized_at = NOW() + WHERE id = $1 + "#, + id, + user_id, + session_id, + app_code_hash + ) + .execute(self.pool) + .await?; + Ok(()) + } + + pub async fn mark_redeemed(&self, id: Uuid) -> Result<(), OAuthHandoffError> { + let result = sqlx::query!( + r#" + UPDATE oauth_handoffs + SET + status = 'redeemed', + redeemed_at = NOW() + WHERE id = $1 + AND status = 'authorized' + "#, + id + ) + .execute(self.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(OAuthHandoffError::AlreadyRedeemed); + } + + Ok(()) + } + + pub async fn ensure_redeemable(&self, id: Uuid) -> Result<(), OAuthHandoffError> { + let handoff = self.get(id).await?; + + match handoff.status() { + Some(AuthorizationStatus::Authorized) => Ok(()), + Some(AuthorizationStatus::Pending) => Err(OAuthHandoffError::NotAuthorized), + _ => Err(OAuthHandoffError::AlreadyRedeemed), + } + } +} diff --git a/crates/remote/src/db/oauth_accounts.rs b/crates/remote/src/db/oauth_accounts.rs new file mode 100644 index 00000000..b938aca0 --- /dev/null +++ b/crates/remote/src/db/oauth_accounts.rs @@ -0,0 +1,153 @@ +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum OAuthAccountError { + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct OAuthAccount { + pub id: Uuid, + pub user_id: Uuid, + pub provider: String, + pub provider_user_id: String, + pub email: Option, + pub username: Option, + pub display_name: Option, + pub avatar_url: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct OAuthAccountInsert<'a> { + pub user_id: Uuid, + pub provider: &'a str, + pub provider_user_id: &'a str, + pub email: Option<&'a str>, + pub username: Option<&'a str>, + pub display_name: Option<&'a str>, + pub avatar_url: Option<&'a str>, +} + +pub struct OAuthAccountRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> OAuthAccountRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn get_by_provider_user( + &self, + provider: &str, + provider_user_id: &str, + ) -> Result, OAuthAccountError> { + sqlx::query_as!( + OAuthAccount, + r#" + SELECT + id AS "id!: Uuid", + user_id AS "user_id!: Uuid", + provider AS "provider!", + provider_user_id AS "provider_user_id!", + email AS "email?", + username AS "username?", + display_name AS "display_name?", + avatar_url AS "avatar_url?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM oauth_accounts + WHERE provider = $1 + AND provider_user_id = $2 + "#, + provider, + provider_user_id + ) + .fetch_optional(self.pool) + .await + .map_err(OAuthAccountError::from) + } + + pub async fn list_by_user( + &self, + user_id: Uuid, + ) -> Result, OAuthAccountError> { + sqlx::query_as!( + OAuthAccount, + r#" + SELECT + id AS "id!: Uuid", + user_id AS "user_id!: Uuid", + provider AS "provider!", + provider_user_id AS "provider_user_id!", + email AS "email?", + username AS "username?", + display_name AS "display_name?", + avatar_url AS "avatar_url?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM oauth_accounts + WHERE user_id = $1 + ORDER BY provider + "#, + user_id + ) + .fetch_all(self.pool) + .await + .map_err(OAuthAccountError::from) + } + + pub async fn upsert( + &self, + account: OAuthAccountInsert<'_>, + ) -> Result { + sqlx::query_as!( + OAuthAccount, + r#" + INSERT INTO oauth_accounts ( + user_id, + provider, + provider_user_id, + email, + username, + display_name, + avatar_url + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (provider, provider_user_id) DO UPDATE + SET + email = EXCLUDED.email, + username = EXCLUDED.username, + display_name = EXCLUDED.display_name, + avatar_url = EXCLUDED.avatar_url + RETURNING + id AS "id!: Uuid", + user_id AS "user_id!: Uuid", + provider AS "provider!", + provider_user_id AS "provider_user_id!", + email AS "email?", + username AS "username?", + display_name AS "display_name?", + avatar_url AS "avatar_url?", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + account.user_id, + account.provider, + account.provider_user_id, + account.email, + account.username, + account.display_name, + account.avatar_url + ) + .fetch_one(self.pool) + .await + .map_err(OAuthAccountError::from) + } +} diff --git a/crates/remote/src/db/organization_members.rs b/crates/remote/src/db/organization_members.rs new file mode 100644 index 00000000..7d2637df --- /dev/null +++ b/crates/remote/src/db/organization_members.rs @@ -0,0 +1,102 @@ +use sqlx::{Executor, PgPool, Postgres}; +pub use utils::api::organizations::MemberRole; +use uuid::Uuid; + +use super::identity_errors::IdentityError; + +pub(super) async fn add_member<'a, E>( + executor: E, + organization_id: Uuid, + user_id: Uuid, + role: MemberRole, +) -> Result<(), sqlx::Error> +where + E: Executor<'a, Database = Postgres>, +{ + sqlx::query!( + r#" + INSERT INTO organization_member_metadata (organization_id, user_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (organization_id, user_id) DO UPDATE + SET role = EXCLUDED.role + "#, + organization_id, + user_id, + role as MemberRole + ) + .execute(executor) + .await?; + + Ok(()) +} + +pub(super) async fn check_user_role( + pool: &PgPool, + organization_id: Uuid, + user_id: Uuid, +) -> Result, IdentityError> { + let result = sqlx::query!( + r#" + SELECT role AS "role!: MemberRole" + FROM organization_member_metadata + WHERE organization_id = $1 AND user_id = $2 + "#, + organization_id, + user_id + ) + .fetch_optional(pool) + .await?; + + Ok(result.map(|r| r.role)) +} + +pub async fn is_member<'a, E>( + executor: E, + organization_id: Uuid, + user_id: Uuid, +) -> Result +where + E: Executor<'a, Database = Postgres>, +{ + let exists = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 + FROM organization_member_metadata + WHERE organization_id = $1 AND user_id = $2 + ) AS "exists!" + "#, + organization_id, + user_id + ) + .fetch_one(executor) + .await?; + + Ok(exists) +} + +pub(crate) async fn assert_membership( + pool: &PgPool, + organization_id: Uuid, + user_id: Uuid, +) -> Result<(), IdentityError> { + let exists = is_member(pool, organization_id, user_id).await?; + + if exists { + Ok(()) + } else { + Err(IdentityError::NotFound) + } +} + +pub(super) async fn assert_admin( + pool: &PgPool, + organization_id: Uuid, + user_id: Uuid, +) -> Result<(), IdentityError> { + let role = check_user_role(pool, organization_id, user_id).await?; + match role { + Some(MemberRole::Admin) => Ok(()), + _ => Err(IdentityError::PermissionDenied), + } +} diff --git a/crates/remote/src/db/organizations.rs b/crates/remote/src/db/organizations.rs new file mode 100644 index 00000000..ccb029b7 --- /dev/null +++ b/crates/remote/src/db/organizations.rs @@ -0,0 +1,332 @@ +use sqlx::{PgPool, query_as}; +pub use utils::api::organizations::{MemberRole, Organization, OrganizationWithRole}; +use uuid::Uuid; + +use super::{ + identity_errors::IdentityError, + organization_members::{ + add_member, assert_admin as check_admin, assert_membership as check_membership, + check_user_role as get_user_role, + }, +}; + +pub struct OrganizationRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> OrganizationRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn assert_membership( + &self, + organization_id: Uuid, + user_id: Uuid, + ) -> Result<(), IdentityError> { + check_membership(self.pool, organization_id, user_id).await + } + + pub async fn fetch_organization( + &self, + organization_id: Uuid, + ) -> Result { + query_as!( + Organization, + r#" + SELECT + id AS "id!: Uuid", + name AS "name!", + slug AS "slug!", + is_personal AS "is_personal!", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM organizations + WHERE id = $1 + "#, + organization_id + ) + .fetch_optional(self.pool) + .await? + .ok_or(IdentityError::NotFound) + } + + pub async fn is_personal(&self, organization_id: Uuid) -> Result { + let result = sqlx::query_scalar!( + r#" + SELECT is_personal + FROM organizations + WHERE id = $1 + "#, + organization_id + ) + .fetch_optional(self.pool) + .await?; + + result.ok_or(IdentityError::NotFound) + } + + pub async fn ensure_personal_org_and_admin_membership( + &self, + user_id: Uuid, + display_name_hint: Option<&str>, + ) -> Result { + let name = personal_org_name(display_name_hint, user_id); + let slug = personal_org_slug(user_id); + + // Try to find existing personal org by slug + let org = find_organization_by_slug(self.pool, &slug).await?; + + let org = match org { + Some(org) => org, + None => { + // Create new personal org (DB will generate random UUID) + create_personal_org(self.pool, &name, &slug).await? + } + }; + + add_member(self.pool, org.id, user_id, MemberRole::Admin).await?; + Ok(org) + } + + pub async fn check_user_role( + &self, + organization_id: Uuid, + user_id: Uuid, + ) -> Result, IdentityError> { + get_user_role(self.pool, organization_id, user_id).await + } + + pub async fn assert_admin( + &self, + organization_id: Uuid, + user_id: Uuid, + ) -> Result<(), IdentityError> { + check_admin(self.pool, organization_id, user_id).await + } + + pub async fn create_organization( + &self, + name: &str, + slug: &str, + creator_user_id: Uuid, + ) -> Result { + let mut tx = self.pool.begin().await?; + + let org = sqlx::query_as!( + Organization, + r#" + INSERT INTO organizations (name, slug) + VALUES ($1, $2) + RETURNING + id AS "id!: Uuid", + name AS "name!", + slug AS "slug!", + is_personal AS "is_personal!", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + name, + slug + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + if let Some(db_err) = e.as_database_error() + && db_err.is_unique_violation() + { + return IdentityError::OrganizationConflict( + "An organization with this slug already exists".to_string(), + ); + } + IdentityError::from(e) + })?; + + add_member(&mut *tx, org.id, creator_user_id, MemberRole::Admin).await?; + + tx.commit().await?; + + Ok(OrganizationWithRole { + id: org.id, + name: org.name, + slug: org.slug, + is_personal: org.is_personal, + created_at: org.created_at, + updated_at: org.updated_at, + user_role: MemberRole::Admin, + }) + } + + pub async fn list_user_organizations( + &self, + user_id: Uuid, + ) -> Result, IdentityError> { + let orgs = sqlx::query_as!( + OrganizationWithRole, + r#" + SELECT + o.id AS "id!: Uuid", + o.name AS "name!", + o.slug AS "slug!", + o.is_personal AS "is_personal!", + o.created_at AS "created_at!", + o.updated_at AS "updated_at!", + m.role AS "user_role!: MemberRole" + FROM organizations o + JOIN organization_member_metadata m ON m.organization_id = o.id + WHERE m.user_id = $1 + ORDER BY o.created_at DESC + "#, + user_id + ) + .fetch_all(self.pool) + .await?; + + Ok(orgs) + } + + pub async fn update_organization_name( + &self, + org_id: Uuid, + user_id: Uuid, + new_name: &str, + ) -> Result { + self.assert_admin(org_id, user_id).await?; + + let org = sqlx::query_as!( + Organization, + r#" + UPDATE organizations + SET name = $2 + WHERE id = $1 + RETURNING + id AS "id!: Uuid", + name AS "name!", + slug AS "slug!", + is_personal AS "is_personal!", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + org_id, + new_name + ) + .fetch_optional(self.pool) + .await? + .ok_or(IdentityError::NotFound)?; + + Ok(org) + } + + pub async fn delete_organization( + &self, + org_id: Uuid, + user_id: Uuid, + ) -> Result<(), IdentityError> { + // First fetch the org to check if it's a personal org + let org = self.fetch_organization(org_id).await?; + + // Check if this is a personal org by flag + if org.is_personal { + return Err(IdentityError::CannotDeleteOrganization( + "Cannot delete personal organizations".to_string(), + )); + } + + let result = sqlx::query!( + r#" + WITH s AS ( + SELECT + COUNT(*) FILTER (WHERE role = 'admin') AS admin_count, + BOOL_OR(user_id = $2 AND role = 'admin') AS is_admin + FROM organization_member_metadata + WHERE organization_id = $1 + ) + DELETE FROM organizations o + USING s + WHERE o.id = $1 + AND s.is_admin = true + AND s.admin_count > 1 + RETURNING o.id + "#, + org_id, + user_id + ) + .fetch_optional(self.pool) + .await?; + + if result.is_none() { + let role = self.check_user_role(org_id, user_id).await?; + match role { + None | Some(MemberRole::Member) => { + return Err(IdentityError::PermissionDenied); + } + Some(MemberRole::Admin) => { + return Err(IdentityError::CannotDeleteOrganization( + "Cannot delete organization: you are the only admin".to_string(), + )); + } + } + } + + Ok(()) + } +} + +async fn find_organization_by_slug( + pool: &PgPool, + slug: &str, +) -> Result, sqlx::Error> { + query_as!( + Organization, + r#" + SELECT + id AS "id!: Uuid", + name AS "name!", + slug AS "slug!", + is_personal AS "is_personal!", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM organizations + WHERE slug = $1 + "#, + slug + ) + .fetch_optional(pool) + .await +} + +async fn create_personal_org( + pool: &PgPool, + name: &str, + slug: &str, +) -> Result { + query_as!( + Organization, + r#" + INSERT INTO organizations (name, slug, is_personal) + VALUES ($1, $2, TRUE) + RETURNING + id AS "id!: Uuid", + name AS "name!", + slug AS "slug!", + is_personal AS "is_personal!", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + name, + slug + ) + .fetch_one(pool) + .await +} + +fn personal_org_name(hint: Option<&str>, user_id: Uuid) -> String { + let user_id_str = user_id.to_string(); + let display_name = hint.unwrap_or(&user_id_str); + format!("{display_name}'s Org") +} + +fn personal_org_slug(user_id: Uuid) -> String { + // Use a deterministic slug pattern so we can find personal orgs + format!("personal-{user_id}") +} diff --git a/crates/remote/src/db/projects.rs b/crates/remote/src/db/projects.rs new file mode 100644 index 00000000..825bfd76 --- /dev/null +++ b/crates/remote/src/db/projects.rs @@ -0,0 +1,190 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::Tx; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + pub metadata: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateProjectData { + pub organization_id: Uuid, + pub name: String, + pub metadata: Value, +} + +#[derive(Debug, Error)] +pub enum ProjectError { + #[error("project conflict: {0}")] + Conflict(String), + #[error("invalid project metadata")] + InvalidMetadata, + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +pub struct ProjectRepository; + +impl ProjectRepository { + pub async fn find_by_id(tx: &mut Tx<'_>, id: Uuid) -> Result, ProjectError> { + let record = sqlx::query!( + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + metadata AS "metadata!: Value", + created_at AS "created_at!: DateTime" + FROM projects + WHERE id = $1 + "#, + id + ) + .fetch_optional(&mut **tx) + .await?; + + Ok(record.map(|row| Project { + id: row.id, + organization_id: row.organization_id, + name: row.name, + metadata: row.metadata, + created_at: row.created_at, + })) + } + + pub async fn insert(tx: &mut Tx<'_>, data: CreateProjectData) -> Result { + let CreateProjectData { + organization_id, + name, + metadata, + } = data; + + let metadata = if metadata.is_null() { + Value::Object(serde_json::Map::new()) + } else if !metadata.is_object() { + return Err(ProjectError::InvalidMetadata); + } else { + metadata + }; + + let record = sqlx::query!( + r#" + INSERT INTO projects ( + organization_id, + name, + metadata + ) + VALUES ($1, $2, $3) + RETURNING + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + metadata AS "metadata!: Value", + created_at AS "created_at!: DateTime" + "#, + organization_id, + name, + metadata + ) + .fetch_one(&mut **tx) + .await + .map_err(ProjectError::from)?; + + Ok(Project { + id: record.id, + organization_id: record.organization_id, + name: record.name, + metadata: record.metadata, + created_at: record.created_at, + }) + } + + pub async fn list_by_organization( + pool: &PgPool, + organization_id: Uuid, + ) -> Result, ProjectError> { + let rows = sqlx::query!( + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + metadata AS "metadata!: Value", + created_at AS "created_at!: DateTime" + FROM projects + WHERE organization_id = $1 + ORDER BY created_at DESC + "#, + organization_id + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| Project { + id: row.id, + organization_id: row.organization_id, + name: row.name, + metadata: row.metadata, + created_at: row.created_at, + }) + .collect()) + } + + pub async fn fetch_by_id( + pool: &PgPool, + project_id: Uuid, + ) -> Result, ProjectError> { + let record = sqlx::query!( + r#" + SELECT + id AS "id!: Uuid", + organization_id AS "organization_id!: Uuid", + name AS "name!", + metadata AS "metadata!: Value", + created_at AS "created_at!: DateTime" + FROM projects + WHERE id = $1 + "#, + project_id + ) + .fetch_optional(pool) + .await?; + + Ok(record.map(|row| Project { + id: row.id, + organization_id: row.organization_id, + name: row.name, + metadata: row.metadata, + created_at: row.created_at, + })) + } + + pub async fn organization_id( + pool: &PgPool, + project_id: Uuid, + ) -> Result, ProjectError> { + sqlx::query_scalar!( + r#" + SELECT organization_id + FROM projects + WHERE id = $1 + "#, + project_id + ) + .fetch_optional(pool) + .await + .map_err(ProjectError::from) + } +} diff --git a/crates/remote/src/db/tasks.rs b/crates/remote/src/db/tasks.rs new file mode 100644 index 00000000..2f27ccfb --- /dev/null +++ b/crates/remote/src/db/tasks.rs @@ -0,0 +1,604 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +use super::{ + Tx, + identity_errors::IdentityError, + projects::{ProjectError, ProjectRepository}, + users::{UserData, fetch_user}, +}; +use crate::db::maintenance; + +pub struct BulkFetchResult { + pub tasks: Vec, + pub deleted_task_ids: Vec, + pub latest_seq: Option, +} + +pub const MAX_SHARED_TASK_TEXT_BYTES: usize = 50 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] +#[serde(rename_all = "kebab-case")] +#[sqlx(type_name = "task_status", rename_all = "kebab-case")] +pub enum TaskStatus { + Todo, + InProgress, + InReview, + Done, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedTaskWithUser { + pub task: SharedTask, + pub user: Option, +} + +impl SharedTaskWithUser { + pub fn new(task: SharedTask, user: Option) -> Self { + Self { task, user } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct SharedTask { + pub id: Uuid, + pub organization_id: Uuid, + pub project_id: Uuid, + pub creator_user_id: Option, + pub assignee_user_id: Option, + pub deleted_by_user_id: Option, + pub title: String, + pub description: Option, + pub status: TaskStatus, + pub version: i64, + pub deleted_at: Option>, + pub shared_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedTaskActivityPayload { + pub task: SharedTask, + pub user: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateSharedTaskData { + pub project_id: Uuid, + pub title: String, + pub description: Option, + pub creator_user_id: Uuid, + pub assignee_user_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct UpdateSharedTaskData { + pub title: Option, + pub description: Option, + pub status: Option, + pub version: Option, + pub acting_user_id: Uuid, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AssignTaskData { + pub new_assignee_user_id: Option, + pub previous_assignee_user_id: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DeleteTaskData { + pub acting_user_id: Uuid, + pub version: Option, +} + +#[derive(Debug, Error)] +pub enum SharedTaskError { + #[error("shared task not found")] + NotFound, + #[error("operation forbidden")] + Forbidden, + #[error("shared task conflict: {0}")] + Conflict(String), + #[error("shared task title and description are too large")] + PayloadTooLarge, + #[error(transparent)] + Project(#[from] ProjectError), + #[error(transparent)] + Identity(#[from] IdentityError), + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + #[error(transparent)] + Serialization(#[from] serde_json::Error), +} + +pub struct SharedTaskRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> SharedTaskRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn find_by_id(&self, task_id: Uuid) -> Result, SharedTaskError> { + let task = sqlx::query_as!( + SharedTask, + r#" + SELECT + id AS "id!", + organization_id AS "organization_id!: Uuid", + project_id AS "project_id!", + creator_user_id AS "creator_user_id?: Uuid", + assignee_user_id AS "assignee_user_id?: Uuid", + deleted_by_user_id AS "deleted_by_user_id?: Uuid", + title AS "title!", + description AS "description?", + status AS "status!: TaskStatus", + version AS "version!", + deleted_at AS "deleted_at?", + shared_at AS "shared_at?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM shared_tasks + WHERE id = $1 + AND deleted_at IS NULL + "#, + task_id + ) + .fetch_optional(self.pool) + .await?; + + Ok(task) + } + + pub async fn create( + &self, + data: CreateSharedTaskData, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(SharedTaskError::from)?; + + let CreateSharedTaskData { + project_id, + title, + description, + creator_user_id, + assignee_user_id, + } = data; + + ensure_text_size(&title, description.as_deref())?; + + let project = ProjectRepository::find_by_id(&mut tx, project_id) + .await? + .ok_or_else(|| { + tracing::warn!(%project_id, "remote project not found when creating shared task"); + SharedTaskError::NotFound + })?; + + let organization_id = project.organization_id; + + let task = sqlx::query_as!( + SharedTask, + r#" + INSERT INTO shared_tasks ( + organization_id, + project_id, + creator_user_id, + assignee_user_id, + title, + description, + shared_at + ) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING id AS "id!", + organization_id AS "organization_id!: Uuid", + project_id AS "project_id!", + creator_user_id AS "creator_user_id?: Uuid", + assignee_user_id AS "assignee_user_id?: Uuid", + deleted_by_user_id AS "deleted_by_user_id?: Uuid", + title AS "title!", + description AS "description?", + status AS "status!: TaskStatus", + version AS "version!", + deleted_at AS "deleted_at?", + shared_at AS "shared_at?", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + organization_id, + project_id, + creator_user_id, + assignee_user_id, + title, + description + ) + .fetch_one(&mut *tx) + .await?; + + let user = match assignee_user_id { + Some(user_id) => fetch_user(&mut tx, user_id).await?, + None => None, + }; + + insert_activity(&mut tx, &task, user.as_ref(), "task.created").await?; + tx.commit().await.map_err(SharedTaskError::from)?; + Ok(SharedTaskWithUser::new(task, user)) + } + + pub async fn bulk_fetch(&self, project_id: Uuid) -> Result { + let mut tx = self.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .execute(&mut *tx) + .await?; + + let rows = sqlx::query!( + r#" + SELECT + st.id AS "id!: Uuid", + st.organization_id AS "organization_id!: Uuid", + st.project_id AS "project_id!: Uuid", + st.creator_user_id AS "creator_user_id?: Uuid", + st.assignee_user_id AS "assignee_user_id?: Uuid", + st.deleted_by_user_id AS "deleted_by_user_id?: Uuid", + st.title AS "title!", + st.description AS "description?", + st.status AS "status!: TaskStatus", + st.version AS "version!", + st.deleted_at AS "deleted_at?", + st.shared_at AS "shared_at?", + st.created_at AS "created_at!", + st.updated_at AS "updated_at!", + u.id AS "user_id?: Uuid", + u.first_name AS "user_first_name?", + u.last_name AS "user_last_name?", + u.username AS "user_username?" + FROM shared_tasks st + LEFT JOIN users u ON st.assignee_user_id = u.id + WHERE st.project_id = $1 + AND st.deleted_at IS NULL + ORDER BY st.updated_at DESC + "#, + project_id + ) + .fetch_all(&mut *tx) + .await?; + + let tasks = rows + .into_iter() + .map(|row| { + let task = SharedTask { + id: row.id, + organization_id: row.organization_id, + project_id: row.project_id, + creator_user_id: row.creator_user_id, + assignee_user_id: row.assignee_user_id, + deleted_by_user_id: row.deleted_by_user_id, + title: row.title, + description: row.description, + status: row.status, + version: row.version, + deleted_at: row.deleted_at, + shared_at: row.shared_at, + created_at: row.created_at, + updated_at: row.updated_at, + }; + + let user = row.user_id.map(|id| UserData { + id, + first_name: row.user_first_name, + last_name: row.user_last_name, + username: row.user_username, + }); + + SharedTaskActivityPayload { task, user } + }) + .collect(); + + let deleted_rows = sqlx::query!( + r#" + SELECT st.id AS "id!: Uuid" + FROM shared_tasks st + WHERE st.project_id = $1 + AND st.deleted_at IS NOT NULL + "#, + project_id + ) + .fetch_all(&mut *tx) + .await?; + + let deleted_task_ids = deleted_rows.into_iter().map(|row| row.id).collect(); + + let latest_seq = sqlx::query_scalar!( + r#" + SELECT MAX(seq) + FROM activity + WHERE project_id = $1 + "#, + project_id + ) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(BulkFetchResult { + tasks, + deleted_task_ids, + latest_seq, + }) + } + + pub async fn update( + &self, + task_id: Uuid, + data: UpdateSharedTaskData, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(SharedTaskError::from)?; + + let task = sqlx::query_as!( + SharedTask, + r#" + UPDATE shared_tasks AS t + SET title = COALESCE($2, t.title), + description = COALESCE($3, t.description), + status = COALESCE($4, t.status), + version = t.version + 1, + updated_at = NOW() + WHERE t.id = $1 + AND t.version = COALESCE($5, t.version) + AND t.assignee_user_id = $6 + AND t.deleted_at IS NULL + RETURNING + t.id AS "id!", + t.organization_id AS "organization_id!: Uuid", + t.project_id AS "project_id!", + t.creator_user_id AS "creator_user_id?: Uuid", + t.assignee_user_id AS "assignee_user_id?: Uuid", + t.deleted_by_user_id AS "deleted_by_user_id?: Uuid", + t.title AS "title!", + t.description AS "description?", + t.status AS "status!: TaskStatus", + t.version AS "version!", + t.deleted_at AS "deleted_at?", + t.shared_at AS "shared_at?", + t.created_at AS "created_at!", + t.updated_at AS "updated_at!" + "#, + task_id, + data.title, + data.description, + data.status as Option, + data.version, + data.acting_user_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| SharedTaskError::Conflict("task version mismatch".to_string()))?; + + ensure_text_size(&task.title, task.description.as_deref())?; + + let user = match task.assignee_user_id { + Some(user_id) => fetch_user(&mut tx, user_id).await?, + None => None, + }; + + insert_activity(&mut tx, &task, user.as_ref(), "task.updated").await?; + tx.commit().await.map_err(SharedTaskError::from)?; + Ok(SharedTaskWithUser::new(task, user)) + } + + pub async fn assign_task( + &self, + task_id: Uuid, + data: AssignTaskData, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(SharedTaskError::from)?; + + let task = sqlx::query_as!( + SharedTask, + r#" + UPDATE shared_tasks AS t + SET assignee_user_id = $2, + version = t.version + 1 + WHERE t.id = $1 + AND t.version = COALESCE($4, t.version) + AND ($3::uuid IS NULL OR t.assignee_user_id = $3::uuid) + AND t.deleted_at IS NULL + RETURNING + t.id AS "id!", + t.organization_id AS "organization_id!: Uuid", + t.project_id AS "project_id!", + t.creator_user_id AS "creator_user_id?: Uuid", + t.assignee_user_id AS "assignee_user_id?: Uuid", + t.deleted_by_user_id AS "deleted_by_user_id?: Uuid", + t.title AS "title!", + t.description AS "description?", + t.status AS "status!: TaskStatus", + t.version AS "version!", + t.deleted_at AS "deleted_at?", + t.shared_at AS "shared_at?", + t.created_at AS "created_at!", + t.updated_at AS "updated_at!" + "#, + task_id, + data.new_assignee_user_id, + data.previous_assignee_user_id, + data.version + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + SharedTaskError::Conflict("task version or previous assignee mismatch".to_string()) + })?; + + let user = match data.new_assignee_user_id { + Some(user_id) => fetch_user(&mut tx, user_id).await?, + None => None, + }; + + insert_activity(&mut tx, &task, user.as_ref(), "task.reassigned").await?; + tx.commit().await.map_err(SharedTaskError::from)?; + Ok(SharedTaskWithUser::new(task, user)) + } + + pub async fn delete_task( + &self, + task_id: Uuid, + data: DeleteTaskData, + ) -> Result { + let mut tx = self.pool.begin().await.map_err(SharedTaskError::from)?; + + let task = sqlx::query_as!( + SharedTask, + r#" + UPDATE shared_tasks AS t + SET deleted_at = NOW(), + deleted_by_user_id = $3, + version = t.version + 1 + WHERE t.id = $1 + AND t.version = COALESCE($2, t.version) + AND t.assignee_user_id = $3 + AND t.deleted_at IS NULL + RETURNING + t.id AS "id!", + t.organization_id AS "organization_id!: Uuid", + t.project_id AS "project_id!", + t.creator_user_id AS "creator_user_id?: Uuid", + t.assignee_user_id AS "assignee_user_id?: Uuid", + t.deleted_by_user_id AS "deleted_by_user_id?: Uuid", + t.title AS "title!", + t.description AS "description?", + t.status AS "status!: TaskStatus", + t.version AS "version!", + t.deleted_at AS "deleted_at?", + t.shared_at AS "shared_at?", + t.created_at AS "created_at!", + t.updated_at AS "updated_at!" + "#, + task_id, + data.version, + data.acting_user_id + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + SharedTaskError::Conflict("task version mismatch or user not authorized".to_string()) + })?; + + insert_activity(&mut tx, &task, None, "task.deleted").await?; + tx.commit().await.map_err(SharedTaskError::from)?; + Ok(SharedTaskWithUser::new(task, None)) + } +} + +pub(crate) fn ensure_text_size( + title: &str, + description: Option<&str>, +) -> Result<(), SharedTaskError> { + let total = title.len() + description.map(|value| value.len()).unwrap_or(0); + + if total > MAX_SHARED_TASK_TEXT_BYTES { + return Err(SharedTaskError::PayloadTooLarge); + } + + Ok(()) +} + +async fn insert_activity( + tx: &mut Tx<'_>, + task: &SharedTask, + user: Option<&UserData>, + event_type: &str, +) -> Result<(), SharedTaskError> { + let payload = SharedTaskActivityPayload { + task: task.clone(), + user: user.cloned(), + }; + let payload = serde_json::to_value(payload).map_err(SharedTaskError::Serialization)?; + + // First attempt at inserting - if partitions are missing we retry after provisioning. + match do_insert_activity(tx, task, event_type, payload.clone()).await { + Ok(_) => Ok(()), + Err(err) => { + if let sqlx::Error::Database(db_err) = &err + && maintenance::is_partition_missing_error(db_err.as_ref()) + { + let code_owned = db_err.code().map(|c| c.to_string()); + let code = code_owned.as_deref().unwrap_or_default(); + tracing::warn!( + "Activity partition missing ({}), creating current and next partitions", + code + ); + + maintenance::ensure_future_partitions(tx.as_mut()) + .await + .map_err(SharedTaskError::from)?; + + return do_insert_activity(tx, task, event_type, payload) + .await + .map_err(SharedTaskError::from); + } + + Err(SharedTaskError::from(err)) + } + } +} + +async fn do_insert_activity( + tx: &mut Tx<'_>, + task: &SharedTask, + event_type: &str, + payload: serde_json::Value, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + WITH next AS ( + INSERT INTO project_activity_counters AS counters (project_id, last_seq) + VALUES ($1, 1) + ON CONFLICT (project_id) + DO UPDATE SET last_seq = counters.last_seq + 1 + RETURNING last_seq + ) + INSERT INTO activity ( + project_id, + seq, + assignee_user_id, + event_type, + payload + ) + SELECT $1, next.last_seq, $2, $3, $4 + FROM next + "#, + task.project_id, + task.assignee_user_id, + event_type, + payload + ) + .execute(&mut **tx) + .await + .map(|_| ()) +} + +impl SharedTaskRepository<'_> { + pub async fn organization_id( + pool: &PgPool, + task_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_scalar!( + r#" + SELECT organization_id + FROM shared_tasks + WHERE id = $1 + "#, + task_id + ) + .fetch_optional(pool) + .await + } +} diff --git a/crates/remote/src/db/users.rs b/crates/remote/src/db/users.rs new file mode 100644 index 00000000..bb344704 --- /dev/null +++ b/crates/remote/src/db/users.rs @@ -0,0 +1,150 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{PgPool, query_as}; +use uuid::Uuid; + +use super::{Tx, identity_errors::IdentityError}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: Uuid, + pub email: String, + pub first_name: Option, + pub last_name: Option, + pub username: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserData { + pub id: Uuid, + pub first_name: Option, + pub last_name: Option, + pub username: Option, +} + +#[derive(Debug, Clone)] +pub struct UpsertUser<'a> { + pub id: Uuid, + pub email: &'a str, + pub first_name: Option<&'a str>, + pub last_name: Option<&'a str>, + pub username: Option<&'a str>, +} + +pub struct UserRepository<'a> { + pool: &'a PgPool, +} + +impl<'a> UserRepository<'a> { + pub fn new(pool: &'a PgPool) -> Self { + Self { pool } + } + + pub async fn upsert_user(&self, user: UpsertUser<'_>) -> Result { + upsert_user(self.pool, &user) + .await + .map_err(IdentityError::from) + } + + pub async fn fetch_user(&self, user_id: Uuid) -> Result { + query_as!( + User, + r#" + SELECT + id AS "id!: Uuid", + email AS "email!", + first_name AS "first_name?", + last_name AS "last_name?", + username AS "username?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM users + WHERE id = $1 + "#, + user_id + ) + .fetch_optional(self.pool) + .await? + .ok_or(IdentityError::NotFound) + } + + pub async fn find_user_by_email(&self, email: &str) -> Result, IdentityError> { + sqlx::query_as!( + User, + r#" + SELECT + id AS "id!: Uuid", + email AS "email!", + first_name AS "first_name?", + last_name AS "last_name?", + username AS "username?", + created_at AS "created_at!", + updated_at AS "updated_at!" + FROM users + WHERE lower(email) = lower($1) + "#, + email + ) + .fetch_optional(self.pool) + .await + .map_err(IdentityError::from) + } +} + +async fn upsert_user(pool: &PgPool, user: &UpsertUser<'_>) -> Result { + query_as!( + User, + r#" + INSERT INTO users (id, email, first_name, last_name, username) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, + username = EXCLUDED.username + RETURNING + id AS "id!: Uuid", + email AS "email!", + first_name AS "first_name?", + last_name AS "last_name?", + username AS "username?", + created_at AS "created_at!", + updated_at AS "updated_at!" + "#, + user.id, + user.email, + user.first_name, + user.last_name, + user.username + ) + .fetch_one(pool) + .await +} + +pub async fn fetch_user(tx: &mut Tx<'_>, user_id: Uuid) -> Result, IdentityError> { + sqlx::query!( + r#" + SELECT + id AS "id!: Uuid", + first_name AS "first_name?", + last_name AS "last_name?", + username AS "username?" + FROM users + WHERE id = $1 + "#, + user_id + ) + .fetch_optional(&mut **tx) + .await + .map_err(IdentityError::from) + .map(|row_opt| { + row_opt.map(|row| UserData { + id: row.id, + first_name: row.first_name, + last_name: row.last_name, + username: row.username, + }) + }) +} diff --git a/crates/remote/src/lib.rs b/crates/remote/src/lib.rs new file mode 100644 index 00000000..08c9b4e4 --- /dev/null +++ b/crates/remote/src/lib.rs @@ -0,0 +1,108 @@ +pub mod activity; +mod app; +mod auth; +pub mod config; +pub mod db; +pub mod mail; +pub mod routes; +mod state; +pub mod ws; + +use std::{env, sync::OnceLock}; + +pub use app::Server; +use sentry_tracing::{EventFilter, SentryLayer}; +pub use state::AppState; +use tracing::Level; +use tracing_error::ErrorLayer; +use tracing_subscriber::{ + fmt::{self, format::FmtSpan}, + layer::{Layer as _, SubscriberExt}, + util::SubscriberInitExt, +}; +pub use ws::message::{ClientMessage, ServerMessage}; + +static INIT_GUARD: OnceLock = OnceLock::new(); + +pub fn init_tracing() { + if tracing::dispatcher::has_been_set() { + return; + } + + let env_filter = env::var("RUST_LOG").unwrap_or_else(|_| "info,sqlx=warn".to_string()); + let fmt_layer = fmt::layer() + .json() + .with_target(false) + .with_span_events(FmtSpan::CLOSE) + .boxed(); + + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new(env_filter)) + .with(ErrorLayer::default()) + .with(fmt_layer) + .with(sentry_layer()) + .init(); +} + +fn environment() -> &'static str { + if cfg!(debug_assertions) { + "dev" + } else { + "production" + } +} + +pub fn sentry_init_once() { + INIT_GUARD.get_or_init(|| { + sentry::init(( + "https://d6e4c45af2b081fadb10fb0ba726ccaf@o4509603705192449.ingest.de.sentry.io/4510305669283920", + sentry::ClientOptions { + release: sentry::release_name!(), + environment: Some(environment().into()), + ..Default::default() + }, + )) + }); + + sentry::configure_scope(|scope| { + scope.set_tag("source", "remote"); + }); +} + +pub fn configure_user_scope(user_id: uuid::Uuid, username: Option<&str>, email: Option<&str>) { + let mut sentry_user = sentry::User { + id: Some(user_id.to_string()), + ..Default::default() + }; + + if let Some(username) = username { + sentry_user.username = Some(username.to_string()); + } + + if let Some(email) = email { + sentry_user.email = Some(email.to_string()); + } + + sentry::configure_scope(|scope| { + scope.set_user(Some(sentry_user)); + }); +} + +fn sentry_layer() -> SentryLayer +where + S: tracing::Subscriber, + S: for<'a> tracing_subscriber::registry::LookupSpan<'a>, +{ + SentryLayer::default() + .span_filter(|meta| { + matches!( + *meta.level(), + Level::DEBUG | Level::INFO | Level::WARN | Level::ERROR + ) + }) + .event_filter(|meta| match *meta.level() { + Level::ERROR => EventFilter::Event, + Level::DEBUG | Level::INFO | Level::WARN => EventFilter::Breadcrumb, + Level::TRACE => EventFilter::Ignore, + }) +} diff --git a/crates/remote/src/mail.rs b/crates/remote/src/mail.rs new file mode 100644 index 00000000..f8616dea --- /dev/null +++ b/crates/remote/src/mail.rs @@ -0,0 +1,96 @@ +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::json; + +use crate::db::organization_members::MemberRole; + +const LOOPS_INVITE_TEMPLATE_ID: &str = "cmhvy2wgs3s13z70i1pxakij9"; + +#[async_trait] +pub trait Mailer: Send + Sync { + async fn send_org_invitation( + &self, + org_name: &str, + email: &str, + accept_url: &str, + role: MemberRole, + invited_by: Option<&str>, + ); +} + +pub struct LoopsMailer { + client: reqwest::Client, + api_key: String, +} + +impl LoopsMailer { + pub fn new(api_key: String) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("failed to build reqwest client"); + + Self { client, api_key } + } +} + +#[async_trait] +impl Mailer for LoopsMailer { + async fn send_org_invitation( + &self, + org_name: &str, + email: &str, + accept_url: &str, + role: MemberRole, + invited_by: Option<&str>, + ) { + let role_str = match role { + MemberRole::Admin => "admin", + MemberRole::Member => "member", + }; + let inviter = invited_by.unwrap_or("someone"); + + if cfg!(debug_assertions) { + tracing::info!( + "Sending invitation email to {email}\n\ + Organization: {org_name}\n\ + Role: {role_str}\n\ + Invited by: {inviter}\n\ + Accept URL: {accept_url}" + ); + } + + let payload = json!({ + "transactionalId": LOOPS_INVITE_TEMPLATE_ID, + "email": email, + "dataVariables": { + "org_name": org_name, + "accept_url": accept_url, + "invited_by": inviter, + } + }); + + let res = self + .client + .post("https://app.loops.so/api/v1/transactional") + .bearer_auth(&self.api_key) + .json(&payload) + .send() + .await; + + match res { + Ok(resp) if resp.status().is_success() => { + tracing::debug!("Invitation email sent via Loops to {email}"); + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::warn!(status = %status, body = %body, "Loops send failed"); + } + Err(err) => { + tracing::error!(error = ?err, "Loops request error"); + } + } + } +} diff --git a/crates/remote/src/main.rs b/crates/remote/src/main.rs new file mode 100644 index 00000000..fe5f1451 --- /dev/null +++ b/crates/remote/src/main.rs @@ -0,0 +1,10 @@ +use remote::{Server, config::RemoteServerConfig, init_tracing, sentry_init_once}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + sentry_init_once(); + init_tracing(); + + let config = RemoteServerConfig::from_env()?; + Server::run(config).await +} diff --git a/crates/remote/src/routes/activity.rs b/crates/remote/src/routes/activity.rs new file mode 100644 index 00000000..0d662623 --- /dev/null +++ b/crates/remote/src/routes/activity.rs @@ -0,0 +1,67 @@ +use axum::{ + Json, Router, + extract::{Extension, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use serde::Deserialize; +use tracing::instrument; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_project_access}; +use crate::{ + AppState, activity::ActivityResponse, auth::RequestContext, db::activity::ActivityRepository, +}; + +pub fn router() -> Router { + Router::new().route("/activity", get(get_activity_stream)) +} + +#[derive(Debug, Deserialize)] +pub struct ActivityQuery { + /// Remote project to stream activity for + pub project_id: Uuid, + /// Fetch events after this ID (exclusive) + pub after: Option, + /// Maximum number of events to return + pub limit: Option, +} + +#[instrument( + name = "activity.get_activity_stream", + skip(state, ctx, params), + fields(user_id = %ctx.user.id, project_id = %params.project_id) +)] +async fn get_activity_stream( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Response { + let config = state.config(); + let limit = params + .limit + .unwrap_or(config.activity_default_limit) + .clamp(1, config.activity_max_limit); + let after = params.after; + let project_id = params.project_id; + + let _organization_id = match ensure_project_access(state.pool(), ctx.user.id, project_id).await + { + Ok(org_id) => org_id, + Err(error) => return error.into_response(), + }; + + let repo = ActivityRepository::new(state.pool()); + match repo.fetch_since(project_id, after, limit).await { + Ok(events) => (StatusCode::OK, Json(ActivityResponse { data: events })).into_response(), + Err(error) => { + tracing::error!(?error, "failed to load activity stream"); + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to load activity stream", + ) + .into_response() + } + } +} diff --git a/crates/remote/src/routes/error.rs b/crates/remote/src/routes/error.rs new file mode 100644 index 00000000..7da7b74f --- /dev/null +++ b/crates/remote/src/routes/error.rs @@ -0,0 +1,120 @@ +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +use crate::db::{identity_errors::IdentityError, projects::ProjectError, tasks::SharedTaskError}; + +#[derive(Debug)] +pub struct ErrorResponse { + status: StatusCode, + message: String, +} + +impl ErrorResponse { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + } + } +} + +impl IntoResponse for ErrorResponse { + fn into_response(self) -> Response { + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} + +pub(crate) fn task_error_response(error: SharedTaskError, context: &str) -> Response { + let response = match error { + SharedTaskError::NotFound => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "task not found" })), + ), + SharedTaskError::Forbidden => ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "only the assignee can modify this task" })), + ), + SharedTaskError::Conflict(message) => { + (StatusCode::CONFLICT, Json(json!({ "error": message }))) + } + SharedTaskError::PayloadTooLarge => ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "title and description cannot exceed 50 KiB combined" + })), + ), + SharedTaskError::Project(ProjectError::Conflict(message)) => { + (StatusCode::CONFLICT, Json(json!({ "error": message }))) + } + SharedTaskError::Project(err) => { + tracing::error!(?err, "{context}", context = context); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal server error" })), + ) + } + SharedTaskError::Identity(err) => return identity_error_response(err, context), + SharedTaskError::Serialization(err) => { + tracing::error!(?err, "{context}", context = context); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to serialize shared task" })), + ) + } + SharedTaskError::Database(err) => { + tracing::error!(?err, "{context}", context = context); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal server error" })), + ) + } + }; + + response.into_response() +} + +pub(crate) fn identity_error_response(error: IdentityError, message: &str) -> Response { + match error { + IdentityError::NotFound => (StatusCode::BAD_REQUEST, Json(json!({ "error": message }))), + IdentityError::PermissionDenied => ( + StatusCode::FORBIDDEN, + Json(json!({ "error": "permission denied" })), + ), + IdentityError::InvitationError(msg) => { + (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))) + } + IdentityError::CannotDeleteOrganization(msg) => { + (StatusCode::CONFLICT, Json(json!({ "error": msg }))) + } + IdentityError::OrganizationConflict(msg) => { + (StatusCode::CONFLICT, Json(json!({ "error": msg }))) + } + IdentityError::Database(err) => { + tracing::error!(?err, "identity sync failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "internal server error" })), + ) + } + } + .into_response() +} + +pub(crate) fn membership_error(error: IdentityError, forbidden_message: &str) -> ErrorResponse { + match error { + IdentityError::NotFound | IdentityError::PermissionDenied => { + ErrorResponse::new(StatusCode::FORBIDDEN, forbidden_message) + } + IdentityError::Database(_) => { + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error") + } + other => { + tracing::warn!(?other, "unexpected membership error"); + ErrorResponse::new(StatusCode::FORBIDDEN, forbidden_message) + } + } +} diff --git a/crates/remote/src/routes/identity.rs b/crates/remote/src/routes/identity.rs new file mode 100644 index 00000000..413f1069 --- /dev/null +++ b/crates/remote/src/routes/identity.rs @@ -0,0 +1,27 @@ +use axum::{Extension, Json, Router, routing::get}; +use serde::{Deserialize, Serialize}; +use tracing::instrument; +use uuid::Uuid; + +use crate::{AppState, auth::RequestContext}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct IdentityResponse { + pub user_id: Uuid, + pub username: Option, + pub email: String, +} + +pub fn router() -> Router { + Router::new().route("/identity", get(get_identity)) +} + +#[instrument(name = "identity.get_identity", skip(ctx), fields(user_id = %ctx.user.id))] +pub async fn get_identity(Extension(ctx): Extension) -> Json { + let user = ctx.user; + Json(IdentityResponse { + user_id: user.id, + username: user.username, + email: user.email, + }) +} diff --git a/crates/remote/src/routes/mod.rs b/crates/remote/src/routes/mod.rs new file mode 100644 index 00000000..e1976ca1 --- /dev/null +++ b/crates/remote/src/routes/mod.rs @@ -0,0 +1,88 @@ +use axum::{ + Router, + http::{Request, header::HeaderName}, + middleware, + routing::get, +}; +use tower_http::{ + cors::CorsLayer, + request_id::{MakeRequestUuid, PropagateRequestIdLayer, RequestId, SetRequestIdLayer}, + services::{ServeDir, ServeFile}, + trace::{DefaultOnFailure, DefaultOnResponse, TraceLayer}, +}; +use tracing::{Level, field}; + +use crate::{AppState, auth::require_session}; + +pub mod activity; +mod error; +mod identity; +mod oauth; +pub(crate) mod organization_members; +mod organizations; +mod projects; +pub mod tasks; + +pub fn router(state: AppState) -> Router { + let trace_layer = TraceLayer::new_for_http() + .make_span_with(|request: &Request<_>| { + let request_id = request + .extensions() + .get::() + .and_then(|id| id.header_value().to_str().ok()); + let span = tracing::info_span!( + "http_request", + method = %request.method(), + uri = %request.uri(), + request_id = field::Empty + ); + if let Some(request_id) = request_id { + span.record("request_id", field::display(request_id)); + } + span + }) + .on_response(DefaultOnResponse::new().level(Level::INFO)) + .on_failure(DefaultOnFailure::new().level(Level::ERROR)); + + let v1_public = Router::::new() + .route("/health", get(health)) + .merge(oauth::public_router()) + .merge(organization_members::public_router()); + + let v1_protected = Router::::new() + .merge(identity::router()) + .merge(activity::router()) + .merge(projects::router()) + .merge(tasks::router()) + .merge(organizations::router()) + .merge(organization_members::protected_router()) + .merge(oauth::protected_router()) + .merge(crate::ws::router()) + .layer(middleware::from_fn_with_state( + state.clone(), + require_session, + )); + + let static_dir = "/srv/static"; + let spa = + ServeDir::new(static_dir).fallback(ServeFile::new(format!("{static_dir}/index.html"))); + + Router::::new() + .nest("/v1", v1_public) + .nest("/v1", v1_protected) + .fallback_service(spa) + .layer(CorsLayer::permissive()) + .layer(trace_layer) + .layer(PropagateRequestIdLayer::new(HeaderName::from_static( + "x-request-id", + ))) + .layer(SetRequestIdLayer::new( + HeaderName::from_static("x-request-id"), + MakeRequestUuid {}, + )) + .with_state(state) +} + +async fn health() -> &'static str { + "ok" +} diff --git a/crates/remote/src/routes/oauth.rs b/crates/remote/src/routes/oauth.rs new file mode 100644 index 00000000..720e67f3 --- /dev/null +++ b/crates/remote/src/routes/oauth.rs @@ -0,0 +1,315 @@ +use std::borrow::Cow; + +use axum::{ + Json, Router, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use serde::Deserialize; +use tracing::warn; +use url::Url; +use utils::api::oauth::{ + HandoffInitRequest, HandoffInitResponse, HandoffRedeemRequest, HandoffRedeemResponse, + ProfileResponse, ProviderProfile, +}; +use uuid::Uuid; + +use crate::{ + AppState, + auth::{CallbackResult, HandoffError, RequestContext}, + db::{oauth::OAuthHandoffError, oauth_accounts::OAuthAccountRepository}, +}; + +pub fn public_router() -> Router { + Router::new() + .route("/oauth/web/init", post(web_init)) + .route("/oauth/web/redeem", post(web_redeem)) + .route("/oauth/{provider}/start", get(authorize_start)) + .route("/oauth/{provider}/callback", get(authorize_callback)) +} + +pub fn protected_router() -> Router { + Router::new() + .route("/profile", get(profile)) + .route("/oauth/logout", post(logout)) +} + +pub async fn web_init( + State(state): State, + Json(payload): Json, +) -> Response { + let handoff = state.handoff(); + + match handoff + .initiate( + &payload.provider, + &payload.return_to, + &payload.app_challenge, + ) + .await + { + Ok(result) => ( + StatusCode::OK, + Json(HandoffInitResponse { + handoff_id: result.handoff_id, + authorize_url: result.authorize_url, + }), + ) + .into_response(), + Err(error) => init_error_response(error), + } +} + +pub async fn web_redeem( + State(state): State, + Json(payload): Json, +) -> Response { + let handoff = state.handoff(); + + match handoff + .redeem(payload.handoff_id, &payload.app_code, &payload.app_verifier) + .await + { + Ok(result) => ( + StatusCode::OK, + Json(HandoffRedeemResponse { + access_token: result.access_token, + }), + ) + .into_response(), + Err(error) => redeem_error_response(error), + } +} + +#[derive(Debug, Deserialize)] +pub struct StartQuery { + handoff_id: Uuid, +} + +pub async fn authorize_start( + State(state): State, + Path(provider): Path, + Query(query): Query, +) -> Response { + let handoff = state.handoff(); + + match handoff.authorize_url(&provider, query.handoff_id).await { + Ok(url) => Redirect::temporary(&url).into_response(), + Err(error) => { + let (status, message) = classify_handoff_error(&error); + ( + status, + format!("OAuth authorization failed: {}", message.into_owned()), + ) + .into_response() + } + } +} + +#[derive(Debug, Deserialize)] +pub struct CallbackQuery { + state: Option, + code: Option, + error: Option, +} + +pub async fn authorize_callback( + State(state): State, + Path(provider): Path, + Query(query): Query, +) -> Response { + let handoff = state.handoff(); + + match handoff + .handle_callback( + &provider, + query.state.as_deref(), + query.code.as_deref(), + query.error.as_deref(), + ) + .await + { + Ok(CallbackResult::Success { + handoff_id, + return_to, + app_code, + }) => match append_query_params(&return_to, Some(handoff_id), Some(&app_code), None) { + Ok(url) => Redirect::temporary(url.as_str()).into_response(), + Err(err) => ( + StatusCode::BAD_REQUEST, + format!("Invalid return_to URL: {err}"), + ) + .into_response(), + }, + Ok(CallbackResult::Error { + handoff_id, + return_to, + error, + }) => { + if let Some(url) = return_to { + match append_query_params(&url, handoff_id, None, Some(&error)) { + Ok(url) => Redirect::temporary(url.as_str()).into_response(), + Err(err) => ( + StatusCode::BAD_REQUEST, + format!("Invalid return_to URL: {err}"), + ) + .into_response(), + } + } else { + ( + StatusCode::BAD_REQUEST, + format!("OAuth authorization failed: {error}"), + ) + .into_response() + } + } + Err(error) => { + let (status, message) = classify_handoff_error(&error); + ( + status, + format!("OAuth authorization failed: {}", message.into_owned()), + ) + .into_response() + } + } +} + +pub async fn profile( + State(state): State, + Extension(ctx): Extension, +) -> Json { + let repo = OAuthAccountRepository::new(state.pool()); + let providers = repo + .list_by_user(ctx.user.id) + .await + .unwrap_or_default() + .into_iter() + .map(|account| ProviderProfile { + provider: account.provider, + username: account.username, + display_name: account.display_name, + email: account.email, + avatar_url: account.avatar_url, + }) + .collect(); + + Json(ProfileResponse { + user_id: ctx.user.id, + username: ctx.user.username.clone(), + email: ctx.user.email.clone(), + providers, + }) +} + +pub async fn logout( + State(state): State, + Extension(ctx): Extension, +) -> Response { + use crate::db::auth::{AuthSessionError, AuthSessionRepository}; + + let repo = AuthSessionRepository::new(state.pool()); + + match repo.revoke(ctx.session_id).await { + Ok(_) | Err(AuthSessionError::NotFound) => StatusCode::NO_CONTENT.into_response(), + Err(AuthSessionError::Database(error)) => { + warn!(?error, session_id = %ctx.session_id, "failed to revoke auth session"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +fn init_error_response(error: HandoffError) -> Response { + match &error { + HandoffError::Provider(err) => warn!(?err, "provider error during oauth init"), + HandoffError::Database(err) => warn!(?err, "database error during oauth init"), + HandoffError::Authorization(err) => warn!(?err, "authorization error during oauth init"), + HandoffError::Identity(err) => warn!(?err, "identity error during oauth init"), + HandoffError::OAuthAccount(err) => warn!(?err, "account error during oauth init"), + _ => {} + } + + let (status, code) = classify_handoff_error(&error); + let code = code.into_owned(); + (status, Json(serde_json::json!({ "error": code }))).into_response() +} + +fn redeem_error_response(error: HandoffError) -> Response { + match &error { + HandoffError::Provider(err) => warn!(?err, "provider error during oauth redeem"), + HandoffError::Database(err) => warn!(?err, "database error during oauth redeem"), + HandoffError::Authorization(err) => warn!(?err, "authorization error during oauth redeem"), + HandoffError::Identity(err) => warn!(?err, "identity error during oauth redeem"), + HandoffError::OAuthAccount(err) => warn!(?err, "account error during oauth redeem"), + HandoffError::Session(err) => warn!(?err, "session error during oauth redeem"), + HandoffError::Jwt(err) => warn!(?err, "jwt error during oauth redeem"), + _ => {} + } + + let (status, code) = classify_handoff_error(&error); + let code = code.into_owned(); + + (status, Json(serde_json::json!({ "error": code }))).into_response() +} + +fn classify_handoff_error(error: &HandoffError) -> (StatusCode, Cow<'_, str>) { + match error { + HandoffError::UnsupportedProvider(_) => ( + StatusCode::BAD_REQUEST, + Cow::Borrowed("unsupported_provider"), + ), + HandoffError::InvalidReturnUrl(_) => { + (StatusCode::BAD_REQUEST, Cow::Borrowed("invalid_return_url")) + } + HandoffError::InvalidChallenge => { + (StatusCode::BAD_REQUEST, Cow::Borrowed("invalid_challenge")) + } + HandoffError::NotFound => (StatusCode::NOT_FOUND, Cow::Borrowed("not_found")), + HandoffError::Expired => (StatusCode::GONE, Cow::Borrowed("expired")), + HandoffError::Denied => (StatusCode::FORBIDDEN, Cow::Borrowed("access_denied")), + HandoffError::Failed(reason) => (StatusCode::BAD_REQUEST, Cow::Owned(reason.clone())), + HandoffError::Provider(_) => (StatusCode::BAD_GATEWAY, Cow::Borrowed("provider_error")), + HandoffError::Database(_) + | HandoffError::Identity(_) + | HandoffError::OAuthAccount(_) + | HandoffError::Session(_) + | HandoffError::Jwt(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Cow::Borrowed("internal_error"), + ), + HandoffError::Authorization(auth_err) => match auth_err { + OAuthHandoffError::NotAuthorized => (StatusCode::GONE, Cow::Borrowed("not_authorized")), + OAuthHandoffError::AlreadyRedeemed => { + (StatusCode::GONE, Cow::Borrowed("already_redeemed")) + } + OAuthHandoffError::NotFound => (StatusCode::NOT_FOUND, Cow::Borrowed("not_found")), + OAuthHandoffError::Database(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Cow::Borrowed("internal_error"), + ), + }, + } +} + +fn append_query_params( + base: &str, + handoff_id: Option, + app_code: Option<&str>, + error: Option<&str>, +) -> Result { + let mut url = Url::parse(base)?; + { + let mut qp = url.query_pairs_mut(); + if let Some(id) = handoff_id { + qp.append_pair("handoff_id", &id.to_string()); + } + if let Some(code) = app_code { + qp.append_pair("app_code", code); + } + if let Some(error) = error { + qp.append_pair("error", error); + } + } + Ok(url) +} diff --git a/crates/remote/src/routes/organization_members.rs b/crates/remote/src/routes/organization_members.rs new file mode 100644 index 00000000..52eac447 --- /dev/null +++ b/crates/remote/src/routes/organization_members.rs @@ -0,0 +1,601 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, +}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use tracing::warn; +use utils::api::organizations::{ + ListMembersResponse, OrganizationMemberWithProfile, RevokeInvitationRequest, + UpdateMemberRoleRequest, UpdateMemberRoleResponse, +}; +use uuid::Uuid; + +use super::error::{ErrorResponse, membership_error}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + identity_errors::IdentityError, + invitations::{Invitation, InvitationRepository}, + organization_members::{self, MemberRole}, + organizations::OrganizationRepository, + projects::ProjectRepository, + tasks::SharedTaskRepository, + }, +}; + +pub fn public_router() -> Router { + Router::new().route("/invitations/{token}", get(get_invitation)) +} + +pub fn protected_router() -> Router { + Router::new() + .route( + "/organizations/{org_id}/invitations", + post(create_invitation), + ) + .route("/organizations/{org_id}/invitations", get(list_invitations)) + .route( + "/organizations/{org_id}/invitations/revoke", + post(revoke_invitation), + ) + .route("/invitations/{token}/accept", post(accept_invitation)) + .route("/organizations/{org_id}/members", get(list_members)) + .route( + "/organizations/{org_id}/members/{user_id}", + delete(remove_member), + ) + .route( + "/organizations/{org_id}/members/{user_id}/role", + patch(update_member_role), + ) +} + +#[derive(Debug, Deserialize)] +pub struct CreateInvitationRequest { + pub email: String, + pub role: MemberRole, +} + +#[derive(Debug, Serialize)] +pub struct CreateInvitationResponse { + pub invitation: Invitation, +} + +#[derive(Debug, Serialize)] +pub struct ListInvitationsResponse { + pub invitations: Vec, +} + +#[derive(Debug, Serialize)] +pub struct GetInvitationResponse { + pub id: Uuid, + pub organization_slug: String, + pub organization_name: String, + pub role: MemberRole, + pub expires_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct AcceptInvitationResponse { + pub organization_id: String, + pub organization_slug: String, + pub role: MemberRole, +} + +pub async fn create_invitation( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, + Json(payload): Json, +) -> Result { + let user = ctx.user; + let org_repo = OrganizationRepository::new(&state.pool); + let invitation_repo = InvitationRepository::new(&state.pool); + + ensure_admin_access(&state.pool, org_id, user.id).await?; + + let token = Uuid::new_v4().to_string(); + let expires_at = Utc::now() + Duration::days(7); + + let invitation = invitation_repo + .create_invitation( + org_id, + user.id, + &payload.email, + payload.role, + expires_at, + &token, + ) + .await + .map_err(|e| match e { + IdentityError::PermissionDenied => { + ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") + } + IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg), + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + let organization = org_repo.fetch_organization(org_id).await.map_err(|_| { + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch organization", + ) + })?; + + let accept_url = format!( + "{}/invitations/{}/accept", + state.server_public_base_url, token + ); + state + .mailer + .send_org_invitation( + &organization.name, + &payload.email, + &accept_url, + payload.role, + user.username.as_deref(), + ) + .await; + + Ok(( + StatusCode::CREATED, + Json(CreateInvitationResponse { invitation }), + )) +} + +pub async fn list_invitations( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, +) -> Result { + let user = ctx.user; + let invitation_repo = InvitationRepository::new(&state.pool); + + ensure_admin_access(&state.pool, org_id, user.id).await?; + + let invitations = invitation_repo + .list_invitations(org_id, user.id) + .await + .map_err(|e| match e { + IdentityError::PermissionDenied => { + ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") + } + IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg), + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + Ok(Json(ListInvitationsResponse { invitations })) +} + +pub async fn get_invitation( + State(state): State, + Path(token): Path, +) -> Result { + let invitation_repo = InvitationRepository::new(&state.pool); + + let invitation = invitation_repo + .get_invitation_by_token(&token) + .await + .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, "Invitation not found"))?; + + let org_repo = OrganizationRepository::new(&state.pool); + let org = org_repo + .fetch_organization(invitation.organization_id) + .await + .map_err(|_| { + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch organization", + ) + })?; + + Ok(Json(GetInvitationResponse { + id: invitation.id, + organization_slug: org.slug, + organization_name: org.name, + role: invitation.role, + expires_at: invitation.expires_at, + })) +} + +pub async fn revoke_invitation( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, + Json(payload): Json, +) -> Result { + let user = ctx.user; + let invitation_repo = InvitationRepository::new(&state.pool); + + ensure_admin_access(&state.pool, org_id, user.id).await?; + + invitation_repo + .revoke_invitation(org_id, payload.invitation_id, user.id) + .await + .map_err(|e| match e { + IdentityError::PermissionDenied => { + ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") + } + IdentityError::NotFound => { + ErrorResponse::new(StatusCode::NOT_FOUND, "Invitation not found") + } + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn accept_invitation( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(token): Path, +) -> Result { + let user = ctx.user; + let invitation_repo = InvitationRepository::new(&state.pool); + + let (org, role) = invitation_repo + .accept_invitation(&token, user.id) + .await + .map_err(|e| match e { + IdentityError::InvitationError(msg) => ErrorResponse::new(StatusCode::BAD_REQUEST, msg), + IdentityError::NotFound => { + ErrorResponse::new(StatusCode::NOT_FOUND, "Invitation not found") + } + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + Ok(Json(AcceptInvitationResponse { + organization_id: org.id.to_string(), + organization_slug: org.slug, + role, + })) +} + +pub async fn list_members( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, +) -> Result { + let user = ctx.user; + ensure_member_access(&state.pool, org_id, user.id).await?; + + let members = sqlx::query_as!( + OrganizationMemberWithProfile, + r#" + SELECT + omm.user_id AS "user_id!: Uuid", + omm.role AS "role!: MemberRole", + omm.joined_at AS "joined_at!", + u.first_name AS "first_name?", + u.last_name AS "last_name?", + u.username AS "username?", + u.email AS "email?", + oa.avatar_url AS "avatar_url?" + FROM organization_member_metadata omm + INNER JOIN users u ON omm.user_id = u.id + LEFT JOIN LATERAL ( + SELECT avatar_url + FROM oauth_accounts + WHERE user_id = omm.user_id + ORDER BY created_at ASC + LIMIT 1 + ) oa ON true + WHERE omm.organization_id = $1 + ORDER BY omm.joined_at ASC + "#, + org_id + ) + .fetch_all(&state.pool) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + Ok(Json(ListMembersResponse { members })) +} + +pub async fn remove_member( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path((org_id, user_id)): Path<(Uuid, Uuid)>, +) -> Result { + let user = ctx.user; + if user.id == user_id { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Cannot remove yourself", + )); + } + + let org_repo = OrganizationRepository::new(&state.pool); + if org_repo + .is_personal(org_id) + .await + .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found"))? + { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Cannot modify members of a personal organization", + )); + } + + ensure_admin_access(&state.pool, org_id, user.id).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + let target = sqlx::query!( + r#" + SELECT role AS "role!: MemberRole" + FROM organization_member_metadata + WHERE organization_id = $1 AND user_id = $2 + FOR UPDATE + "#, + org_id, + user_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "Member not found"))?; + + if target.role == MemberRole::Admin { + let admin_ids = sqlx::query_scalar!( + r#" + SELECT user_id + FROM organization_member_metadata + WHERE organization_id = $1 AND role = 'admin' + FOR UPDATE + "#, + org_id + ) + .fetch_all(&mut *tx) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + if admin_ids.len() == 1 && admin_ids[0] == user_id { + return Err(ErrorResponse::new( + StatusCode::CONFLICT, + "Cannot remove the last admin", + )); + } + } + + sqlx::query!( + r#" + DELETE FROM organization_member_metadata + WHERE organization_id = $1 AND user_id = $2 + "#, + org_id, + user_id + ) + .execute(&mut *tx) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + tx.commit() + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn update_member_role( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path((org_id, user_id)): Path<(Uuid, Uuid)>, + Json(payload): Json, +) -> Result { + let user = ctx.user; + if user.id == user_id && payload.role == MemberRole::Member { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Cannot demote yourself", + )); + } + + let org_repo = OrganizationRepository::new(&state.pool); + if org_repo + .is_personal(org_id) + .await + .map_err(|_| ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found"))? + { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Cannot modify members of a personal organization", + )); + } + + ensure_admin_access(&state.pool, org_id, user.id).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + let target = sqlx::query!( + r#" + SELECT role AS "role!: MemberRole" + FROM organization_member_metadata + WHERE organization_id = $1 AND user_id = $2 + FOR UPDATE + "#, + org_id, + user_id + ) + .fetch_optional(&mut *tx) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "Member not found"))?; + + if target.role == payload.role { + return Ok(Json(UpdateMemberRoleResponse { + user_id, + role: payload.role, + })); + } + + if target.role == MemberRole::Admin && payload.role == MemberRole::Member { + let admin_ids = sqlx::query_scalar!( + r#" + SELECT user_id + FROM organization_member_metadata + WHERE organization_id = $1 AND role = 'admin' + FOR UPDATE + "#, + org_id + ) + .fetch_all(&mut *tx) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + if admin_ids.len() == 1 && admin_ids[0] == user_id { + return Err(ErrorResponse::new( + StatusCode::CONFLICT, + "Cannot demote the last admin", + )); + } + } + + sqlx::query!( + r#" + UPDATE organization_member_metadata + SET role = $3 + WHERE organization_id = $1 AND user_id = $2 + "#, + org_id, + user_id, + payload.role as MemberRole + ) + .execute(&mut *tx) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + tx.commit() + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + Ok(Json(UpdateMemberRoleResponse { + user_id, + role: payload.role, + })) +} + +pub(crate) async fn ensure_member_access( + pool: &PgPool, + organization_id: Uuid, + user_id: Uuid, +) -> Result<(), ErrorResponse> { + organization_members::assert_membership(pool, organization_id, user_id) + .await + .map_err(|err| membership_error(err, "Not a member of organization")) +} + +pub(crate) async fn ensure_admin_access( + pool: &PgPool, + organization_id: Uuid, + user_id: Uuid, +) -> Result<(), ErrorResponse> { + OrganizationRepository::new(pool) + .assert_admin(organization_id, user_id) + .await + .map_err(|err| membership_error(err, "Admin access required")) +} + +pub(crate) async fn ensure_project_access( + pool: &PgPool, + user_id: Uuid, + project_id: Uuid, +) -> Result { + let organization_id = ProjectRepository::organization_id(pool, project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })? + .ok_or_else(|| { + warn!( + %project_id, + %user_id, + "project not found for access check" + ); + ErrorResponse::new(StatusCode::NOT_FOUND, "project not found") + })?; + + organization_members::assert_membership(pool, organization_id, user_id) + .await + .map_err(|err| { + if let IdentityError::Database(error) = &err { + tracing::error!( + ?error, + %organization_id, + %project_id, + "failed to authorize project membership" + ); + } else { + warn!( + ?err, + %organization_id, + %project_id, + %user_id, + "project access denied" + ); + } + membership_error(err, "project not accessible") + })?; + + Ok(organization_id) +} + +pub(crate) async fn ensure_task_access( + pool: &PgPool, + user_id: Uuid, + task_id: Uuid, +) -> Result { + let organization_id = SharedTaskRepository::organization_id(pool, task_id) + .await + .map_err(|error| { + tracing::error!(?error, %task_id, "failed to load shared task"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })? + .ok_or_else(|| { + warn!( + %task_id, + %user_id, + "shared task not found for access check" + ); + ErrorResponse::new(StatusCode::NOT_FOUND, "shared task not found") + })?; + + organization_members::assert_membership(pool, organization_id, user_id) + .await + .map_err(|err| { + if let IdentityError::Database(error) = &err { + tracing::error!( + ?error, + %organization_id, + %task_id, + "failed to authorize shared task access" + ); + } else { + warn!( + ?err, + %organization_id, + %task_id, + %user_id, + "shared task access denied" + ); + } + membership_error(err, "task not accessible") + })?; + + Ok(organization_id) +} diff --git a/crates/remote/src/routes/organizations.rs b/crates/remote/src/routes/organizations.rs new file mode 100644 index 00000000..ae0288ae --- /dev/null +++ b/crates/remote/src/routes/organizations.rs @@ -0,0 +1,194 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, patch, post}, +}; +use utils::api::organizations::{ + CreateOrganizationRequest, CreateOrganizationResponse, GetOrganizationResponse, + ListOrganizationsResponse, MemberRole, UpdateOrganizationRequest, +}; +use uuid::Uuid; + +use super::error::ErrorResponse; +use crate::{ + AppState, + auth::RequestContext, + db::{ + identity_errors::IdentityError, organization_members, organizations::OrganizationRepository, + }, +}; + +pub fn router() -> Router { + Router::new() + .route("/organizations", post(create_organization)) + .route("/organizations", get(list_organizations)) + .route("/organizations/{org_id}", get(get_organization)) + .route("/organizations/{org_id}", patch(update_organization)) + .route("/organizations/{org_id}", delete(delete_organization)) +} + +pub async fn create_organization( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Json(payload): Json, +) -> Result { + let name = payload.name.trim(); + let slug = payload.slug.trim().to_lowercase(); + + if name.is_empty() || name.len() > 100 { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Organization name must be between 1 and 100 characters", + )); + } + + if slug.len() < 3 || slug.len() > 63 { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Organization slug must be between 3 and 63 characters", + )); + } + + if !slug + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Organization slug can only contain lowercase letters, numbers, hyphens, and underscores", + )); + } + + let org_repo = OrganizationRepository::new(&state.pool); + + let organization = org_repo + .create_organization(name, &slug, ctx.user.id) + .await + .map_err(|e| match e { + IdentityError::OrganizationConflict(msg) => { + ErrorResponse::new(StatusCode::CONFLICT, msg) + } + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + Ok(( + StatusCode::CREATED, + Json(CreateOrganizationResponse { organization }), + )) +} + +pub async fn list_organizations( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, +) -> Result { + let org_repo = OrganizationRepository::new(&state.pool); + + let organizations = org_repo + .list_user_organizations(ctx.user.id) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?; + + Ok(Json(ListOrganizationsResponse { organizations })) +} + +pub async fn get_organization( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, +) -> Result { + let org_repo = OrganizationRepository::new(&state.pool); + + organization_members::assert_membership(&state.pool, org_id, ctx.user.id) + .await + .map_err(|e| match e { + IdentityError::NotFound => { + ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") + } + _ => ErrorResponse::new(StatusCode::FORBIDDEN, "Access denied"), + })?; + + let organization = org_repo.fetch_organization(org_id).await.map_err(|_| { + ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch organization", + ) + })?; + + let role = org_repo + .check_user_role(org_id, ctx.user.id) + .await + .map_err(|_| ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"))? + .unwrap_or(MemberRole::Member); + + let user_role = match role { + MemberRole::Admin => "ADMIN", + MemberRole::Member => "MEMBER", + } + .to_string(); + + Ok(Json(GetOrganizationResponse { + organization, + user_role, + })) +} + +pub async fn update_organization( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, + Json(payload): Json, +) -> Result { + let name = payload.name.trim(); + + if name.is_empty() || name.len() > 100 { + return Err(ErrorResponse::new( + StatusCode::BAD_REQUEST, + "Organization name must be between 1 and 100 characters", + )); + } + + let org_repo = OrganizationRepository::new(&state.pool); + + let organization = org_repo + .update_organization_name(org_id, ctx.user.id, name) + .await + .map_err(|e| match e { + IdentityError::PermissionDenied => { + ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") + } + IdentityError::NotFound => { + ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") + } + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + Ok(Json(organization)) +} + +pub async fn delete_organization( + State(state): State, + axum::extract::Extension(ctx): axum::extract::Extension, + Path(org_id): Path, +) -> Result { + let org_repo = OrganizationRepository::new(&state.pool); + + org_repo + .delete_organization(org_id, ctx.user.id) + .await + .map_err(|e| match e { + IdentityError::PermissionDenied => { + ErrorResponse::new(StatusCode::FORBIDDEN, "Admin access required") + } + IdentityError::CannotDeleteOrganization(msg) => { + ErrorResponse::new(StatusCode::CONFLICT, msg) + } + IdentityError::NotFound => { + ErrorResponse::new(StatusCode::NOT_FOUND, "Organization not found") + } + _ => ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "Database error"), + })?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/remote/src/routes/projects.rs b/crates/remote/src/routes/projects.rs new file mode 100644 index 00000000..32157749 --- /dev/null +++ b/crates/remote/src/routes/projects.rs @@ -0,0 +1,172 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, Query, State}, + http::StatusCode, + routing::get, +}; +use serde::Deserialize; +use serde_json::Value; +use tracing::instrument; +use utils::api::projects::{ListProjectsResponse, RemoteProject}; +use uuid::Uuid; + +use super::{error::ErrorResponse, organization_members::ensure_member_access}; +use crate::{ + AppState, + auth::RequestContext, + db::projects::{CreateProjectData, Project, ProjectError, ProjectRepository}, +}; + +#[derive(Debug, Deserialize)] +struct ProjectsQuery { + organization_id: Uuid, +} + +#[derive(Debug, Deserialize)] +struct CreateProjectRequest { + organization_id: Uuid, + name: String, + #[serde(default)] + metadata: Value, +} + +pub fn router() -> Router { + Router::new() + .route("/projects", get(list_projects).post(create_project)) + .route("/projects/{project_id}", get(get_project)) +} + +#[instrument( + name = "projects.list_projects", + skip(state, ctx, params), + fields(org_id = %params.organization_id, user_id = %ctx.user.id) +)] +async fn list_projects( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result, ErrorResponse> { + let target_org = params.organization_id; + ensure_member_access(state.pool(), target_org, ctx.user.id).await?; + + let projects = match ProjectRepository::list_by_organization(state.pool(), target_org).await { + Ok(rows) => rows.into_iter().map(to_remote_project).collect(), + Err(error) => { + tracing::error!(?error, org_id = %target_org, "failed to list remote projects"); + return Err(ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to list projects", + )); + } + }; + + Ok(Json(ListProjectsResponse { projects })) +} + +#[instrument( + name = "projects.get_project", + skip(state, ctx), + fields(project_id = %project_id, user_id = %ctx.user.id) +)] +async fn get_project( + State(state): State, + Extension(ctx): Extension, + Path(project_id): Path, +) -> Result, ErrorResponse> { + let record = ProjectRepository::fetch_by_id(state.pool(), project_id) + .await + .map_err(|error| { + tracing::error!(?error, %project_id, "failed to load project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "failed to load project") + })? + .ok_or_else(|| ErrorResponse::new(StatusCode::NOT_FOUND, "project not found"))?; + + ensure_member_access(state.pool(), record.organization_id, ctx.user.id).await?; + + Ok(Json(to_remote_project(record))) +} + +#[instrument( + name = "projects.create_project", + skip(state, ctx, payload), + fields(user_id = %ctx.user.id, org_id = %payload.organization_id) +)] +async fn create_project( + State(state): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Result, ErrorResponse> { + let CreateProjectRequest { + organization_id, + name, + metadata, + } = payload; + + ensure_member_access(state.pool(), organization_id, ctx.user.id).await?; + + let mut tx = state.pool().begin().await.map_err(|error| { + tracing::error!(?error, "failed to start transaction for project creation"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + })?; + + let metadata = normalize_metadata(metadata).ok_or_else(|| { + ErrorResponse::new(StatusCode::BAD_REQUEST, "metadata must be a JSON object") + })?; + + let project = match ProjectRepository::insert( + &mut tx, + CreateProjectData { + organization_id, + name, + metadata, + }, + ) + .await + { + Ok(project) => project, + Err(error) => { + tx.rollback().await.ok(); + return Err(match error { + ProjectError::Conflict(message) => { + tracing::warn!(?message, "remote project conflict"); + ErrorResponse::new(StatusCode::CONFLICT, "project already exists") + } + ProjectError::InvalidMetadata => { + ErrorResponse::new(StatusCode::BAD_REQUEST, "invalid project metadata") + } + ProjectError::Database(err) => { + tracing::error!(?err, "failed to create remote project"); + ErrorResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") + } + }); + } + }; + + if let Err(error) = tx.commit().await { + tracing::error!(?error, "failed to commit remote project creation"); + return Err(ErrorResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error", + )); + } + + Ok(Json(to_remote_project(project))) +} + +fn to_remote_project(project: Project) -> RemoteProject { + RemoteProject { + id: project.id, + organization_id: project.organization_id, + name: project.name, + metadata: project.metadata, + created_at: project.created_at, + } +} + +fn normalize_metadata(value: Value) -> Option { + match value { + Value::Null => Some(Value::Object(serde_json::Map::new())), + Value::Object(_) => Some(value), + _ => None, + } +} diff --git a/crates/remote/src/routes/tasks.rs b/crates/remote/src/routes/tasks.rs new file mode 100644 index 00000000..4c23e09d --- /dev/null +++ b/crates/remote/src/routes/tasks.rs @@ -0,0 +1,374 @@ +use axum::{ + Json, Router, + extract::{Extension, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, patch, post}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::{Span, instrument}; +use uuid::Uuid; + +use super::{ + error::{identity_error_response, task_error_response}, + organization_members::{ensure_project_access, ensure_task_access}, +}; +use crate::{ + AppState, + auth::RequestContext, + db::{ + organization_members, + tasks::{ + AssignTaskData, CreateSharedTaskData, DeleteTaskData, SharedTask, SharedTaskError, + SharedTaskRepository, SharedTaskWithUser, TaskStatus, UpdateSharedTaskData, + ensure_text_size, + }, + users::{UserData, UserRepository}, + }, +}; + +pub fn router() -> Router { + Router::new() + .route("/tasks/bulk", get(bulk_shared_tasks)) + .route("/tasks", post(create_shared_task)) + .route("/tasks/{task_id}", patch(update_shared_task)) + .route("/tasks/{task_id}", delete(delete_shared_task)) + .route("/tasks/{task_id}/assign", post(assign_task)) +} + +#[derive(Debug, Deserialize)] +pub struct BulkTasksQuery { + pub project_id: Uuid, +} + +#[instrument( + name = "tasks.bulk_shared_tasks", + skip(state, ctx, query), + fields(user_id = %ctx.user.id, project_id = %query.project_id, org_id = tracing::field::Empty) +)] +pub async fn bulk_shared_tasks( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Response { + let pool = state.pool(); + let _organization_id = match ensure_project_access(pool, ctx.user.id, query.project_id).await { + Ok(org_id) => { + Span::current().record("org_id", format_args!("{org_id}")); + org_id + } + Err(error) => return error.into_response(), + }; + + let repo = SharedTaskRepository::new(pool); + match repo.bulk_fetch(query.project_id).await { + Ok(snapshot) => ( + StatusCode::OK, + Json(BulkSharedTasksResponse { + tasks: snapshot.tasks, + deleted_task_ids: snapshot.deleted_task_ids, + latest_seq: snapshot.latest_seq, + }), + ) + .into_response(), + Err(error) => match error { + SharedTaskError::Database(err) => { + tracing::error!(?err, "failed to load shared task snapshot"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "failed to load shared tasks" })), + ) + .into_response() + } + other => task_error_response(other, "failed to load shared tasks"), + }, + } +} + +#[instrument( + name = "tasks.create_shared_task", + skip(state, ctx, payload), + fields(user_id = %ctx.user.id, org_id = tracing::field::Empty) +)] +pub async fn create_shared_task( + State(state): State, + Extension(ctx): Extension, + Json(payload): Json, +) -> Response { + let pool = state.pool(); + let repo = SharedTaskRepository::new(pool); + let user_repo = UserRepository::new(pool); + let CreateSharedTaskRequest { + project_id, + title, + description, + assignee_user_id, + } = payload; + + if let Err(error) = ensure_text_size(&title, description.as_deref()) { + return task_error_response(error, "shared task payload too large"); + } + + let organization_id = match ensure_project_access(pool, ctx.user.id, project_id).await { + Ok(org_id) => { + Span::current().record("org_id", format_args!("{org_id}")); + org_id + } + Err(error) => return error.into_response(), + }; + + if let Some(assignee) = assignee_user_id.as_ref() { + if let Err(err) = user_repo.fetch_user(*assignee).await { + return identity_error_response(err, "assignee not found or inactive"); + } + if let Err(err) = + organization_members::assert_membership(pool, organization_id, *assignee).await + { + return identity_error_response(err, "assignee not part of organization"); + } + } + + let data = CreateSharedTaskData { + project_id, + title, + description, + creator_user_id: ctx.user.id, + assignee_user_id, + }; + + match repo.create(data).await { + Ok(task) => (StatusCode::CREATED, Json(SharedTaskResponse::from(task))).into_response(), + Err(error) => task_error_response(error, "failed to create shared task"), + } +} + +#[instrument( + name = "tasks.update_shared_task", + skip(state, ctx, payload), + fields(user_id = %ctx.user.id, task_id = %task_id, org_id = tracing::field::Empty) +)] +pub async fn update_shared_task( + State(state): State, + Extension(ctx): Extension, + Path(task_id): Path, + Json(payload): Json, +) -> Response { + let pool = state.pool(); + let _organization_id = match ensure_task_access(pool, ctx.user.id, task_id).await { + Ok(org_id) => { + Span::current().record("org_id", format_args!("{org_id}")); + org_id + } + Err(error) => return error.into_response(), + }; + + let repo = SharedTaskRepository::new(pool); + let existing = match repo.find_by_id(task_id).await { + Ok(Some(task)) => task, + Ok(None) => { + return task_error_response(SharedTaskError::NotFound, "shared task not found"); + } + Err(error) => { + return task_error_response(error, "failed to load shared task"); + } + }; + + if existing.assignee_user_id.as_ref() != Some(&ctx.user.id) { + return task_error_response( + SharedTaskError::Forbidden, + "acting user is not the task assignee", + ); + } + + let UpdateSharedTaskRequest { + title, + description, + status, + version, + } = payload; + + let next_title = title.as_deref().unwrap_or(existing.title.as_str()); + let next_description = description.as_deref().or(existing.description.as_deref()); + + if let Err(error) = ensure_text_size(next_title, next_description) { + return task_error_response(error, "shared task payload too large"); + } + + let data = UpdateSharedTaskData { + title, + description, + status, + version, + acting_user_id: ctx.user.id, + }; + + match repo.update(task_id, data).await { + Ok(task) => (StatusCode::OK, Json(SharedTaskResponse::from(task))).into_response(), + Err(error) => task_error_response(error, "failed to update shared task"), + } +} + +#[instrument( + name = "tasks.assign_shared_task", + skip(state, ctx, payload), + fields(user_id = %ctx.user.id, task_id = %task_id, org_id = tracing::field::Empty) +)] +pub async fn assign_task( + State(state): State, + Extension(ctx): Extension, + Path(task_id): Path, + Json(payload): Json, +) -> Response { + let pool = state.pool(); + let organization_id = match ensure_task_access(pool, ctx.user.id, task_id).await { + Ok(org_id) => { + Span::current().record("org_id", format_args!("{org_id}")); + org_id + } + Err(error) => return error.into_response(), + }; + + let repo = SharedTaskRepository::new(pool); + let user_repo = UserRepository::new(pool); + + let existing = match repo.find_by_id(task_id).await { + Ok(Some(task)) => task, + Ok(None) => { + return task_error_response(SharedTaskError::NotFound, "shared task not found"); + } + Err(error) => { + return task_error_response(error, "failed to load shared task"); + } + }; + + if existing.assignee_user_id.as_ref() != Some(&ctx.user.id) { + return task_error_response( + SharedTaskError::Forbidden, + "acting user is not the task assignee", + ); + } + + if let Some(assignee) = payload.new_assignee_user_id.as_ref() { + if let Err(err) = user_repo.fetch_user(*assignee).await { + return identity_error_response(err, "assignee not found or inactive"); + } + if let Err(err) = + organization_members::assert_membership(pool, organization_id, *assignee).await + { + return identity_error_response(err, "assignee not part of organization"); + } + } + + let data = AssignTaskData { + new_assignee_user_id: payload.new_assignee_user_id, + previous_assignee_user_id: Some(ctx.user.id), + version: payload.version, + }; + + match repo.assign_task(task_id, data).await { + Ok(task) => (StatusCode::OK, Json(SharedTaskResponse::from(task))).into_response(), + Err(error) => task_error_response(error, "failed to transfer task assignment"), + } +} + +#[instrument( + name = "tasks.delete_shared_task", + skip(state, ctx, payload), + fields(user_id = %ctx.user.id, task_id = %task_id, org_id = tracing::field::Empty) +)] +pub async fn delete_shared_task( + State(state): State, + Extension(ctx): Extension, + Path(task_id): Path, + payload: Option>, +) -> Response { + let pool = state.pool(); + let _organization_id = match ensure_task_access(pool, ctx.user.id, task_id).await { + Ok(org_id) => { + Span::current().record("org_id", format_args!("{org_id}")); + org_id + } + Err(error) => return error.into_response(), + }; + + let repo = SharedTaskRepository::new(pool); + + let existing = match repo.find_by_id(task_id).await { + Ok(Some(task)) => task, + Ok(None) => { + return task_error_response(SharedTaskError::NotFound, "shared task not found"); + } + Err(error) => { + return task_error_response(error, "failed to load shared task"); + } + }; + + if existing.assignee_user_id.as_ref() != Some(&ctx.user.id) { + return task_error_response( + SharedTaskError::Forbidden, + "acting user is not the task assignee", + ); + } + + let version = payload.as_ref().and_then(|body| body.0.version); + + let data = DeleteTaskData { + acting_user_id: ctx.user.id, + version, + }; + + match repo.delete_task(task_id, data).await { + Ok(task) => (StatusCode::OK, Json(SharedTaskResponse::from(task))).into_response(), + Err(error) => task_error_response(error, "failed to delete shared task"), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BulkSharedTasksResponse { + pub tasks: Vec, + pub deleted_task_ids: Vec, + pub latest_seq: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateSharedTaskRequest { + pub project_id: Uuid, + pub title: String, + pub description: Option, + pub assignee_user_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSharedTaskRequest { + pub title: Option, + pub description: Option, + pub status: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignSharedTaskRequest { + pub new_assignee_user_id: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteSharedTaskRequest { + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedTaskResponse { + pub task: SharedTask, + pub user: Option, +} + +impl From for SharedTaskResponse { + fn from(v: SharedTaskWithUser) -> Self { + Self { + task: v.task, + user: v.user, + } + } +} diff --git a/crates/remote/src/state.rs b/crates/remote/src/state.rs new file mode 100644 index 00000000..70998cf4 --- /dev/null +++ b/crates/remote/src/state.rs @@ -0,0 +1,63 @@ +use std::sync::Arc; + +use sqlx::PgPool; + +use crate::{ + activity::ActivityBroker, + auth::{JwtService, OAuthHandoffService}, + config::RemoteServerConfig, + mail::Mailer, +}; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub broker: ActivityBroker, + pub config: RemoteServerConfig, + pub jwt: Arc, + pub mailer: Arc, + pub server_public_base_url: String, + handoff: Arc, +} + +impl AppState { + pub fn new( + pool: PgPool, + broker: ActivityBroker, + config: RemoteServerConfig, + jwt: Arc, + handoff: Arc, + mailer: Arc, + server_public_base_url: String, + ) -> Self { + Self { + pool, + broker, + config, + jwt, + mailer, + server_public_base_url, + handoff, + } + } + + pub fn pool(&self) -> &PgPool { + &self.pool + } + + pub fn broker(&self) -> &ActivityBroker { + &self.broker + } + + pub fn config(&self) -> &RemoteServerConfig { + &self.config + } + + pub fn jwt(&self) -> Arc { + Arc::clone(&self.jwt) + } + + pub fn handoff(&self) -> Arc { + Arc::clone(&self.handoff) + } +} diff --git a/crates/remote/src/ws/message.rs b/crates/remote/src/ws/message.rs new file mode 100644 index 00000000..44c16276 --- /dev/null +++ b/crates/remote/src/ws/message.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use crate::activity::ActivityEvent; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum ClientMessage { + #[serde(rename = "ack")] + Ack { cursor: i64 }, + #[serde(rename = "auth-token")] + AuthToken { token: String }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", content = "data")] +pub enum ServerMessage { + #[serde(rename = "activity")] + Activity(ActivityEvent), + #[serde(rename = "error")] + Error { message: String }, +} diff --git a/crates/remote/src/ws/mod.rs b/crates/remote/src/ws/mod.rs new file mode 100644 index 00000000..7667f0a4 --- /dev/null +++ b/crates/remote/src/ws/mod.rs @@ -0,0 +1,41 @@ +use axum::{ + Router, + extract::{Extension, Query, State, ws::WebSocketUpgrade}, + response::IntoResponse, + routing::get, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{AppState, auth::RequestContext}; + +pub mod message; +mod session; + +#[derive(Debug, Deserialize, Clone)] +pub struct WsQueryParams { + pub project_id: Uuid, + pub cursor: Option, +} + +pub fn router() -> Router { + Router::new().route("/ws", get(upgrade)) +} + +async fn upgrade( + ws: WebSocketUpgrade, + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> impl IntoResponse { + match crate::routes::organization_members::ensure_project_access( + state.pool(), + ctx.user.id, + params.project_id, + ) + .await + { + Ok(_) => ws.on_upgrade(move |socket| session::handle(socket, state, ctx, params)), + Err(error) => error.into_response(), + } +} diff --git a/crates/remote/src/ws/session.rs b/crates/remote/src/ws/session.rs new file mode 100644 index 00000000..f5f73701 --- /dev/null +++ b/crates/remote/src/ws/session.rs @@ -0,0 +1,500 @@ +use std::sync::Arc; + +use axum::extract::ws::{Message, WebSocket}; +use futures::{SinkExt, StreamExt}; +use sqlx::PgPool; +use thiserror::Error; +use tokio::time::{self, MissedTickBehavior}; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tracing::{Span, instrument}; +use utils::ws::{WS_AUTH_REFRESH_INTERVAL, WS_BULK_SYNC_THRESHOLD}; +use uuid::Uuid; + +use super::{ + WsQueryParams, + message::{ClientMessage, ServerMessage}, +}; +use crate::{ + AppState, + activity::{ActivityBroker, ActivityEvent, ActivityStream}, + auth::{JwtError, JwtIdentity, JwtService, RequestContext}, + db::{ + activity::ActivityRepository, + auth::{AuthSessionError, AuthSessionRepository}, + }, +}; + +#[instrument( + name = "ws.session", + skip(socket, state, ctx, params), + fields( + user_id = %ctx.user.id, + project_id = %params.project_id, + org_id = tracing::field::Empty, + session_id = %ctx.session_id + ) +)] +pub async fn handle( + socket: WebSocket, + state: AppState, + ctx: RequestContext, + params: WsQueryParams, +) { + let config = state.config(); + let pool_ref = state.pool(); + let project_id = params.project_id; + let organization_id = match crate::routes::organization_members::ensure_project_access( + pool_ref, + ctx.user.id, + project_id, + ) + .await + { + Ok(org_id) => org_id, + Err(error) => { + tracing::info!( + ?error, + user_id = %ctx.user.id, + %project_id, + "websocket project access denied" + ); + return; + } + }; + Span::current().record("org_id", format_args!("{organization_id}")); + + let pool = pool_ref.clone(); + let mut last_sent_seq = params.cursor; + let mut auth_state = WsAuthState::new( + state.jwt(), + pool.clone(), + ctx.session_id, + ctx.session_secret.clone(), + ctx.user.id, + project_id, + ); + let mut auth_check_interval = time::interval(WS_AUTH_REFRESH_INTERVAL); + auth_check_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + let (mut sender, mut inbound) = socket.split(); + let mut activity_stream = state.broker().subscribe(project_id); + + if let Ok(history) = ActivityRepository::new(&pool) + .fetch_since(project_id, params.cursor, config.activity_default_limit) + .await + { + for event in history { + if send_activity(&mut sender, &event).await.is_err() { + return; + } + last_sent_seq = Some(event.seq); + } + } + + tracing::debug!(org_id = %organization_id, project_id = %project_id, "starting websocket session"); + + loop { + tokio::select! { + maybe_activity = activity_stream.next() => { + match maybe_activity { + Some(Ok(event)) => { + tracing::trace!(?event, "received activity event"); + assert_eq!(event.project_id, project_id, "activity stream emitted cross-project event"); + if let Some(prev_seq) = last_sent_seq { + if prev_seq >= event.seq { + continue; + } + if event.seq > prev_seq + 1 { + tracing::warn!( + expected_next = prev_seq + 1, + actual = event.seq, + org_id = %organization_id, + project_id = %project_id, + "activity stream skipped sequence; running catch-up" + ); + match activity_stream_catch_up( + &mut sender, + &pool, + project_id, + organization_id, + prev_seq, + state.broker(), + config.activity_catchup_batch_size, + WS_BULK_SYNC_THRESHOLD as i64, + "gap", + ).await { + Ok((seq, stream)) => { + last_sent_seq = Some(seq); + activity_stream = stream; + } + Err(()) => break, + } + continue; + } + } + if send_activity(&mut sender, &event).await.is_err() { + break; + } + last_sent_seq = Some(event.seq); + } + Some(Err(BroadcastStreamRecvError::Lagged(skipped))) => { + tracing::warn!(skipped, org_id = %organization_id, project_id = %project_id, "activity stream lagged"); + let Some(prev_seq) = last_sent_seq else { + tracing::info!( + org_id = %organization_id, + project_id = %project_id, + "activity stream lagged without baseline; forcing bulk sync" + ); + let _ = send_error(&mut sender, "activity backlog dropped").await; + break; + }; + + match activity_stream_catch_up( + &mut sender, + &pool, + project_id, + organization_id, + prev_seq, + state.broker(), + config.activity_catchup_batch_size, + WS_BULK_SYNC_THRESHOLD as i64, + "lag", + ).await { + Ok((seq, stream)) => { + last_sent_seq = Some(seq); + activity_stream = stream; + } + Err(()) => break, + } + } + None => break, + } + } + + maybe_message = inbound.next() => { + match maybe_message { + Some(Ok(msg)) => { + if matches!(msg, Message::Close(_)) { + break; + } + if let Message::Text(text) = msg { + match serde_json::from_str::(&text) { + Ok(ClientMessage::Ack { .. }) => {} + Ok(ClientMessage::AuthToken { token }) => { + auth_state.store_token(token); + } + Err(error) => { + tracing::debug!(?error, "invalid inbound message"); + } + } + } + } + Some(Err(error)) => { + tracing::debug!(?error, "websocket receive error"); + break; + } + None => break, + } + } + + _ = auth_check_interval.tick() => { + match auth_state.verify().await { + Ok(()) => {} + Err(error) => { + tracing::info!(?error, "closing websocket due to auth verification error"); + let message = match error { + AuthVerifyError::Revoked | AuthVerifyError::SecretMismatch => { + "authorization revoked" + } + AuthVerifyError::MembershipRevoked => "project access revoked", + AuthVerifyError::UserMismatch { .. } + | AuthVerifyError::Decode(_) + | AuthVerifyError::Session(_) => "authorization error", + }; + let _ = send_error(&mut sender, message).await; + let _ = sender.send(Message::Close(None)).await; + break; + } + } + } + } + } +} + +async fn send_activity( + sender: &mut futures::stream::SplitSink, + event: &ActivityEvent, +) -> Result<(), ()> { + tracing::trace!( + event_type = %event.event_type.as_str(), + project_id = %event.project_id, + "sending activity event" + ); + + match serde_json::to_string(&ServerMessage::Activity(event.clone())) { + Ok(json) => sender + .send(Message::Text(json.into())) + .await + .map_err(|error| { + tracing::debug!(?error, "failed to send activity message"); + }), + Err(error) => { + tracing::error!(?error, "failed to serialise activity event"); + Err(()) + } + } +} + +async fn send_error( + sender: &mut futures::stream::SplitSink, + message: &str, +) -> Result<(), ()> { + match serde_json::to_string(&ServerMessage::Error { + message: message.to_string(), + }) { + Ok(json) => sender + .send(Message::Text(json.into())) + .await + .map_err(|error| { + tracing::debug!(?error, "failed to send websocket error message"); + }), + Err(error) => { + tracing::error!(?error, "failed to serialise websocket error message"); + Err(()) + } + } +} + +struct WsAuthState { + jwt: Arc, + pool: PgPool, + session_id: Uuid, + session_secret: String, + expected_user_id: Uuid, + project_id: Uuid, + pending_token: Option, +} + +impl WsAuthState { + fn new( + jwt: Arc, + pool: PgPool, + session_id: Uuid, + session_secret: String, + expected_user_id: Uuid, + project_id: Uuid, + ) -> Self { + Self { + jwt, + pool, + session_id, + session_secret, + expected_user_id, + project_id, + pending_token: None, + } + } + + fn store_token(&mut self, token: String) { + self.pending_token = Some(token); + } + + async fn verify(&mut self) -> Result<(), AuthVerifyError> { + if let Some(token) = self.pending_token.take() { + let identity = self.jwt.decode(&token).map_err(AuthVerifyError::Decode)?; + self.apply_identity(identity).await?; + } + + self.validate_session().await?; + self.validate_membership().await + } + + async fn apply_identity(&mut self, identity: JwtIdentity) -> Result<(), AuthVerifyError> { + if identity.user_id != self.expected_user_id { + return Err(AuthVerifyError::UserMismatch { + expected: self.expected_user_id, + received: identity.user_id, + }); + } + + self.session_id = identity.session_id; + self.session_secret = identity.nonce; + self.validate_session().await + } + + async fn validate_session(&self) -> Result<(), AuthVerifyError> { + let repo = AuthSessionRepository::new(&self.pool); + let session = repo + .get(self.session_id) + .await + .map_err(AuthVerifyError::Session)?; + + if session.revoked_at.is_some() { + return Err(AuthVerifyError::Revoked); + } + + if !self + .jwt + .verify_session_secret(session.session_secret_hash.as_deref(), &self.session_secret) + .unwrap_or(false) + { + return Err(AuthVerifyError::SecretMismatch); + } + + Ok(()) + } + + async fn validate_membership(&self) -> Result<(), AuthVerifyError> { + crate::routes::organization_members::ensure_project_access( + &self.pool, + self.expected_user_id, + self.project_id, + ) + .await + .map(|_| ()) + .map_err(|error| { + tracing::warn!( + ?error, + user_id = %self.expected_user_id, + project_id = %self.project_id, + "websocket membership validation failed" + ); + AuthVerifyError::MembershipRevoked + }) + } +} + +#[derive(Debug, Error)] +enum AuthVerifyError { + #[error(transparent)] + Decode(#[from] JwtError), + #[error("received token for unexpected user: expected {expected}, received {received}")] + UserMismatch { expected: Uuid, received: Uuid }, + #[error(transparent)] + Session(#[from] AuthSessionError), + #[error("session revoked")] + Revoked, + #[error("session rotated")] + SecretMismatch, + #[error("organization membership revoked")] + MembershipRevoked, +} + +#[allow(clippy::too_many_arguments)] +async fn activity_stream_catch_up( + sender: &mut futures::stream::SplitSink, + pool: &PgPool, + project_id: Uuid, + organization_id: Uuid, + last_seq: i64, + broker: &ActivityBroker, + batch_size: i64, + bulk_limit: i64, + reason: &'static str, +) -> Result<(i64, ActivityStream), ()> { + let mut activity_stream = broker.subscribe(project_id); + + let event = match activity_stream.next().await { + Some(Ok(event)) => event, + Some(Err(_)) | None => { + let _ = send_error(sender, "activity backlog dropped").await; + return Err(()); + } + }; + let target_seq = event.seq; + + if target_seq <= last_seq { + return Ok((last_seq, activity_stream)); + } + + let bulk_limit = bulk_limit.max(1); + let diff = target_seq - last_seq; + if diff > bulk_limit { + tracing::info!( + org_id = %organization_id, + project_id = %project_id, + threshold = bulk_limit, + reason, + "activity catch up exceeded threshold; forcing bulk sync" + ); + let _ = send_error(sender, "activity backlog dropped").await; + return Err(()); + } + + let catch_up_result = catch_up_from_db( + sender, + pool, + project_id, + organization_id, + last_seq, + target_seq, + batch_size.max(1), + ) + .await; + + match catch_up_result { + Ok(seq) => Ok((seq, activity_stream)), + Err(CatchUpError::Stale) => { + let _ = send_error(sender, "activity backlog dropped").await; + Err(()) + } + Err(CatchUpError::Send) => Err(()), + } +} + +#[derive(Debug, Error)] +enum CatchUpError { + #[error("activity stream went stale during catch up")] + Stale, + #[error("failed to send activity event")] + Send, +} + +async fn catch_up_from_db( + sender: &mut futures::stream::SplitSink, + pool: &PgPool, + project_id: Uuid, + organization_id: Uuid, + last_seq: i64, + target_seq: i64, + batch_size: i64, +) -> Result { + let repository = ActivityRepository::new(pool); + let mut current_seq = last_seq; + let mut cursor = last_seq; + + loop { + let events = repository + .fetch_since(project_id, Some(cursor), batch_size) + .await + .map_err(|error| { + tracing::error!(?error, org_id = %organization_id, project_id = %project_id, "failed to fetch activity catch up"); + CatchUpError::Stale + })?; + + if events.is_empty() { + tracing::warn!(org_id = %organization_id, project_id = %project_id, "activity catch up returned no events"); + return Err(CatchUpError::Stale); + } + + for event in events { + if event.seq <= current_seq { + continue; + } + if event.seq > target_seq { + return Ok(current_seq); + } + if send_activity(sender, &event).await.is_err() { + return Err(CatchUpError::Send); + } + current_seq = event.seq; + cursor = event.seq; + } + + if current_seq >= target_seq { + break; + } + } + + Ok(current_seq) +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 974a18d1..97c51f8a 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -27,14 +27,12 @@ sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "sqlit chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } -async-trait = "0.1" -command-group = { version = "5.0", features = ["with-tokio"] } nix = { version = "0.29", features = ["signal", "process"] } openssl-sys = { workspace = true } rmcp = { version = "0.5.0", features = ["server", "transport-io"] } schemars = { workspace = true } -regex = "1.11.1" -toml = "0.8" +secrecy = "0.10.3" +sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debug-images"] } reqwest = { version = "0.12", features = ["json"] } strip-ansi-escapes = "0.2.1" thiserror = { workspace = true } @@ -44,13 +42,10 @@ ignore = "0.4" git2 = "0.18" mime_guess = "2.0" rust-embed = "8.2" -octocrab = "0.44" -dirs = "5.0" - -[dev-dependencies] -tempfile = "3.8" -tower = { version = "0.4", features = ["util"] } +axum-extra = { version = "0.9", features = ["typed-header"] } +url = "2.5" +rand = { version = "0.8", features = ["std"] } +sha2 = "0.10" [build-dependencies] dotenv = "0.15" - diff --git a/crates/server/build.rs b/crates/server/build.rs index b2b12760..7a42e565 100644 --- a/crates/server/build.rs +++ b/crates/server/build.rs @@ -9,11 +9,8 @@ fn main() { if let Ok(api_endpoint) = std::env::var("POSTHOG_API_ENDPOINT") { println!("cargo:rustc-env=POSTHOG_API_ENDPOINT={}", api_endpoint); } - if let Ok(api_key) = std::env::var("GITHUB_APP_ID") { - println!("cargo:rustc-env=GITHUB_APP_ID={}", api_key); - } - if let Ok(api_endpoint) = std::env::var("GITHUB_APP_CLIENT_ID") { - println!("cargo:rustc-env=GITHUB_APP_CLIENT_ID={}", api_endpoint); + if let Ok(vk_shared_api_base) = std::env::var("VK_SHARED_API_BASE") { + println!("cargo:rustc-env=VK_SHARED_API_BASE={}", vk_shared_api_base); } // Create frontend/dist directory if it doesn't exist diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index e85e1d25..b25e4b51 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -17,6 +17,8 @@ fn generate_types_content() -> String { db::models::project::UpdateProject::decl(), db::models::project::SearchResult::decl(), db::models::project::SearchMatchType::decl(), + server::routes::projects::CreateRemoteProjectRequest::decl(), + server::routes::projects::LinkToExistingRequest::decl(), executors::actions::ExecutorAction::decl(), executors::mcp_config::McpConfig::decl(), executors::actions::ExecutorActionType::decl(), @@ -35,9 +37,38 @@ fn generate_types_content() -> String { db::models::task::TaskRelationships::decl(), db::models::task::CreateTask::decl(), db::models::task::UpdateTask::decl(), + db::models::shared_task::SharedTask::decl(), db::models::image::Image::decl(), db::models::image::CreateImage::decl(), utils::response::ApiResponse::<()>::decl(), + utils::api::oauth::LoginStatus::decl(), + utils::api::oauth::ProfileResponse::decl(), + utils::api::oauth::ProviderProfile::decl(), + utils::api::oauth::StatusResponse::decl(), + utils::api::organizations::MemberRole::decl(), + utils::api::organizations::InvitationStatus::decl(), + utils::api::organizations::Organization::decl(), + utils::api::organizations::OrganizationWithRole::decl(), + utils::api::organizations::ListOrganizationsResponse::decl(), + utils::api::organizations::GetOrganizationResponse::decl(), + utils::api::organizations::CreateOrganizationRequest::decl(), + utils::api::organizations::CreateOrganizationResponse::decl(), + utils::api::organizations::UpdateOrganizationRequest::decl(), + utils::api::organizations::Invitation::decl(), + utils::api::organizations::CreateInvitationRequest::decl(), + utils::api::organizations::CreateInvitationResponse::decl(), + utils::api::organizations::ListInvitationsResponse::decl(), + utils::api::organizations::GetInvitationResponse::decl(), + utils::api::organizations::AcceptInvitationResponse::decl(), + utils::api::organizations::RevokeInvitationRequest::decl(), + utils::api::organizations::OrganizationMember::decl(), + utils::api::organizations::OrganizationMemberWithProfile::decl(), + utils::api::organizations::ListMembersResponse::decl(), + utils::api::organizations::UpdateMemberRoleRequest::decl(), + utils::api::organizations::UpdateMemberRoleResponse::decl(), + utils::api::projects::RemoteProject::decl(), + utils::api::projects::ListProjectsResponse::decl(), + utils::api::projects::RemoteProjectMembersResponse::decl(), server::routes::config::UserSystemInfo::decl(), server::routes::config::Environment::decl(), server::routes::config::McpServerQuery::decl(), @@ -51,6 +82,9 @@ fn generate_types_content() -> String { server::routes::task_attempts::ChangeTargetBranchResponse::decl(), server::routes::task_attempts::RenameBranchRequest::decl(), server::routes::task_attempts::RenameBranchResponse::decl(), + server::routes::shared_tasks::AssignSharedTaskRequest::decl(), + server::routes::shared_tasks::AssignSharedTaskResponse::decl(), + server::routes::tasks::ShareTaskResponse::decl(), server::routes::tasks::CreateAndStartTaskRequest::decl(), server::routes::task_attempts::CreateGitHubPrRequest::decl(), server::routes::images::ImageResponse::decl(), @@ -64,9 +98,6 @@ fn generate_types_content() -> String { services::services::config::SoundFile::decl(), services::services::config::UiLanguage::decl(), services::services::config::ShowcaseState::decl(), - services::services::auth::DeviceFlowStartResponse::decl(), - server::routes::auth::DevicePollStatus::decl(), - server::routes::auth::CheckTokenResponse::decl(), services::services::git::GitBranch::decl(), utils::diff::Diff::decl(), utils::diff::DiffChangeKind::decl(), @@ -95,6 +126,7 @@ fn generate_types_content() -> String { server::routes::task_attempts::CreateTaskAttemptBody::decl(), server::routes::task_attempts::RunAgentSetupRequest::decl(), server::routes::task_attempts::RunAgentSetupResponse::decl(), + server::routes::task_attempts::gh_cli_setup::GhCliSetupError::decl(), server::routes::task_attempts::RebaseTaskAttemptRequest::decl(), server::routes::task_attempts::GitOperationError::decl(), server::routes::task_attempts::ReplaceProcessRequest::decl(), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index f1e0eb31..07a5aeb3 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -7,13 +7,13 @@ use axum::{ use db::models::{ execution_process::ExecutionProcessError, project::ProjectError, task_attempt::TaskAttemptError, }; -use deployment::DeploymentError; +use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::executors::ExecutorError; use git2::Error as Git2Error; use services::services::{ - auth::AuthError, config::ConfigError, container::ContainerError, drafts::DraftsServiceError, + config::ConfigError, container::ContainerError, drafts::DraftsServiceError, git::GitServiceError, github_service::GitHubServiceError, image::ImageError, - worktree_manager::WorktreeError, + remote_client::RemoteClientError, share::ShareError, worktree_manager::WorktreeError, }; use thiserror::Error; use utils::response::ApiResponse; @@ -32,8 +32,6 @@ pub enum ApiError { #[error(transparent)] GitHubService(#[from] GitHubServiceError), #[error(transparent)] - Auth(#[from] AuthError), - #[error(transparent)] Deployment(#[from] DeploymentError), #[error(transparent)] Container(#[from] ContainerError), @@ -53,8 +51,22 @@ pub enum ApiError { Multipart(#[from] MultipartError), #[error("IO error: {0}")] Io(#[from] std::io::Error), + #[error(transparent)] + RemoteClient(#[from] RemoteClientError), + #[error("Unauthorized")] + Unauthorized, + #[error("Bad request: {0}")] + BadRequest(String), #[error("Conflict: {0}")] Conflict(String), + #[error("Forbidden: {0}")] + Forbidden(String), +} + +impl From<&'static str> for ApiError { + fn from(msg: &'static str) -> Self { + ApiError::BadRequest(msg.to_string()) + } } impl From for ApiError { @@ -63,6 +75,12 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(_: RemoteClientNotConfigured) -> Self { + ApiError::BadRequest("Remote client not configured".to_string()) + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status_code, error_type) = match &self { @@ -85,7 +103,6 @@ impl IntoResponse for ApiError { _ => (StatusCode::INTERNAL_SERVER_ERROR, "GitServiceError"), }, ApiError::GitHubService(_) => (StatusCode::INTERNAL_SERVER_ERROR, "GitHubServiceError"), - ApiError::Auth(_) => (StatusCode::INTERNAL_SERVER_ERROR, "AuthError"), ApiError::Deployment(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DeploymentError"), ApiError::Container(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError"), ApiError::Executor(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ExecutorError"), @@ -113,7 +130,38 @@ impl IntoResponse for ApiError { }, ApiError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"), ApiError::Multipart(_) => (StatusCode::BAD_REQUEST, "MultipartError"), + ApiError::RemoteClient(err) => match err { + RemoteClientError::Auth => (StatusCode::UNAUTHORIZED, "RemoteClientError"), + RemoteClientError::Timeout => (StatusCode::GATEWAY_TIMEOUT, "RemoteClientError"), + RemoteClientError::Transport(_) => (StatusCode::BAD_GATEWAY, "RemoteClientError"), + RemoteClientError::Http { status, .. } => ( + StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY), + "RemoteClientError", + ), + RemoteClientError::Api(code) => match code { + services::services::remote_client::HandoffErrorCode::NotFound => { + (StatusCode::NOT_FOUND, "RemoteClientError") + } + services::services::remote_client::HandoffErrorCode::Expired => { + (StatusCode::UNAUTHORIZED, "RemoteClientError") + } + services::services::remote_client::HandoffErrorCode::AccessDenied => { + (StatusCode::FORBIDDEN, "RemoteClientError") + } + services::services::remote_client::HandoffErrorCode::ProviderError + | services::services::remote_client::HandoffErrorCode::InternalError => { + (StatusCode::BAD_GATEWAY, "RemoteClientError") + } + _ => (StatusCode::BAD_REQUEST, "RemoteClientError"), + }, + RemoteClientError::Serde(_) | RemoteClientError::Url(_) => { + (StatusCode::BAD_REQUEST, "RemoteClientError") + } + }, + ApiError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), + ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"), ApiError::Conflict(_) => (StatusCode::CONFLICT, "ConflictError"), + ApiError::Forbidden(_) => (StatusCode::FORBIDDEN, "ForbiddenError"), }; let error_message = match &self { @@ -137,7 +185,53 @@ impl IntoResponse for ApiError { _ => format!("{}: {}", error_type, self), }, ApiError::Multipart(_) => "Failed to upload file. Please ensure the file is valid and try again.".to_string(), + ApiError::RemoteClient(err) => match err { + RemoteClientError::Auth => "Unauthorized. Please sign in again.".to_string(), + RemoteClientError::Timeout => "Remote service timeout. Please try again.".to_string(), + RemoteClientError::Transport(_) => "Remote service unavailable. Please try again.".to_string(), + RemoteClientError::Http { body, .. } => { + if body.is_empty() { + "Remote service error. Please try again.".to_string() + } else { + body.clone() + } + } + RemoteClientError::Api(code) => match code { + services::services::remote_client::HandoffErrorCode::NotFound => { + "The requested resource was not found.".to_string() + } + services::services::remote_client::HandoffErrorCode::Expired => { + "The link or token has expired.".to_string() + } + services::services::remote_client::HandoffErrorCode::AccessDenied => { + "Access denied.".to_string() + } + services::services::remote_client::HandoffErrorCode::UnsupportedProvider => { + "Unsupported authentication provider.".to_string() + } + services::services::remote_client::HandoffErrorCode::InvalidReturnUrl => { + "Invalid return URL.".to_string() + } + services::services::remote_client::HandoffErrorCode::InvalidChallenge => { + "Invalid authentication challenge.".to_string() + } + services::services::remote_client::HandoffErrorCode::ProviderError => { + "Authentication provider error. Please try again.".to_string() + } + services::services::remote_client::HandoffErrorCode::InternalError => { + "Internal remote service error. Please try again.".to_string() + } + services::services::remote_client::HandoffErrorCode::Other(msg) => { + format!("Authentication error: {}", msg) + } + }, + RemoteClientError::Serde(_) => "Unexpected response from remote service.".to_string(), + RemoteClientError::Url(_) => "Remote service URL is invalid.".to_string(), + }, + ApiError::Unauthorized => "Unauthorized. Please sign in again.".to_string(), + ApiError::BadRequest(msg) => msg.clone(), ApiError::Conflict(msg) => msg.clone(), + ApiError::Forbidden(msg) => msg.clone(), ApiError::Drafts(drafts_err) => match drafts_err { DraftsServiceError::Conflict(msg) => msg.clone(), DraftsServiceError::Database(_) => format!("{}: {}", error_type, drafts_err), @@ -153,3 +247,60 @@ impl IntoResponse for ApiError { (status_code, Json(response)).into_response() } } + +impl From for ApiError { + fn from(err: ShareError) -> Self { + match err { + ShareError::Database(db_err) => ApiError::Database(db_err), + ShareError::AlreadyShared(_) => ApiError::Conflict("Task already shared".to_string()), + ShareError::TaskNotFound(_) => { + ApiError::Conflict("Task not found for sharing".to_string()) + } + ShareError::ProjectNotFound(_) => { + ApiError::Conflict("Project not found for sharing".to_string()) + } + ShareError::ProjectNotLinked(project_id) => { + tracing::warn!( + %project_id, + "project must be linked to a remote project before sharing tasks" + ); + ApiError::Conflict( + "Link this project to a remote project before sharing tasks.".to_string(), + ) + } + ShareError::MissingConfig(reason) => { + ApiError::Conflict(format!("Share service not configured: {reason}")) + } + ShareError::Transport(err) => { + tracing::error!(?err, "share task transport error"); + ApiError::Conflict("Failed to share task with remote service".to_string()) + } + ShareError::Serialization(err) => { + tracing::error!(?err, "share task serialization error"); + ApiError::Conflict("Failed to parse remote share response".to_string()) + } + ShareError::Url(err) => { + tracing::error!(?err, "share task URL error"); + ApiError::Conflict("Share service URL is invalid".to_string()) + } + ShareError::WebSocket(err) => { + tracing::error!(?err, "share task websocket error"); + ApiError::Conflict("Unexpected websocket error during sharing".to_string()) + } + ShareError::InvalidResponse => ApiError::Conflict( + "Remote share service returned an unexpected response".to_string(), + ), + ShareError::MissingGitHubToken => ApiError::Conflict( + "GitHub token is required to fetch repository metadata for sharing".to_string(), + ), + ShareError::Git(err) => ApiError::GitService(err), + ShareError::GitHub(err) => ApiError::GitHubService(err), + ShareError::MissingAuth => ApiError::Unauthorized, + ShareError::InvalidUserId => ApiError::Conflict("Invalid user ID format".to_string()), + ShareError::InvalidOrganizationId => { + ApiError::Conflict("Invalid organization ID format".to_string()) + } + ShareError::RemoteClientError(err) => ApiError::Conflict(err.to_string()), + } + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index f8198195..8b1d44fb 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -52,7 +52,6 @@ async fn main() -> Result<(), VibeKanbanError> { deployment .track_if_analytics_allowed("session_start", serde_json::json!({})) .await; - // Pre-warm file search cache for most active projects let deployment_for_cache = deployment.clone(); tokio::spawn(async move { diff --git a/crates/server/src/middleware/model_loaders.rs b/crates/server/src/middleware/model_loaders.rs index 43bf22df..77f1cc3b 100644 --- a/crates/server/src/middleware/model_loaders.rs +++ b/crates/server/src/middleware/model_loaders.rs @@ -120,62 +120,6 @@ pub async fn load_execution_process_middleware( Ok(next.run(request).await) } -// TODO: fix -// Middleware that loads and injects Project, Task, TaskAttempt, and ExecutionProcess -// based on the path parameters: project_id, task_id, attempt_id, process_id -// pub async fn load_execution_process_with_context_middleware( -// State(deployment): State, -// Path((project_id, task_id, attempt_id, process_id)): Path<(Uuid, Uuid, Uuid, Uuid)>, -// request: axum::extract::Request, -// next: Next, -// ) -> Result { -// // Load the task attempt context first -// let context = match TaskAttempt::load_context( -// &deployment.db().pool, -// attempt_id, -// task_id, -// project_id, -// ) -// .await -// { -// Ok(context) => context, -// Err(e) => { -// tracing::error!( -// "Failed to load context for attempt {} in task {} in project {}: {}", -// attempt_id, -// task_id, -// project_id, -// e -// ); -// return Err(StatusCode::NOT_FOUND); -// } -// }; - -// // Load the execution process -// let execution_process = match ExecutionProcess::find_by_id(&deployment.db().pool, process_id).await -// { -// Ok(Some(process)) => process, -// Ok(None) => { -// tracing::warn!("ExecutionProcess {} not found", process_id); -// return Err(StatusCode::NOT_FOUND); -// } -// Err(e) => { -// tracing::error!("Failed to fetch execution process {}: {}", process_id, e); -// return Err(StatusCode::INTERNAL_SERVER_ERROR); -// } -// }; - -// // Insert all models as extensions -// let mut request = request; -// request.extensions_mut().insert(context.project); -// request.extensions_mut().insert(context.task); -// request.extensions_mut().insert(context.task_attempt); -// request.extensions_mut().insert(execution_process); - -// // Continue with the next middleware/handler -// Ok(next.run(request).await) -// } - // Middleware that loads and injects Tag based on the tag_id path parameter pub async fn load_tag_middleware( State(deployment): State, diff --git a/crates/server/src/routes/auth.rs b/crates/server/src/routes/auth.rs deleted file mode 100644 index 77efcef2..00000000 --- a/crates/server/src/routes/auth.rs +++ /dev/null @@ -1,128 +0,0 @@ -use axum::{ - Router, - extract::{Request, State}, - http::StatusCode, - middleware::{Next, from_fn_with_state}, - response::{Json as ResponseJson, Response}, - routing::{get, post}, -}; -use deployment::Deployment; -use octocrab::auth::Continue; -use serde::{Deserialize, Serialize}; -use services::services::{ - auth::{AuthError, DeviceFlowStartResponse}, - config::save_config_to_file, - github_service::{GitHubService, GitHubServiceError}, -}; -use utils::response::ApiResponse; - -use crate::{DeploymentImpl, error::ApiError}; - -pub fn router(deployment: &DeploymentImpl) -> Router { - Router::new() - .route("/auth/github/device/start", post(device_start)) - .route("/auth/github/device/poll", post(device_poll)) - .route("/auth/github/check", get(github_check_token)) - .layer(from_fn_with_state( - deployment.clone(), - sentry_user_context_middleware, - )) -} - -/// POST /auth/github/device/start -async fn device_start( - State(deployment): State, -) -> Result>, ApiError> { - let device_start_response = deployment.auth().device_start().await?; - Ok(ResponseJson(ApiResponse::success(device_start_response))) -} - -#[derive(Serialize, Deserialize, ts_rs::TS)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[ts(use_ts_enum)] -pub enum DevicePollStatus { - SlowDown, - AuthorizationPending, - Success, -} - -#[derive(Serialize, Deserialize, ts_rs::TS)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[ts(use_ts_enum)] -pub enum CheckTokenResponse { - Valid, - Invalid, -} - -/// POST /auth/github/device/poll -async fn device_poll( - State(deployment): State, -) -> Result>, ApiError> { - let user_info = match deployment.auth().device_poll().await { - Ok(info) => info, - Err(AuthError::Pending(Continue::SlowDown)) => { - return Ok(ResponseJson(ApiResponse::success( - DevicePollStatus::SlowDown, - ))); - } - Err(AuthError::Pending(Continue::AuthorizationPending)) => { - return Ok(ResponseJson(ApiResponse::success( - DevicePollStatus::AuthorizationPending, - ))); - } - Err(e) => return Err(e.into()), - }; - // Save to config - { - let config_path = utils::assets::config_path(); - let mut config = deployment.config().write().await; - config.github.username = Some(user_info.username.clone()); - config.github.primary_email = user_info.primary_email.clone(); - config.github.oauth_token = Some(user_info.token.to_string()); - config.github_login_acknowledged = true; // Also acknowledge the GitHub login step - save_config_to_file(&config.clone(), &config_path).await?; - } - let _ = deployment.update_sentry_scope().await; - let props = serde_json::json!({ - "username": user_info.username, - "email": user_info.primary_email, - }); - deployment - .track_if_analytics_allowed("$identify", props) - .await; - Ok(ResponseJson(ApiResponse::success( - DevicePollStatus::Success, - ))) -} - -/// GET /auth/github/check -async fn github_check_token( - State(deployment): State, -) -> Result>, ApiError> { - let gh_config = deployment.config().read().await.github.clone(); - let Some(token) = gh_config.token() else { - return Ok(ResponseJson(ApiResponse::success( - CheckTokenResponse::Invalid, - ))); - }; - let gh = GitHubService::new(&token)?; - match gh.check_token().await { - Ok(()) => Ok(ResponseJson(ApiResponse::success( - CheckTokenResponse::Valid, - ))), - Err(GitHubServiceError::TokenInvalid) => Ok(ResponseJson(ApiResponse::success( - CheckTokenResponse::Invalid, - ))), - Err(e) => Err(e.into()), - } -} - -/// Middleware to set Sentry user context for every request -pub async fn sentry_user_context_middleware( - State(deployment): State, - req: Request, - next: Next, -) -> Result { - let _ = deployment.update_sentry_scope().await; - Ok(next.run(req).await) -} diff --git a/crates/server/src/routes/config.rs b/crates/server/src/routes/config.rs index 8f3a21ec..c2c0ec28 100644 --- a/crates/server/src/routes/config.rs +++ b/crates/server/src/routes/config.rs @@ -19,7 +19,7 @@ use serde_json::Value; use services::services::config::{Config, ConfigError, SoundFile, save_config_to_file}; use tokio::fs; use ts_rs::TS; -use utils::{assets::config_path, response::ApiResponse}; +use utils::{api::oauth::LoginStatus, assets::config_path, response::ApiResponse}; use crate::{DeploymentImpl, error::ApiError}; @@ -62,6 +62,7 @@ impl Environment { pub struct UserSystemInfo { pub config: Config, pub analytics_user_id: String, + pub login_status: LoginStatus, #[serde(flatten)] pub profiles: ExecutorConfigs, pub environment: Environment, @@ -75,10 +76,12 @@ async fn get_user_system_info( State(deployment): State, ) -> ResponseJson> { let config = deployment.config().read().await; + let login_status = deployment.get_login_status().await; let user_system_info = UserSystemInfo { config: config.clone(), analytics_user_id: deployment.user_id().to_string(), + login_status, profiles: ExecutorConfigs::get_cached(), environment: Environment::new(), capabilities: { @@ -144,25 +147,7 @@ async fn track_config_events(deployment: &DeploymentImpl, old: &Config, new: &Co }), ), ( - !old.github_login_acknowledged && new.github_login_acknowledged, - "onboarding_github_login_completed", - serde_json::json!({ - "username": new.github.username, - "email": new.github.primary_email, - "auth_method": if new.github.oauth_token.is_some() { "oauth" } - else if new.github.pat.is_some() { "pat" } - else { "none" }, - "has_default_pr_base": new.github.default_pr_base.is_some(), - "skipped": new.github.username.is_none() - }), - ), - ( - !old.telemetry_acknowledged && new.telemetry_acknowledged, - "onboarding_telemetry_choice", - serde_json::json!({}), - ), - ( - !old.analytics_enabled.unwrap_or(false) && new.analytics_enabled.unwrap_or(false), + !old.analytics_enabled && new.analytics_enabled, "analytics_session_start", serde_json::json!({}), ), diff --git a/crates/server/src/routes/github.rs b/crates/server/src/routes/github.rs deleted file mode 100644 index ee6233aa..00000000 --- a/crates/server/src/routes/github.rs +++ /dev/null @@ -1,212 +0,0 @@ -#![cfg(feature = "cloud")] - -use axum::{ - Json, Router, - extract::{Query, State}, - http::StatusCode, - response::Json as ResponseJson, - routing::{get, post}, -}; -use serde::Deserialize; -use ts_rs::TS; -use uuid::Uuid; - -use crate::{ - app_state::AppState, - models::{ - ApiResponse, - project::{CreateProject, Project}, - }, - services::{ - GitHubServiceError, - git_service::GitService, - github_service::{GitHubService, RepositoryInfo}, - }, -}; - -#[derive(Debug, Deserialize, TS)] -pub struct CreateProjectFromGitHub { - pub repository_id: i64, - pub name: String, - pub clone_url: String, - pub setup_script: Option, - pub dev_script: Option, - pub cleanup_script: Option, -} - -#[derive(serde::Deserialize)] -pub struct RepositoryQuery { - pub page: Option, -} - -/// List GitHub repositories for the authenticated user -pub async fn list_repositories( - State(app_state): State, - Query(params): Query, -) -> Result>>, StatusCode> { - let page = params.page.unwrap_or(1); - - // Get GitHub configuration - let github_config = { - let config = app_state.get_config().read().await; - config.github.clone() - }; - - // Check if GitHub is configured - if github_config.token.is_none() { - return Ok(ResponseJson(ApiResponse::error( - "GitHub token not configured. Please authenticate with GitHub first.", - ))); - } - - // Create GitHub service with token - let github_token = github_config.token.as_deref().unwrap(); - let github_service = match GitHubService::new(github_token) { - Ok(service) => service, - Err(e) => { - tracing::error!("Failed to create GitHub service: {}", e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - // List repositories - match github_service.list_repositories(page).await { - Ok(repositories) => { - tracing::info!( - "Retrieved {} repositories from GitHub (page {})", - repositories.len(), - page - ); - Ok(ResponseJson(ApiResponse::success(repositories))) - } - Err(GitHubServiceError::TokenInvalid) => Ok(ResponseJson(ApiResponse::error( - "GitHub token is invalid or expired. Please re-authenticate with GitHub.", - ))), - Err(e) => { - tracing::error!("Failed to list GitHub repositories: {}", e); - Ok(ResponseJson(ApiResponse::error(&format!( - "Failed to retrieve repositories: {}", - e - )))) - } - } -} - -/// Create a project from a GitHub repository -pub async fn create_project_from_github( - State(app_state): State, - Json(payload): Json, -) -> Result>, StatusCode> { - tracing::debug!("Creating project '{}' from GitHub repository", payload.name); - - // Get workspace path - let workspace_path = match app_state.get_workspace_path().await { - Ok(path) => path, - Err(e) => { - tracing::error!("Failed to get workspace path: {}", e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - }; - - let target_path = workspace_path.join(&payload.name); - - // Check if project directory already exists - if target_path.exists() { - return Ok(ResponseJson(ApiResponse::error( - "A project with this name already exists in the workspace", - ))); - } - - // Check if git repo path is already used by another project - match Project::find_by_git_repo_path(&app_state.db_pool, &target_path.to_string_lossy()).await { - Ok(Some(_)) => { - return Ok(ResponseJson(ApiResponse::error( - "A project with this git repository path already exists", - ))); - } - Ok(None) => { - // Path is available, continue - } - Err(e) => { - tracing::error!("Failed to check for existing git repo path: {}", e); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - } - - // Get GitHub token - let github_token = { - let config = app_state.get_config().read().await; - config.github.token.clone() - }; - - // Clone the repository - match GitService::clone_repository(&payload.clone_url, &target_path, github_token.as_deref()) { - Ok(_) => { - tracing::info!( - "Successfully cloned repository {} to {}", - payload.clone_url, - target_path.display() - ); - } - Err(e) => { - tracing::error!("Failed to clone repository: {}", e); - return Ok(ResponseJson(ApiResponse::error(&format!( - "Failed to clone repository: {}", - e - )))); - } - } - - // Create project record in database - let has_setup_script = payload.setup_script.is_some(); - let has_dev_script = payload.dev_script.is_some(); - let project_data = CreateProject { - name: payload.name.clone(), - git_repo_path: target_path.to_string_lossy().to_string(), - use_existing_repo: true, // Since we just cloned it - setup_script: payload.setup_script, - dev_script: payload.dev_script, - cleanup_script: payload.cleanup_script, - }; - - let project_id = Uuid::new_v4(); - match Project::create(&app_state.db_pool, &project_data, project_id).await { - Ok(project) => { - // Track project creation event - app_state - .track_analytics_event( - "project_created", - Some(serde_json::json!({ - "project_id": project.id.to_string(), - "repository_id": payload.repository_id, - "clone_url": payload.clone_url, - "has_setup_script": has_setup_script, - "has_dev_script": has_dev_script, - "trigger": "github", - })), - ) - .await; - - Ok(ResponseJson(ApiResponse::success(project))) - } - Err(e) => { - tracing::error!("Failed to create project: {}", e); - - // Clean up cloned repository if project creation failed - if target_path.exists() { - if let Err(cleanup_err) = std::fs::remove_dir_all(&target_path) { - tracing::error!("Failed to cleanup cloned repository: {}", cleanup_err); - } - } - - Err(StatusCode::INTERNAL_SERVER_ERROR) - } - } -} - -/// Create router for GitHub-related endpoints (only registered in cloud mode) -pub fn github_router() -> Router { - Router::new() - .route("/github/repositories", get(list_repositories)) - .route("/projects/from-github", post(create_project_from_github)) -} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 39b96913..71139432 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -1,13 +1,11 @@ use axum::{ Router, - middleware::from_fn_with_state, routing::{IntoMakeService, get}, }; use crate::DeploymentImpl; pub mod approvals; -pub mod auth; pub mod config; pub mod containers; pub mod filesystem; @@ -18,7 +16,10 @@ pub mod execution_processes; pub mod frontend; pub mod health; pub mod images; +pub mod oauth; +pub mod organizations; pub mod projects; +pub mod shared_tasks; pub mod tags; pub mod task_attempts; pub mod tasks; @@ -32,18 +33,16 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { .merge(projects::router(&deployment)) .merge(drafts::router(&deployment)) .merge(tasks::router(&deployment)) + .merge(shared_tasks::router()) .merge(task_attempts::router(&deployment)) .merge(execution_processes::router(&deployment)) .merge(tags::router(&deployment)) - .merge(auth::router(&deployment)) + .merge(oauth::router()) + .merge(organizations::router()) .merge(filesystem::router()) .merge(events::router(&deployment)) .merge(approvals::router()) .nest("/images", images::routes()) - .layer(from_fn_with_state( - deployment.clone(), - auth::sentry_user_context_middleware, - )) .with_state(deployment); Router::new() diff --git a/crates/server/src/routes/oauth.rs b/crates/server/src/routes/oauth.rs new file mode 100644 index 00000000..4c3e36ae --- /dev/null +++ b/crates/server/src/routes/oauth.rs @@ -0,0 +1,302 @@ +use axum::{ + Router, + extract::{Json, Query, State}, + http::{Response, StatusCode}, + response::Json as ResponseJson, + routing::{get, post}, +}; +use deployment::Deployment; +use rand::{Rng, distributions::Alphanumeric}; +use serde::{Deserialize, Serialize}; +use services::services::{config::save_config_to_file, oauth_credentials::Credentials}; +use sha2::{Digest, Sha256}; +use utils::{ + api::oauth::{HandoffInitRequest, HandoffRedeemRequest, StatusResponse}, + assets::config_path, + response::ApiResponse, +}; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +pub fn router() -> Router { + Router::new() + .route("/auth/handoff/init", post(handoff_init)) + .route("/auth/handoff/complete", get(handoff_complete)) + .route("/auth/logout", post(logout)) + .route("/auth/status", get(status)) +} + +#[derive(Debug, Deserialize)] +struct HandoffInitPayload { + provider: String, + return_to: String, +} + +#[derive(Debug, Serialize)] +struct HandoffInitResponseBody { + handoff_id: Uuid, + authorize_url: String, +} + +async fn handoff_init( + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let app_verifier = generate_secret(); + let app_challenge = hash_sha256_hex(&app_verifier); + + let request = HandoffInitRequest { + provider: payload.provider.clone(), + return_to: payload.return_to.clone(), + app_challenge, + }; + + let response = client.handoff_init(&request).await?; + + deployment + .store_oauth_handoff(response.handoff_id, payload.provider, app_verifier) + .await; + + Ok(ResponseJson(ApiResponse::success( + HandoffInitResponseBody { + handoff_id: response.handoff_id, + authorize_url: response.authorize_url, + }, + ))) +} + +#[derive(Debug, Deserialize)] +struct HandoffCompleteQuery { + handoff_id: Uuid, + #[serde(default)] + app_code: Option, + #[serde(default)] + error: Option, +} + +async fn handoff_complete( + State(deployment): State, + Query(query): Query, +) -> Result, ApiError> { + if let Some(error) = query.error { + return Ok(simple_html_response( + StatusCode::BAD_REQUEST, + format!("OAuth authorization failed: {error}"), + )); + } + + let Some(app_code) = query.app_code.clone() else { + return Ok(simple_html_response( + StatusCode::BAD_REQUEST, + "Missing app_code in callback".to_string(), + )); + }; + + let (provider, app_verifier) = match deployment.take_oauth_handoff(&query.handoff_id).await { + Some(state) => state, + None => { + tracing::warn!( + handoff_id = %query.handoff_id, + "received callback for unknown handoff" + ); + return Ok(simple_html_response( + StatusCode::BAD_REQUEST, + "OAuth handoff not found or already completed".to_string(), + )); + } + }; + + let client = deployment.remote_client()?; + + let redeem_request = HandoffRedeemRequest { + handoff_id: query.handoff_id, + app_code, + app_verifier, + }; + + let redeem = client.handoff_redeem(&redeem_request).await?; + + let credentials = Credentials { + access_token: redeem.access_token.clone(), + }; + + deployment + .auth_context() + .save_credentials(&credentials) + .await + .map_err(|e| { + tracing::error!(?e, "failed to save credentials"); + ApiError::Io(e) + })?; + + // Enable analytics automatically on login if not already enabled + let config_guard = deployment.config().read().await; + if !config_guard.analytics_enabled { + let mut new_config = config_guard.clone(); + drop(config_guard); // Release read lock before acquiring write lock + + new_config.analytics_enabled = true; + + // Save updated config to disk + let config_path = config_path(); + if let Err(e) = save_config_to_file(&new_config, &config_path).await { + tracing::warn!( + ?e, + "failed to save config after enabling analytics on login" + ); + } else { + // Update in-memory config + let mut config = deployment.config().write().await; + *config = new_config; + drop(config); + + tracing::info!("analytics automatically enabled after successful login"); + + // Track analytics_session_start event + if let Some(analytics) = deployment.analytics() { + analytics.track_event( + deployment.user_id(), + "analytics_session_start", + Some(serde_json::json!({})), + ); + } + } + } else { + drop(config_guard); + } + + // Fetch and cache the user's profile + let _ = deployment.get_login_status().await; + + // Start remote sync if not already running + { + let handle_guard = deployment.share_sync_handle().lock().await; + let should_start = handle_guard.is_none(); + drop(handle_guard); + + if should_start { + if let Some(share_config) = deployment.share_config() { + tracing::info!("Starting remote sync after login"); + deployment.spawn_remote_sync(share_config.clone()); + } else { + tracing::debug!( + "Share config not available; skipping remote sync spawn after login" + ); + } + } + } + + Ok(close_window_response(format!( + "Signed in with {provider}. You can return to the app." + ))) +} + +async fn logout(State(deployment): State) -> Result { + // Stop remote sync if running + if let Some(handle) = deployment.share_sync_handle().lock().await.take() { + tracing::info!("Stopping remote sync due to logout"); + handle.shutdown().await; + } + + let auth_context = deployment.auth_context(); + + if let Ok(client) = deployment.remote_client() { + let _ = client.logout().await; + } + + auth_context.clear_credentials().await.map_err(|e| { + tracing::error!(?e, "failed to clear credentials"); + ApiError::Io(e) + })?; + + auth_context.clear_profile().await; + + Ok(StatusCode::NO_CONTENT) +} + +async fn status( + State(deployment): State, +) -> Result>, ApiError> { + use utils::api::oauth::LoginStatus; + + match deployment.get_login_status().await { + LoginStatus::LoggedOut => Ok(ResponseJson(ApiResponse::success(StatusResponse { + logged_in: false, + profile: None, + degraded: None, + }))), + LoginStatus::LoggedIn { profile } => { + Ok(ResponseJson(ApiResponse::success(StatusResponse { + logged_in: true, + profile: Some(profile), + degraded: None, + }))) + } + } +} + +fn generate_secret() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect() +} + +fn hash_sha256_hex(input: &str) -> String { + let mut output = String::with_capacity(64); + let digest = Sha256::digest(input.as_bytes()); + for byte in digest { + use std::fmt::Write; + let _ = write!(output, "{:02x}", byte); + } + output +} + +fn simple_html_response(status: StatusCode, message: String) -> Response { + let body = format!( + "OAuth\ +

{}

", + message + ); + Response::builder() + .status(status) + .header("content-type", "text/html; charset=utf-8") + .body(body) + .unwrap() +} + +fn close_window_response(message: String) -> Response { + let body = format!( + "\ + \ + \ + \ + Authentication Complete\ + \ + \ + \ + \ +

{}

\ +

If this window does not close automatically, you may close it manually.

\ + \ + ", + message + ); + + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html; charset=utf-8") + .body(body) + .unwrap() +} diff --git a/crates/server/src/routes/organizations.rs b/crates/server/src/routes/organizations.rs new file mode 100644 index 00000000..69b286c2 --- /dev/null +++ b/crates/server/src/routes/organizations.rs @@ -0,0 +1,215 @@ +use axum::{ + Router, + extract::{Json, Path, State}, + http::StatusCode, + response::Json as ResponseJson, + routing::{delete, get, patch, post}, +}; +use utils::{ + api::{ + organizations::{ + AcceptInvitationResponse, CreateInvitationRequest, CreateInvitationResponse, + CreateOrganizationRequest, CreateOrganizationResponse, GetInvitationResponse, + GetOrganizationResponse, ListInvitationsResponse, ListMembersResponse, + ListOrganizationsResponse, Organization, RevokeInvitationRequest, + UpdateMemberRoleRequest, UpdateMemberRoleResponse, UpdateOrganizationRequest, + }, + projects::RemoteProject, + }, + response::ApiResponse, +}; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +pub fn router() -> Router { + Router::new() + .route("/organizations", get(list_organizations)) + .route("/organizations", post(create_organization)) + .route("/organizations/{id}", get(get_organization)) + .route("/organizations/{id}", patch(update_organization)) + .route("/organizations/{id}", delete(delete_organization)) + .route( + "/organizations/{org_id}/projects", + get(list_organization_projects), + ) + .route( + "/organizations/{org_id}/invitations", + post(create_invitation), + ) + .route("/organizations/{org_id}/invitations", get(list_invitations)) + .route( + "/organizations/{org_id}/invitations/revoke", + post(revoke_invitation), + ) + .route("/invitations/{token}", get(get_invitation)) + .route("/invitations/{token}/accept", post(accept_invitation)) + .route("/organizations/{org_id}/members", get(list_members)) + .route( + "/organizations/{org_id}/members/{user_id}", + delete(remove_member), + ) + .route( + "/organizations/{org_id}/members/{user_id}/role", + patch(update_member_role), + ) +} + +async fn list_organization_projects( + State(deployment): State, + Path(org_id): Path, +) -> Result>>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.list_projects(org_id).await?; + + Ok(ResponseJson(ApiResponse::success(response.projects))) +} + +async fn list_organizations( + State(deployment): State, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.list_organizations().await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn get_organization( + State(deployment): State, + Path(id): Path, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.get_organization(id).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn create_organization( + State(deployment): State, + Json(request): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.create_organization(&request).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn update_organization( + State(deployment): State, + Path(id): Path, + Json(request): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.update_organization(id, &request).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn delete_organization( + State(deployment): State, + Path(id): Path, +) -> Result { + let client = deployment.remote_client()?; + + client.delete_organization(id).await?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn create_invitation( + State(deployment): State, + Path(org_id): Path, + Json(request): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.create_invitation(org_id, &request).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn list_invitations( + State(deployment): State, + Path(org_id): Path, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.list_invitations(org_id).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn get_invitation( + State(deployment): State, + Path(token): Path, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.get_invitation(&token).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn revoke_invitation( + State(deployment): State, + Path(org_id): Path, + Json(payload): Json, +) -> Result { + let client = deployment.remote_client()?; + + client + .revoke_invitation(org_id, payload.invitation_id) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn accept_invitation( + State(deployment): State, + Path(invitation_token): Path, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.accept_invitation(&invitation_token).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn list_members( + State(deployment): State, + Path(org_id): Path, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.list_members(org_id).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} + +async fn remove_member( + State(deployment): State, + Path((org_id, user_id)): Path<(Uuid, Uuid)>, +) -> Result { + let client = deployment.remote_client()?; + + client.remove_member(org_id, user_id).await?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn update_member_role( + State(deployment): State, + Path((org_id, user_id)): Path<(Uuid, Uuid)>, + Json(request): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let response = client.update_member_role(org_id, user_id, &request).await?; + + Ok(ResponseJson(ApiResponse::success(response))) +} diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index 1d15ffbb..bbbaa30b 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -1,28 +1,48 @@ -use std::path::Path; +use std::path::Path as StdPath; use axum::{ Extension, Json, Router, - extract::{Query, State}, + extract::{Path, Query, State}, http::StatusCode, middleware::from_fn_with_state, response::Json as ResponseJson, routing::{get, post}, }; -use db::models::project::{ - CreateProject, Project, ProjectError, SearchMatchType, SearchResult, UpdateProject, +use db::models::{ + project::{CreateProject, Project, ProjectError, SearchMatchType, SearchResult, UpdateProject}, + task::Task, }; use deployment::Deployment; use ignore::WalkBuilder; +use serde::Deserialize; use services::services::{ file_ranker::FileRanker, file_search_cache::{CacheError, SearchMode, SearchQuery}, git::GitBranch, + remote_client::CreateRemoteProjectPayload, + share::link_shared_tasks_to_project, +}; +use ts_rs::TS; +use utils::{ + api::projects::{RemoteProject, RemoteProjectMembersResponse}, + path::expand_tilde, + response::ApiResponse, }; -use utils::{path::expand_tilde, response::ApiResponse}; use uuid::Uuid; use crate::{DeploymentImpl, error::ApiError, middleware::load_project_middleware}; +#[derive(Deserialize, TS)] +pub struct LinkToExistingRequest { + pub remote_project_id: Uuid, +} + +#[derive(Deserialize, TS)] +pub struct CreateRemoteProjectRequest { + pub organization_id: Uuid, + pub name: String, +} + pub async fn get_projects( State(deployment): State, ) -> Result>>, ApiError> { @@ -44,6 +64,127 @@ pub async fn get_project_branches( Ok(ResponseJson(ApiResponse::success(branches))) } +pub async fn link_project_to_existing_remote( + Path(project_id): Path, + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let remote_project = client.get_project(payload.remote_project_id).await?; + + let updated_project = + apply_remote_project_link(&deployment, project_id, remote_project).await?; + + Ok(ResponseJson(ApiResponse::success(updated_project))) +} + +pub async fn create_and_link_remote_project( + Path(project_id): Path, + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let repo_name = payload.name.trim().to_string(); + if repo_name.trim().is_empty() { + return Err(ApiError::Conflict( + "Remote project name cannot be empty.".to_string(), + )); + } + + let client = deployment.remote_client()?; + + let remote_project = client + .create_project(&CreateRemoteProjectPayload { + organization_id: payload.organization_id, + name: repo_name, + metadata: None, + }) + .await?; + + let updated_project = + apply_remote_project_link(&deployment, project_id, remote_project).await?; + + Ok(ResponseJson(ApiResponse::success(updated_project))) +} + +pub async fn unlink_project( + Extension(project): Extension, + State(deployment): State, +) -> Result>, ApiError> { + let pool = &deployment.db().pool; + + if let Some(remote_project_id) = project.remote_project_id { + let mut tx = pool.begin().await?; + + Task::clear_shared_task_ids_for_remote_project(&mut *tx, remote_project_id).await?; + + Project::set_remote_project_id_tx(&mut *tx, project.id, None).await?; + + tx.commit().await?; + } + + let updated_project = Project::find_by_id(pool, project.id) + .await? + .ok_or(ProjectError::ProjectNotFound)?; + + Ok(ResponseJson(ApiResponse::success(updated_project))) +} + +pub async fn get_remote_project_by_id( + State(deployment): State, + Path(remote_project_id): Path, +) -> Result>, ApiError> { + let client = deployment.remote_client()?; + + let remote_project = client.get_project(remote_project_id).await?; + + Ok(ResponseJson(ApiResponse::success(remote_project))) +} + +pub async fn get_project_remote_members( + State(deployment): State, + Extension(project): Extension, +) -> Result>, ApiError> { + let remote_project_id = project.remote_project_id.ok_or_else(|| { + ApiError::Conflict("Project is not linked to a remote project".to_string()) + })?; + + let client = deployment.remote_client()?; + + let remote_project = client.get_project(remote_project_id).await?; + let members = client + .list_members(remote_project.organization_id) + .await? + .members; + + Ok(ResponseJson(ApiResponse::success( + RemoteProjectMembersResponse { + organization_id: remote_project.organization_id, + members, + }, + ))) +} + +async fn apply_remote_project_link( + deployment: &DeploymentImpl, + project_id: Uuid, + remote_project: RemoteProject, +) -> Result { + let pool = &deployment.db().pool; + + Project::set_remote_project_id(pool, project_id, Some(remote_project.id)).await?; + + let updated_project = Project::find_by_id(pool, project_id) + .await? + .ok_or(ProjectError::ProjectNotFound)?; + + let current_profile = deployment.auth_context().cached_profile().await; + let current_user_id = current_profile.as_ref().map(|p| p.user_id); + link_shared_tasks_to_project(pool, current_user_id, project_id, remote_project.id).await?; + + Ok(updated_project) +} + pub async fn create_project( State(deployment): State, Json(payload): Json, @@ -381,7 +522,7 @@ async fn search_files_in_repo( query: &str, mode: SearchMode, ) -> Result, Box> { - let repo_path = Path::new(repo_path); + let repo_path = StdPath::new(repo_path); if !repo_path.exists() { return Err("Repository path does not exist".into()); @@ -510,9 +651,15 @@ pub fn router(deployment: &DeploymentImpl) -> Router { "/", get(get_project).put(update_project).delete(delete_project), ) + .route("/remote/members", get(get_project_remote_members)) .route("/branches", get(get_project_branches)) .route("/search", get(search_project_files)) .route("/open-editor", post(open_project_in_editor)) + .route( + "/link", + post(link_project_to_existing_remote).delete(unlink_project), + ) + .route("/link/create", post(create_and_link_remote_project)) .layer(from_fn_with_state( deployment.clone(), load_project_middleware, @@ -522,5 +669,8 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/", get(get_projects).post(create_project)) .nest("/{id}", project_id_router); - Router::new().nest("/projects", projects_router) + Router::new().nest("/projects", projects_router).route( + "/remote-projects/{remote_project_id}", + get(get_remote_project_by_id), + ) } diff --git a/crates/server/src/routes/shared_tasks.rs b/crates/server/src/routes/shared_tasks.rs new file mode 100644 index 00000000..b0dd9afd --- /dev/null +++ b/crates/server/src/routes/shared_tasks.rs @@ -0,0 +1,93 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + response::Json as ResponseJson, + routing::{delete, post}, +}; +use db::models::shared_task::SharedTask; +use deployment::Deployment; +use serde::{Deserialize, Serialize}; +use services::services::share::ShareError; +use ts_rs::TS; +use utils::response::ApiResponse; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +#[derive(Debug, Clone, Deserialize, TS)] +#[ts(export)] +pub struct AssignSharedTaskRequest { + pub new_assignee_user_id: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, TS)] +#[ts(export)] +pub struct AssignSharedTaskResponse { + pub shared_task: SharedTask, +} + +pub fn router() -> Router { + Router::new() + .route( + "/shared-tasks/{shared_task_id}/assign", + post(assign_shared_task), + ) + .route("/shared-tasks/{shared_task_id}", delete(delete_shared_task)) +} + +pub async fn assign_shared_task( + Path(shared_task_id): Path, + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let Ok(publisher) = deployment.share_publisher() else { + return Err(ShareError::MissingConfig("share publisher unavailable").into()); + }; + + let shared_task = SharedTask::find_by_id(&deployment.db().pool, shared_task_id) + .await? + .ok_or_else(|| ApiError::Conflict("shared task not found".into()))?; + + let updated_shared_task = publisher + .assign_shared_task( + &shared_task, + payload.new_assignee_user_id.clone(), + payload.version, + ) + .await?; + + let props = serde_json::json!({ + "shared_task_id": shared_task_id, + "new_assignee_user_id": payload.new_assignee_user_id, + }); + deployment + .track_if_analytics_allowed("reassign_shared_task", props) + .await; + + Ok(ResponseJson(ApiResponse::success( + AssignSharedTaskResponse { + shared_task: updated_shared_task, + }, + ))) +} + +pub async fn delete_shared_task( + Path(shared_task_id): Path, + State(deployment): State, +) -> Result>, ApiError> { + let Ok(publisher) = deployment.share_publisher() else { + return Err(ShareError::MissingConfig("share publisher unavailable").into()); + }; + + publisher.delete_shared_task(shared_task_id).await?; + + let props = serde_json::json!({ + "shared_task_id": shared_task_id, + }); + deployment + .track_if_analytics_allowed("stop_sharing_task", props) + .await; + + Ok(ResponseJson(ApiResponse::success(()))) +} diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 8c12a7a3..e512ff79 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -1,5 +1,6 @@ pub mod cursor_setup; pub mod drafts; +pub mod gh_cli_setup; pub mod util; use axum::{ @@ -35,6 +36,7 @@ use git2::BranchType; use serde::{Deserialize, Serialize}; use services::services::{ container::ContainerService, + gh_cli::GhCli, git::{ConflictOp, WorktreeResetOptions}, github_service::{CreatePrRequest, GitHubService, GitHubServiceError}, }; @@ -47,7 +49,10 @@ use crate::{ DeploymentImpl, error::ApiError, middleware::load_task_attempt_middleware, - routes::task_attempts::util::{ensure_worktree_path, handle_images_for_prompt}, + routes::task_attempts::{ + gh_cli_setup::GhCliSetupError, + util::{ensure_worktree_path, handle_images_for_prompt}, + }, }; #[derive(Debug, Deserialize, Serialize, TS)] @@ -690,6 +695,22 @@ pub async fn merge_task_attempt( .await?; Task::update_status(pool, ctx.task.id, TaskStatus::Done).await?; + // Try broadcast update to other users in organization + if let Ok(publisher) = deployment.share_publisher() { + if let Err(err) = publisher.update_shared_task_by_id(ctx.task.id).await { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + ctx.task.id + ); + } + } else { + tracing::debug!( + "Share publisher unavailable; skipping remote update for {}", + ctx.task.id + ); + } + deployment .track_if_analytics_allowed( "task_attempt_merged", @@ -708,19 +729,14 @@ pub async fn push_task_attempt_branch( Extension(task_attempt): Extension, State(deployment): State, ) -> Result>, ApiError> { - let github_config = deployment.config().read().await.github.clone(); - let Some(github_token) = github_config.token() else { - return Err(GitHubServiceError::TokenInvalid.into()); - }; - - let github_service = GitHubService::new(&github_token)?; + let github_service = GitHubService::new()?; github_service.check_token().await?; let ws_path = ensure_worktree_path(&deployment, &task_attempt).await?; deployment .git() - .push_to_github(&ws_path, &task_attempt.branch, &github_token)?; + .push_to_github(&ws_path, &task_attempt.branch)?; Ok(ResponseJson(ApiResponse::success(()))) } @@ -730,13 +746,6 @@ pub async fn create_github_pr( Json(request): Json, ) -> Result>, ApiError> { let github_config = deployment.config().read().await.github.clone(); - let Some(github_token) = github_config.token() else { - return Ok(ResponseJson(ApiResponse::error_with_data( - GitHubServiceError::TokenInvalid, - ))); - }; - // Create GitHub service instance - let github_service = GitHubService::new(&github_token)?; // Get the task attempt to access the stored target branch let target_branch = request.target_branch.unwrap_or_else(|| { // Use the stored target branch from the task attempt as the default @@ -763,10 +772,9 @@ pub async fn create_github_pr( let workspace_path = ensure_worktree_path(&deployment, &task_attempt).await?; // Push the branch to GitHub first - if let Err(e) = - deployment - .git() - .push_to_github(&workspace_path, &task_attempt.branch, &github_token) + if let Err(e) = deployment + .git() + .push_to_github(&workspace_path, &task_attempt.branch) { tracing::error!("Failed to push branch to GitHub: {}", e); let gh_e = GitHubServiceError::from(e); @@ -810,7 +818,9 @@ pub async fn create_github_pr( .git() .get_github_repo_info(&project.git_repo_path)?; - match github_service.create_pr(&repo_info, &pr_request).await { + // Use gh CLI to create the PR (uses native GitHub authentication) + let gh_cli = GhCli::new(); + match gh_cli.create_pr(&pr_request, &repo_info) { Ok(pr_info) => { // Update the task attempt with PR information if let Err(e) = Merge::create_pr( @@ -848,11 +858,12 @@ pub async fn create_github_pr( task_attempt.id, e ); - if e.is_api_data() { - Ok(ResponseJson(ApiResponse::error_with_data(e))) + let gh_error = GitHubServiceError::from(e); + if gh_error.is_api_data() { + Ok(ResponseJson(ApiResponse::error_with_data(gh_error))) } else { Ok(ResponseJson(ApiResponse::error( - format!("Failed to create PR: {}", e).as_str(), + format!("Failed to create PR: {}", gh_error).as_str(), ))) } } @@ -1010,16 +1021,11 @@ pub async fn get_task_attempt_branch_status( (Some(a), Some(b)) } BranchType::Remote => { - let github_config = deployment.config().read().await.github.clone(); - let token = github_config - .token() - .ok_or(ApiError::GitHubService(GitHubServiceError::TokenInvalid))?; let (remote_commits_ahead, remote_commits_behind) = deployment.git().get_remote_branch_status( &ctx.project.git_repo_path, &task_attempt.branch, Some(&task_attempt.target_branch), - token, )?; (Some(remote_commits_ahead), Some(remote_commits_behind)) } @@ -1035,17 +1041,9 @@ pub async fn get_task_attempt_branch_status( })) = merges.first() { // check remote status if the attempt has an open PR - let github_config = deployment.config().read().await.github.clone(); - let token = github_config - .token() - .ok_or(ApiError::GitHubService(GitHubServiceError::TokenInvalid))?; - let (remote_commits_ahead, remote_commits_behind) = - deployment.git().get_remote_branch_status( - &ctx.project.git_repo_path, - &task_attempt.branch, - None, - token, - )?; + let (remote_commits_ahead, remote_commits_behind) = deployment + .git() + .get_remote_branch_status(&ctx.project.git_repo_path, &task_attempt.branch, None)?; (Some(remote_commits_ahead), Some(remote_commits_behind)) } else { (None, None) @@ -1263,7 +1261,6 @@ pub async fn rebase_task_attempt( let new_base_branch = payload .new_base_branch .unwrap_or(task_attempt.target_branch.clone()); - let github_config = deployment.config().read().await.github.clone(); let pool = &deployment.db().pool; @@ -1304,7 +1301,6 @@ pub async fn rebase_task_attempt( &new_base_branch, &old_base_branch, &task_attempt.branch.clone(), - github_config.token(), ); if let Err(e) = result { use services::services::git::GitServiceError; @@ -1554,12 +1550,6 @@ pub async fn attach_existing_pr( }))); } - // Get GitHub token - let github_config = deployment.config().read().await.github.clone(); - let Some(github_token) = github_config.token() else { - return Err(ApiError::GitHubService(GitHubServiceError::TokenInvalid)); - }; - // Get project and repo info let Some(task) = task_attempt.parent_task(pool).await? else { return Err(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound)); @@ -1568,7 +1558,7 @@ pub async fn attach_existing_pr( return Err(ApiError::Project(ProjectError::ProjectNotFound)); }; - let github_service = GitHubService::new(&github_token)?; + let github_service = GitHubService::new()?; let repo_info = deployment .git() .get_github_repo_info(&project.git_repo_path)?; @@ -1604,6 +1594,22 @@ pub async fn attach_existing_pr( // If PR is merged, mark task as done if matches!(pr_info.status, MergeStatus::Merged) { Task::update_status(pool, task.id, TaskStatus::Done).await?; + + // Try broadcast update to other users in organization + if let Ok(publisher) = deployment.share_publisher() { + if let Err(err) = publisher.update_shared_task_by_id(task.id).await { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + task.id + ); + } + } else { + tracing::debug!( + "Share publisher unavailable; skipping remote update for {}", + task.id + ); + } } Ok(ResponseJson(ApiResponse::success(AttachPrResponse { @@ -1622,11 +1628,49 @@ pub async fn attach_existing_pr( } } +#[axum::debug_handler] +pub async fn gh_cli_setup_handler( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + match gh_cli_setup::run_gh_cli_setup(&deployment, &task_attempt).await { + Ok(execution_process) => { + deployment + .track_if_analytics_allowed( + "gh_cli_setup_executed", + serde_json::json!({ + "attempt_id": task_attempt.id.to_string(), + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(execution_process))) + } + Err(ApiError::Executor(ExecutorError::ExecutableNotFound { program })) + if program == "brew" => + { + Ok(ResponseJson(ApiResponse::error_with_data( + GhCliSetupError::BrewMissing, + ))) + } + Err(ApiError::Executor(ExecutorError::SetupHelperNotSupported)) => Ok(ResponseJson( + ApiResponse::error_with_data(GhCliSetupError::SetupHelperNotSupported), + )), + Err(ApiError::Executor(err)) => Ok(ResponseJson(ApiResponse::error_with_data( + GhCliSetupError::Other { + message: err.to_string(), + }, + ))), + Err(err) => Err(err), + } +} + pub fn router(deployment: &DeploymentImpl) -> Router { let task_attempt_id_router = Router::new() .route("/", get(get_task_attempt)) .route("/follow-up", post(follow_up)) .route("/run-agent-setup", post(run_agent_setup)) + .route("/gh-cli-setup", post(gh_cli_setup_handler)) .route( "/draft", get(drafts::get_draft) diff --git a/crates/server/src/routes/task_attempts/gh_cli_setup.rs b/crates/server/src/routes/task_attempts/gh_cli_setup.rs new file mode 100644 index 00000000..e24af967 --- /dev/null +++ b/crates/server/src/routes/task_attempts/gh_cli_setup.rs @@ -0,0 +1,106 @@ +use db::models::{ + execution_process::{ExecutionProcess, ExecutionProcessRunReason}, + task_attempt::TaskAttempt, +}; +use deployment::Deployment; +use executors::actions::ExecutorAction; +#[cfg(unix)] +use executors::{ + actions::{ + ExecutorActionType, + script::{ScriptContext, ScriptRequest, ScriptRequestLanguage}, + }, + executors::ExecutorError, +}; +use serde::{Deserialize, Serialize}; +use services::services::container::ContainerService; +use ts_rs::TS; + +use crate::{error::ApiError, routes::task_attempts::ensure_worktree_path}; + +#[derive(Debug, Serialize, Deserialize, TS)] +#[ts(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum GhCliSetupError { + BrewMissing, + SetupHelperNotSupported, + Other { message: String }, +} + +pub async fn run_gh_cli_setup( + deployment: &crate::DeploymentImpl, + task_attempt: &TaskAttempt, +) -> Result { + let executor_action = get_gh_cli_setup_helper_action().await?; + + let _ = ensure_worktree_path(deployment, task_attempt).await?; + + let execution_process = deployment + .container() + .start_execution( + task_attempt, + &executor_action, + &ExecutionProcessRunReason::SetupScript, + ) + .await?; + Ok(execution_process) +} + +async fn get_gh_cli_setup_helper_action() -> Result { + #[cfg(unix)] + { + use utils::shell::resolve_executable_path; + + if resolve_executable_path("brew").await.is_none() { + return Err(ApiError::Executor(ExecutorError::ExecutableNotFound { + program: "brew".to_string(), + })); + } + + // Install script + let install_script = r#"#!/bin/bash +set -e +if ! command -v gh &> /dev/null; then + echo "Installing GitHub CLI..." + brew install gh + echo "Installation complete!" +else + echo "GitHub CLI already installed" +fi"# + .to_string(); + + let install_request = ScriptRequest { + script: install_script, + language: ScriptRequestLanguage::Bash, + context: ScriptContext::GithubCliSetupScript, + }; + + // Auth script + let auth_script = r#"#!/bin/bash +set -e +export GH_PROMPT_DISABLED=1 +gh auth login --web --git-protocol https --skip-ssh-key +"# + .to_string(); + + let auth_request = ScriptRequest { + script: auth_script, + language: ScriptRequestLanguage::Bash, + context: ScriptContext::GithubCliSetupScript, + }; + + // Chain them: install → auth + Ok(ExecutorAction::new( + ExecutorActionType::ScriptRequest(install_request), + Some(Box::new(ExecutorAction::new( + ExecutorActionType::ScriptRequest(auth_request), + None, + ))), + )) + } + + #[cfg(not(unix))] + { + use executors::executors::ExecutorError::SetupHelperNotSupported; + Err(ApiError::Executor(SetupHelperNotSupported)) + } +} diff --git a/crates/server/src/routes/tasks.rs b/crates/server/src/routes/tasks.rs index 3b02e902..655b20da 100644 --- a/crates/server/src/routes/tasks.rs +++ b/crates/server/src/routes/tasks.rs @@ -10,7 +10,7 @@ use axum::{ http::StatusCode, middleware::from_fn_with_state, response::{IntoResponse, Json as ResponseJson}, - routing::{get, post}, + routing::{delete, get, post, put}, }; use db::models::{ image::TaskImage, @@ -21,8 +21,9 @@ use deployment::Deployment; use executors::profile::ExecutorProfileId; use futures_util::{SinkExt, StreamExt, TryStreamExt}; use serde::{Deserialize, Serialize}; -use services::services::container::{ - ContainerService, WorktreeCleanupData, cleanup_worktrees_direct, +use services::services::{ + container::{ContainerService, WorktreeCleanupData, cleanup_worktrees_direct}, + share::ShareError, }; use sqlx::Error as SqlxError; use ts_rs::TS; @@ -215,6 +216,7 @@ pub async fn create_task_and_start( pub async fn update_task( Extension(existing_task): Extension, State(deployment): State, + Json(payload): Json, ) -> Result>, ApiError> { // Use existing values if not provided in update @@ -245,6 +247,14 @@ pub async fn update_task( TaskImage::associate_many_dedup(&deployment.db().pool, task.id, image_ids).await?; } + // If task has been shared, broadcast update + if task.shared_task_id.is_some() { + let Ok(publisher) = deployment.share_publisher() else { + return Err(ShareError::MissingConfig("share publisher unavailable").into()); + }; + publisher.update_shared_task(&task).await?; + } + Ok(ResponseJson(ApiResponse::success(task))) } @@ -289,6 +299,13 @@ pub async fn delete_task( }) .collect(); + if let Some(shared_task_id) = task.shared_task_id { + let Ok(publisher) = deployment.share_publisher() else { + return Err(ShareError::MissingConfig("share publisher unavailable").into()); + }; + publisher.delete_shared_task(shared_task_id).await?; + } + // Use a transaction to ensure atomicity: either all operations succeed or all are rolled back let mut tx = deployment.db().pool.begin().await?; @@ -356,9 +373,47 @@ pub async fn delete_task( Ok((StatusCode::ACCEPTED, ResponseJson(ApiResponse::success(())))) } +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct ShareTaskResponse { + pub shared_task_id: Uuid, +} + +pub async fn share_task( + Extension(task): Extension, + State(deployment): State, +) -> Result>, ApiError> { + let Ok(publisher) = deployment.share_publisher() else { + return Err(ShareError::MissingConfig("share publisher unavailable").into()); + }; + let profile = deployment + .auth_context() + .cached_profile() + .await + .ok_or(ShareError::MissingAuth)?; + let shared_task_id = publisher.share_task(task.id, profile.user_id).await?; + + let props = serde_json::json!({ + "task_id": task.id, + "shared_task_id": shared_task_id, + }); + deployment + .track_if_analytics_allowed("start_sharing_task", props) + .await; + + Ok(ResponseJson(ApiResponse::success(ShareTaskResponse { + shared_task_id, + }))) +} + pub fn router(deployment: &DeploymentImpl) -> Router { + let task_actions_router = Router::new() + .route("/", put(update_task)) + .route("/", delete(delete_task)) + .route("/share", post(share_task)); + let task_id_router = Router::new() - .route("/", get(get_task).put(update_task).delete(delete_task)) + .route("/", get(get_task)) + .merge(task_actions_router) .layer(from_fn_with_state(deployment.clone(), load_task_middleware)); let inner = Router::new() diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 0e5cbe8d..7e3b2eea 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -11,33 +11,28 @@ cloud = [] utils = { path = "../utils" } executors = { path = "../executors" } db = { path = "../db" } +remote = { path = "../remote" } tokio = { workspace = true } tokio-util = { version = "0.7", features = ["io"] } axum = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +url = "2.5" anyhow = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { workspace = true } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "sqlite-preupdate-hook", "chrono", "uuid"] } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } dirs = "5.0" -xdg = "3.0" git2 = "0.18" tempfile = "3.21" -async-trait = "0.1" -libc = "0.2" +async-trait = { workspace = true } rust-embed = "8.2" -directories = "6.0.0" -open = "5.3.2" ignore = "0.4" -command-group = { version = "5.0", features = ["with-tokio"] } openssl-sys = { workspace = true } regex = "1.11.1" notify-rust = "4.11" -octocrab = "0.44" os_info = "3.12.0" reqwest = { version = "0.12", features = ["json"] } lazy_static = "1.4" @@ -48,14 +43,18 @@ base64 = "0.22" thiserror = { workspace = true } futures = "0.3.31" tokio-stream = "0.1.17" -secrecy = "0.10.3" strum_macros = "0.27.2" strum = "0.27.2" notify = "8.2.0" notify-debouncer-full = "0.5.0" +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } dunce = "1.0" dashmap = "6.1" once_cell = "1.20" sha2 = "0.10" fst = "0.4" +secrecy = "0.10.3" moka = { version = "0.12", features = ["future"] } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "2" diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 4e379ae7..fa09461b 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -1 +1,3 @@ pub mod services; + +pub use services::remote_client::{HandoffErrorCode, RemoteClient, RemoteClientError}; diff --git a/crates/services/src/services/auth.rs b/crates/services/src/services/auth.rs index 088fca4b..bc208be3 100644 --- a/crates/services/src/services/auth.rs +++ b/crates/services/src/services/auth.rs @@ -1,131 +1,45 @@ use std::sync::Arc; -use anyhow::Error as AnyhowError; -use axum::http::{HeaderName, header::ACCEPT}; -use octocrab::{ - OctocrabBuilder, - auth::{Continue, DeviceCodes, OAuth}, -}; -use secrecy::{ExposeSecret, SecretString}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; use tokio::sync::RwLock; -use ts_rs::TS; +use utils::api::oauth::ProfileResponse; + +use super::oauth_credentials::{Credentials, OAuthCredentials}; #[derive(Clone)] -pub struct AuthService { - pub client_id: String, - pub device_codes: Arc>>, +pub struct AuthContext { + oauth: Arc, + profile: Arc>>, } -#[derive(Debug, Error)] -pub enum AuthError { - #[error(transparent)] - GitHubClient(#[from] octocrab::Error), - #[error(transparent)] - Parse(#[from] serde_json::Error), - #[error("Device flow not started")] - DeviceFlowNotStarted, - #[error("Device flow pending")] - Pending(Continue), - #[error(transparent)] - Other(#[from] AnyhowError), -} - -#[derive(Serialize, Deserialize, TS)] -pub struct DeviceFlowStartResponse { - pub user_code: String, - pub verification_uri: String, - pub expires_in: u32, - pub interval: u32, -} - -pub struct UserInfo { - pub username: String, - pub primary_email: Option, - pub token: String, -} - -#[derive(Deserialize)] -pub struct GitHubEmailEntry { - pub email: String, - pub primary: bool, -} - -impl Default for AuthService { - fn default() -> Self { - Self::new() - } -} - -impl AuthService { - pub fn new() -> Self { - let client_id_str = option_env!("GITHUB_CLIENT_ID").unwrap_or("Ov23li9bxz3kKfPOIsGm"); - AuthService { - client_id: client_id_str.to_string(), - device_codes: Arc::new(RwLock::new(None)), // Initially no device codes - } +impl AuthContext { + pub fn new( + oauth: Arc, + profile: Arc>>, + ) -> Self { + Self { oauth, profile } } - pub async fn device_start(&self) -> Result { - let client = OctocrabBuilder::new() - .base_uri("https://github.com")? - .add_header(ACCEPT, "application/json".to_string()) - .build()?; - let device_codes = client - .authenticate_as_device( - &SecretString::from(self.client_id.clone()), - ["user:email", "repo"], - ) - .await?; - self.device_codes - .write() - .await - .replace(device_codes.clone()); // Store the device codes for later polling - Ok(DeviceFlowStartResponse { - user_code: device_codes.user_code, - verification_uri: device_codes.verification_uri, - expires_in: device_codes.expires_in as u32, - interval: device_codes.interval as u32, - }) + pub async fn get_credentials(&self) -> Option { + self.oauth.get().await } - pub async fn device_poll(&self) -> Result { - let device_codes = { - let guard = self.device_codes.read().await; - guard - .as_ref() - .ok_or(AuthError::DeviceFlowNotStarted)? - .clone() - }; - let client = OctocrabBuilder::new() - .base_uri("https://github.com")? - .add_header(ACCEPT, "application/json".to_string()) - .build()?; - let poll_response = device_codes - .poll_once(&client, &SecretString::from(self.client_id.clone())) - .await?; - let access_token = poll_response.either( - |OAuth { access_token, .. }| Ok(access_token), - |c| Err(AuthError::Pending(c)), - )?; - let client = OctocrabBuilder::new() - .add_header( - HeaderName::try_from("User-Agent").unwrap(), - "vibe-kanban-app".to_string(), - ) - .personal_token(access_token.clone()) - .build()?; - let user = client.current().user().await?; - let emails: Vec = client.get("/user/emails", None::<&()>).await?; - let primary_email = emails - .iter() - .find(|entry| entry.primary) - .map(|entry| entry.email.clone()); - Ok(UserInfo { - username: user.login, - primary_email, - token: access_token.expose_secret().to_string(), - }) + pub async fn save_credentials(&self, creds: &Credentials) -> std::io::Result<()> { + self.oauth.save(creds).await + } + + pub async fn clear_credentials(&self) -> std::io::Result<()> { + self.oauth.clear().await + } + + pub async fn cached_profile(&self) -> Option { + self.profile.read().await.clone() + } + + pub async fn set_profile(&self, profile: ProfileResponse) { + *self.profile.write().await = Some(profile) + } + + pub async fn clear_profile(&self) { + *self.profile.write().await = None } } diff --git a/crates/services/src/services/config/mod.rs b/crates/services/src/services/config/mod.rs index 358266e8..9b1018ce 100644 --- a/crates/services/src/services/config/mod.rs +++ b/crates/services/src/services/config/mod.rs @@ -14,15 +14,15 @@ pub enum ConfigError { ValidationError(String), } -pub type Config = versions::v7::Config; -pub type NotificationConfig = versions::v7::NotificationConfig; -pub type EditorConfig = versions::v7::EditorConfig; -pub type ThemeMode = versions::v7::ThemeMode; -pub type SoundFile = versions::v7::SoundFile; -pub type EditorType = versions::v7::EditorType; -pub type GitHubConfig = versions::v7::GitHubConfig; -pub type UiLanguage = versions::v7::UiLanguage; -pub type ShowcaseState = versions::v7::ShowcaseState; +pub type Config = versions::v8::Config; +pub type NotificationConfig = versions::v8::NotificationConfig; +pub type EditorConfig = versions::v8::EditorConfig; +pub type ThemeMode = versions::v8::ThemeMode; +pub type SoundFile = versions::v8::SoundFile; +pub type EditorType = versions::v8::EditorType; +pub type GitHubConfig = versions::v8::GitHubConfig; +pub type UiLanguage = versions::v8::UiLanguage; +pub type ShowcaseState = versions::v8::ShowcaseState; /// Will always return config, trying old schemas or eventually returning default pub async fn load_config_from_file(config_path: &PathBuf) -> Config { diff --git a/crates/services/src/services/config/versions/mod.rs b/crates/services/src/services/config/versions/mod.rs index 9e0985bf..720164f7 100644 --- a/crates/services/src/services/config/versions/mod.rs +++ b/crates/services/src/services/config/versions/mod.rs @@ -5,3 +5,4 @@ pub(super) mod v4; pub(super) mod v5; pub(super) mod v6; pub(super) mod v7; +pub(super) mod v8; diff --git a/crates/services/src/services/config/versions/v7.rs b/crates/services/src/services/config/versions/v7.rs index 524cf804..c0c09c2d 100644 --- a/crates/services/src/services/config/versions/v7.rs +++ b/crates/services/src/services/config/versions/v7.rs @@ -35,6 +35,8 @@ pub struct Config { pub disclaimer_acknowledged: bool, pub onboarding_acknowledged: bool, pub github_login_acknowledged: bool, + #[serde(default)] + pub login_acknowledged: bool, pub telemetry_acknowledged: bool, pub notifications: NotificationConfig, pub editor: EditorConfig, @@ -88,6 +90,7 @@ impl Config { disclaimer_acknowledged: old_config.disclaimer_acknowledged, onboarding_acknowledged: old_config.onboarding_acknowledged, github_login_acknowledged: old_config.github_login_acknowledged, + login_acknowledged: false, telemetry_acknowledged: old_config.telemetry_acknowledged, notifications: old_config.notifications, editor: old_config.editor, @@ -133,6 +136,7 @@ impl Default for Config { disclaimer_acknowledged: false, onboarding_acknowledged: false, github_login_acknowledged: false, + login_acknowledged: false, telemetry_acknowledged: false, notifications: NotificationConfig::default(), editor: EditorConfig::default(), diff --git a/crates/services/src/services/config/versions/v8.rs b/crates/services/src/services/config/versions/v8.rs new file mode 100644 index 00000000..320f6820 --- /dev/null +++ b/crates/services/src/services/config/versions/v8.rs @@ -0,0 +1,109 @@ +use anyhow::Error; +use executors::{executors::BaseCodingAgent, profile::ExecutorProfileId}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +pub use v7::{ + EditorConfig, EditorType, GitHubConfig, NotificationConfig, ShowcaseState, SoundFile, + ThemeMode, UiLanguage, +}; + +use crate::services::config::versions::v7; + +fn default_git_branch_prefix() -> String { + "vk".to_string() +} + +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +pub struct Config { + pub config_version: String, + pub theme: ThemeMode, + pub executor_profile: ExecutorProfileId, + pub disclaimer_acknowledged: bool, + pub onboarding_acknowledged: bool, + pub notifications: NotificationConfig, + pub editor: EditorConfig, + pub github: GitHubConfig, + pub analytics_enabled: bool, + pub workspace_dir: Option, + pub last_app_version: Option, + pub show_release_notes: bool, + #[serde(default)] + pub language: UiLanguage, + #[serde(default = "default_git_branch_prefix")] + pub git_branch_prefix: String, + #[serde(default)] + pub showcases: ShowcaseState, +} + +impl Config { + fn from_v7_config(old_config: v7::Config) -> Self { + // Convert Option to bool: None or Some(true) become true, Some(false) stays false + let analytics_enabled = old_config.analytics_enabled.unwrap_or(true); + + Self { + config_version: "v8".to_string(), + theme: old_config.theme, + executor_profile: old_config.executor_profile, + disclaimer_acknowledged: old_config.disclaimer_acknowledged, + onboarding_acknowledged: old_config.onboarding_acknowledged, + notifications: old_config.notifications, + editor: old_config.editor, + github: old_config.github, + analytics_enabled, + workspace_dir: old_config.workspace_dir, + last_app_version: old_config.last_app_version, + show_release_notes: old_config.show_release_notes, + language: old_config.language, + git_branch_prefix: old_config.git_branch_prefix, + showcases: old_config.showcases, + } + } + + pub fn from_previous_version(raw_config: &str) -> Result { + let old_config = v7::Config::from(raw_config.to_string()); + Ok(Self::from_v7_config(old_config)) + } +} + +impl From for Config { + fn from(raw_config: String) -> Self { + if let Ok(config) = serde_json::from_str::(&raw_config) + && config.config_version == "v8" + { + return config; + } + + match Self::from_previous_version(&raw_config) { + Ok(config) => { + tracing::info!("Config upgraded to v8"); + config + } + Err(e) => { + tracing::warn!("Config migration failed: {}, using default", e); + Self::default() + } + } + } +} + +impl Default for Config { + fn default() -> Self { + Self { + config_version: "v8".to_string(), + theme: ThemeMode::System, + executor_profile: ExecutorProfileId::new(BaseCodingAgent::ClaudeCode), + disclaimer_acknowledged: false, + onboarding_acknowledged: false, + notifications: NotificationConfig::default(), + editor: EditorConfig::default(), + github: GitHubConfig::default(), + analytics_enabled: true, + workspace_dir: None, + last_app_version: None, + show_release_notes: false, + language: UiLanguage::default(), + git_branch_prefix: default_git_branch_prefix(), + showcases: ShowcaseState::default(), + } + } +} diff --git a/crates/services/src/services/container.rs b/crates/services/src/services/container.rs index 2bf3dfbf..b9523452 100644 --- a/crates/services/src/services/container.rs +++ b/crates/services/src/services/container.rs @@ -44,6 +44,7 @@ use uuid::Uuid; use crate::services::{ git::{GitService, GitServiceError}, image::ImageService, + share::SharePublisher, worktree_manager::{WorktreeError, WorktreeManager}, }; pub type ContainerRef = String; @@ -110,6 +111,8 @@ pub trait ContainerService { fn git(&self) -> &GitService; + fn share_publisher(&self) -> Option<&SharePublisher>; + fn task_attempt_to_current_dir(&self, task_attempt: &TaskAttempt) -> PathBuf; async fn create(&self, task_attempt: &TaskAttempt) -> Result; @@ -190,6 +193,7 @@ pub trait ContainerService { &self, task_attempt: &TaskAttempt, ) -> Result; + async fn is_container_clean(&self, task_attempt: &TaskAttempt) -> Result; async fn start_execution_inner( @@ -588,6 +592,16 @@ pub trait ContainerService { && run_reason != &ExecutionProcessRunReason::DevServer { Task::update_status(&self.db().pool, task.id, TaskStatus::InProgress).await?; + + if let Some(publisher) = self.share_publisher() + && let Err(err) = publisher.update_shared_task_by_id(task.id).await + { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + task.id + ); + } } // Create new execution process record // Capture current HEAD as the "before" commit for this execution diff --git a/crates/services/src/services/events.rs b/crates/services/src/services/events.rs index ea071b58..af842b30 100644 --- a/crates/services/src/services/events.rs +++ b/crates/services/src/services/events.rs @@ -5,6 +5,7 @@ use db::{ models::{ draft::{Draft, DraftType}, execution_process::ExecutionProcess, + shared_task::SharedTask as SharedDbTask, task::Task, task_attempt::TaskAttempt, }, @@ -22,7 +23,9 @@ mod streams; #[path = "events/types.rs"] pub mod types; -pub use patches::{draft_patch, execution_process_patch, task_attempt_patch, task_patch}; +pub use patches::{ + draft_patch, execution_process_patch, shared_task_patch, task_attempt_patch, task_patch, +}; pub use types::{EventError, EventPatch, EventPatchInner, HookTables, RecordTypes}; #[derive(Clone)] @@ -125,6 +128,14 @@ impl EventService { msg_store_for_preupdate.push_patch(patch); } } + "shared_tasks" => { + if let Ok(value) = preupdate.get_old_column_value(0) + && let Ok(task_id) = >::decode(value) + { + let patch = shared_task_patch::remove(task_id); + msg_store_for_preupdate.push_patch(patch); + } + } "drafts" => { let draft_type = preupdate .get_old_column_value(2) @@ -168,10 +179,27 @@ impl EventService { (HookTables::Tasks, SqliteOperation::Delete) | (HookTables::TaskAttempts, SqliteOperation::Delete) | (HookTables::ExecutionProcesses, SqliteOperation::Delete) - | (HookTables::Drafts, SqliteOperation::Delete) => { + | (HookTables::Drafts, SqliteOperation::Delete) + | (HookTables::SharedTasks, SqliteOperation::Delete) => { // Deletions handled in preupdate hook for reliable data capture return; } + (HookTables::SharedTasks, _) => { + match SharedDbTask::find_by_rowid(&db.pool, rowid).await { + Ok(Some(task)) => RecordTypes::SharedTask(task), + Ok(None) => RecordTypes::DeletedSharedTask { + rowid, + task_id: None, + }, + Err(e) => { + tracing::error!( + "Failed to fetch shared_task: {:?}", + e + ); + return; + } + } + } (HookTables::Tasks, _) => { match Task::find_by_rowid(&db.pool, rowid).await { Ok(Some(task)) => RecordTypes::Task(task), @@ -282,6 +310,15 @@ impl EventService { msg_store_for_hook.push_patch(patch); return; } + RecordTypes::SharedTask(task) => { + let patch = match hook.operation { + SqliteOperation::Insert => shared_task_patch::add(task), + SqliteOperation::Update => shared_task_patch::replace(task), + _ => shared_task_patch::replace(task), + }; + msg_store_for_hook.push_patch(patch); + return; + } RecordTypes::DeletedDraft { draft_type, task_attempt_id: Some(id), .. } => { let patch = match draft_type { DraftType::FollowUp => draft_patch::follow_up_clear(*id), @@ -298,6 +335,14 @@ impl EventService { msg_store_for_hook.push_patch(patch); return; } + RecordTypes::DeletedSharedTask { + task_id: Some(task_id), + .. + } => { + let patch = shared_task_patch::remove(*task_id); + msg_store_for_hook.push_patch(patch); + return; + } RecordTypes::TaskAttempt(attempt) => { // Task attempts should update the parent task with fresh data if let Ok(Some(task)) = diff --git a/crates/services/src/services/events/patches.rs b/crates/services/src/services/events/patches.rs index d8299371..4354d5e1 100644 --- a/crates/services/src/services/events/patches.rs +++ b/crates/services/src/services/events/patches.rs @@ -1,6 +1,7 @@ use db::models::{ draft::{Draft, DraftType}, execution_process::ExecutionProcess, + shared_task::SharedTask as DbSharedTask, task::TaskWithAttemptStatus, task_attempt::TaskAttempt, }; @@ -50,6 +51,44 @@ pub mod task_patch { } } +/// Helper functions for creating shared task-specific patches +pub mod shared_task_patch { + use super::*; + + fn shared_task_path(task_id: Uuid) -> String { + format!( + "/shared_tasks/{}", + escape_pointer_segment(&task_id.to_string()) + ) + } + + pub fn add(task: &DbSharedTask) -> Patch { + Patch(vec![PatchOperation::Add(AddOperation { + path: shared_task_path(task.id) + .try_into() + .expect("Shared task path should be valid"), + value: serde_json::to_value(task).expect("Shared task serialization should not fail"), + })]) + } + + pub fn replace(task: &DbSharedTask) -> Patch { + Patch(vec![PatchOperation::Replace(ReplaceOperation { + path: shared_task_path(task.id) + .try_into() + .expect("Shared task path should be valid"), + value: serde_json::to_value(task).expect("Shared task serialization should not fail"), + })]) + } + + pub fn remove(task_id: Uuid) -> Patch { + Patch(vec![PatchOperation::Remove(RemoveOperation { + path: shared_task_path(task_id) + .try_into() + .expect("Shared task path should be valid"), + })]) + } +} + /// Helper functions for creating execution process-specific patches pub mod execution_process_patch { use super::*; diff --git a/crates/services/src/services/events/streams.rs b/crates/services/src/services/events/streams.rs index 8779ea32..a9dc833b 100644 --- a/crates/services/src/services/events/streams.rs +++ b/crates/services/src/services/events/streams.rs @@ -1,6 +1,8 @@ use db::models::{ draft::{Draft, DraftType}, execution_process::ExecutionProcess, + project::Project, + shared_task::SharedTask, task::{Task, TaskWithAttemptStatus}, }; use futures::StreamExt; @@ -31,15 +33,37 @@ impl EventService { .map(|task| (task.id.to_string(), serde_json::to_value(task).unwrap())) .collect(); - let initial_patch = json!([{ - "op": "replace", - "path": "/tasks", - "value": tasks_map - }]); + let remote_project_id = Project::find_by_id(&self.db.pool, project_id) + .await? + .and_then(|project| project.remote_project_id); + + let shared_tasks = if let Some(remote_project_id) = remote_project_id { + SharedTask::list_by_remote_project_id(&self.db.pool, remote_project_id).await? + } else { + Vec::new() + }; + let shared_tasks_map: serde_json::Map = shared_tasks + .into_iter() + .map(|task| (task.id.to_string(), serde_json::to_value(task).unwrap())) + .collect(); + + let initial_patch = json!([ + { + "op": "replace", + "path": "/tasks", + "value": tasks_map + }, + { + "op": "replace", + "path": "/shared_tasks", + "value": shared_tasks_map + } + ]); let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap()); // Clone necessary data for the async filter let db_pool = self.db.pool.clone(); + let remote_project_id_filter = remote_project_id; // Get filtered event stream let filtered_stream = @@ -50,6 +74,44 @@ impl EventService { Ok(LogMsg::JsonPatch(patch)) => { // Filter events based on project_id if let Some(patch_op) = patch.0.first() { + if patch_op.path().starts_with("/shared_tasks/") { + match patch_op { + json_patch::PatchOperation::Add(op) => { + if let Ok(shared_task) = + serde_json::from_value::( + op.value.clone(), + ) + && remote_project_id_filter + .map(|expected| { + shared_task.remote_project_id == expected + }) + .unwrap_or(false) + { + return Some(Ok(LogMsg::JsonPatch(patch))); + } + } + json_patch::PatchOperation::Replace(op) => { + if let Ok(shared_task) = + serde_json::from_value::( + op.value.clone(), + ) + && remote_project_id_filter + .map(|expected| { + shared_task.remote_project_id == expected + }) + .unwrap_or(false) + { + return Some(Ok(LogMsg::JsonPatch(patch))); + } + } + json_patch::PatchOperation::Remove(_) => { + // Forward removals; clients will ignore missing tasks + return Some(Ok(LogMsg::JsonPatch(patch))); + } + _ => {} + } + return None; + } // Check if this is a direct task patch (new format) if patch_op.path().starts_with("/tasks/") { match patch_op { @@ -103,6 +165,19 @@ impl EventService { return Some(Ok(LogMsg::JsonPatch(patch))); } } + RecordTypes::SharedTask(shared_task) => { + if remote_project_id_filter + .map(|expected| { + shared_task.remote_project_id == expected + }) + .unwrap_or(false) + { + return Some(Ok(LogMsg::JsonPatch(patch))); + } + } + RecordTypes::DeletedSharedTask { .. } => { + return Some(Ok(LogMsg::JsonPatch(patch))); + } RecordTypes::TaskAttempt(attempt) => { // Check if this task_attempt belongs to a task in our project if let Ok(Some(task)) = diff --git a/crates/services/src/services/events/types.rs b/crates/services/src/services/events/types.rs index ba1eaec4..0dbe4de0 100644 --- a/crates/services/src/services/events/types.rs +++ b/crates/services/src/services/events/types.rs @@ -2,6 +2,7 @@ use anyhow::Error as AnyhowError; use db::models::{ draft::{Draft, DraftType}, execution_process::ExecutionProcess, + shared_task::SharedTask, task::Task, task_attempt::TaskAttempt, }; @@ -32,6 +33,8 @@ pub enum HookTables { ExecutionProcesses, #[strum(to_string = "drafts")] Drafts, + #[strum(to_string = "shared_tasks")] + SharedTasks, } #[derive(Serialize, Deserialize, TS)] @@ -42,6 +45,7 @@ pub enum RecordTypes { ExecutionProcess(ExecutionProcess), Draft(Draft), RetryDraft(Draft), + SharedTask(SharedTask), DeletedTask { rowid: i64, project_id: Option, @@ -61,6 +65,10 @@ pub enum RecordTypes { draft_type: DraftType, task_attempt_id: Option, }, + DeletedSharedTask { + rowid: i64, + task_id: Option, + }, } #[derive(Serialize, Deserialize, TS)] diff --git a/crates/services/src/services/gh_cli.rs b/crates/services/src/services/gh_cli.rs new file mode 100644 index 00000000..64b4b416 --- /dev/null +++ b/crates/services/src/services/gh_cli.rs @@ -0,0 +1,293 @@ +//! Minimal helpers around the GitHub CLI (`gh`). +//! +//! This module deliberately mirrors the ergonomics of `git_cli.rs` so we can +//! plug in the GitHub CLI for operations the REST client does not cover well. +//! Future work will flesh out richer error handling and testing. + +use std::{ + ffi::{OsStr, OsString}, + process::Command, +}; + +use chrono::{DateTime, Utc}; +use db::models::merge::{MergeStatus, PullRequestInfo}; +use serde_json::Value; +use thiserror::Error; +use utils::shell::resolve_executable_path_blocking; + +use crate::services::github_service::{CreatePrRequest, GitHubRepoInfo}; + +/// High-level errors originating from the GitHub CLI. +#[derive(Debug, Error)] +pub enum GhCliError { + #[error("GitHub CLI (`gh`) executable not found or not runnable")] + NotAvailable, + #[error("GitHub CLI command failed: {0}")] + CommandFailed(String), + #[error("GitHub CLI authentication failed: {0}")] + AuthFailed(String), + #[error("GitHub CLI returned unexpected output: {0}")] + UnexpectedOutput(String), +} + +/// Newtype wrapper for invoking the `gh` command. +#[derive(Debug, Clone, Default)] +pub struct GhCli; + +impl GhCli { + pub fn new() -> Self { + Self {} + } + + /// Ensure the GitHub CLI binary is discoverable. + fn ensure_available(&self) -> Result<(), GhCliError> { + resolve_executable_path_blocking("gh") + .ok_or(GhCliError::NotAvailable) + .map(|_| ()) + } + + /// Generic helper to execute `gh ` and return stdout on success. + fn run(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + self.ensure_available()?; + let gh = resolve_executable_path_blocking("gh").ok_or(GhCliError::NotAvailable)?; + let mut cmd = Command::new(&gh); + for arg in args { + cmd.arg(arg); + } + let output = cmd + .output() + .map_err(|err| GhCliError::CommandFailed(err.to_string()))?; + + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).to_string()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + // Check exit code first - gh CLI uses exit code 4 for auth failures + if output.status.code() == Some(4) { + return Err(GhCliError::AuthFailed(stderr)); + } + + // Fall back to string matching for older gh versions or other auth scenarios + let lower = stderr.to_ascii_lowercase(); + if lower.contains("authentication failed") + || lower.contains("must authenticate") + || lower.contains("bad credentials") + || lower.contains("unauthorized") + || lower.contains("gh auth login") + { + return Err(GhCliError::AuthFailed(stderr)); + } + + Err(GhCliError::CommandFailed(stderr)) + } + + /// Run `gh pr create` and parse the response. + /// + /// TODO: support writing the body to a temp file (`--body-file`) for large/multi-line + /// content and expand stdout/stderr mapping into richer error variants. + pub fn create_pr( + &self, + request: &CreatePrRequest, + repo_info: &GitHubRepoInfo, + ) -> Result { + let mut args: Vec = Vec::with_capacity(12); + args.push(OsString::from("pr")); + args.push(OsString::from("create")); + args.push(OsString::from("--repo")); + args.push(OsString::from(format!( + "{}/{}", + repo_info.owner, repo_info.repo_name + ))); + args.push(OsString::from("--head")); + args.push(OsString::from(&request.head_branch)); + args.push(OsString::from("--base")); + args.push(OsString::from(&request.base_branch)); + args.push(OsString::from("--title")); + args.push(OsString::from(&request.title)); + + let body = request.body.as_deref().unwrap_or(""); + args.push(OsString::from("--body")); + args.push(OsString::from(body)); + + let raw = self.run(args)?; + Self::parse_pr_create_text(&raw) + } + + /// Ensure the GitHub CLI has valid auth. + pub fn check_auth(&self) -> Result<(), GhCliError> { + match self.run(["auth", "status"]) { + Ok(_) => Ok(()), + Err(GhCliError::CommandFailed(msg)) => Err(GhCliError::AuthFailed(msg)), + Err(err) => Err(err), + } + } + + /// Fetch repository numeric ID via `gh api`. + pub fn repo_database_id(&self, owner: &str, repo: &str) -> Result { + let raw = self.run(["api", &format!("repos/{owner}/{repo}"), "--method", "GET"])?; + let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse gh api repos response: {err}; raw: {raw}" + )) + })?; + value.get("id").and_then(Value::as_i64).ok_or_else(|| { + GhCliError::UnexpectedOutput(format!( + "gh api repos response missing numeric repository id: {value:#?}" + )) + }) + } + + /// Retrieve details for a single pull request. + pub fn view_pr( + &self, + owner: &str, + repo: &str, + pr_number: i64, + ) -> Result { + let raw = self.run([ + "pr", + "view", + &pr_number.to_string(), + "--repo", + &format!("{owner}/{repo}"), + "--json", + "number,url,state,mergedAt,mergeCommit", + ])?; + Self::parse_pr_view(&raw) + } + + /// List pull requests for a branch (includes closed/merged). + pub fn list_prs_for_branch( + &self, + owner: &str, + repo: &str, + branch: &str, + ) -> Result, GhCliError> { + let raw = self.run([ + "pr", + "list", + "--repo", + &format!("{owner}/{repo}"), + "--state", + "all", + "--head", + &format!("{owner}:{branch}"), + "--json", + "number,url,state,mergedAt,mergeCommit", + ])?; + Self::parse_pr_list(&raw) + } +} + +impl GhCli { + fn parse_pr_create_text(raw: &str) -> Result { + let pr_url = raw + .lines() + .rev() + .flat_map(|line| line.split_whitespace()) + .map(|token| token.trim_matches(|c: char| c == '<' || c == '>')) + .find(|token| token.starts_with("http") && token.contains("/pull/")) + .ok_or_else(|| { + GhCliError::UnexpectedOutput(format!( + "gh pr create did not return a pull request URL; raw output: {raw}" + )) + })? + .trim_end_matches(['.', ',', ';']) + .to_string(); + + let number = pr_url + .rsplit('/') + .next() + .ok_or_else(|| { + GhCliError::UnexpectedOutput(format!( + "Failed to extract PR number from URL '{pr_url}'" + )) + })? + .trim_end_matches(|c: char| !c.is_ascii_digit()) + .parse::() + .map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse PR number from URL '{pr_url}': {err}" + )) + })?; + + Ok(PullRequestInfo { + number, + url: pr_url, + status: MergeStatus::Open, + merged_at: None, + merge_commit_sha: None, + }) + } + + fn parse_pr_view(raw: &str) -> Result { + let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse gh pr view response: {err}; raw: {raw}" + )) + })?; + Self::extract_pr_info(&value).ok_or_else(|| { + GhCliError::UnexpectedOutput(format!( + "gh pr view response missing required fields: {value:#?}" + )) + }) + } + + fn parse_pr_list(raw: &str) -> Result, GhCliError> { + let value: Value = serde_json::from_str(raw.trim()).map_err(|err| { + GhCliError::UnexpectedOutput(format!( + "Failed to parse gh pr list response: {err}; raw: {raw}" + )) + })?; + let arr = value.as_array().ok_or_else(|| { + GhCliError::UnexpectedOutput(format!("gh pr list response is not an array: {value:#?}")) + })?; + arr.iter() + .map(|item| { + Self::extract_pr_info(item).ok_or_else(|| { + GhCliError::UnexpectedOutput(format!( + "gh pr list item missing required fields: {item:#?}" + )) + }) + }) + .collect() + } + + fn extract_pr_info(value: &Value) -> Option { + let number = value.get("number")?.as_i64()?; + let url = value.get("url")?.as_str()?.to_string(); + let state = value + .get("state") + .and_then(Value::as_str) + .unwrap_or("OPEN") + .to_string(); + let merged_at = value + .get("mergedAt") + .and_then(Value::as_str) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + let merge_commit_sha = value + .get("mergeCommit") + .and_then(|v| v.get("oid")) + .and_then(Value::as_str) + .map(|s| s.to_string()); + Some(PullRequestInfo { + number, + url, + status: match state.to_ascii_uppercase().as_str() { + "OPEN" => MergeStatus::Open, + "MERGED" => MergeStatus::Merged, + "CLOSED" => MergeStatus::Closed, + _ => MergeStatus::Unknown, + }, + merged_at, + merge_commit_sha, + }) + } +} diff --git a/crates/services/src/services/git.rs b/crates/services/src/services/git.rs index 85425a56..d5431d65 100644 --- a/crates/services/src/services/git.rs +++ b/crates/services/src/services/git.rs @@ -33,8 +33,6 @@ pub enum GitServiceError { BranchesDiverged(String), #[error("{0} has uncommitted changes: {1}")] WorktreeDirty(String, String), - #[error("No GitHub token available.")] - TokenUnavailable, #[error("Rebase in progress; resolve or abort it before retrying")] RebaseInProgress, } @@ -919,7 +917,6 @@ impl GitService { repo_path: &Path, branch_name: &str, base_branch_name: Option<&str>, - github_token: String, ) -> Result<(usize, usize), GitServiceError> { let repo = Repository::open(repo_path)?; let branch_ref = Self::find_branch(&repo, branch_name)?.into_reference(); @@ -932,7 +929,7 @@ impl GitService { } .into_reference(); let remote = self.get_remote_from_branch_ref(&repo, &base_branch_ref)?; - self.fetch_all_from_remote(&repo, &github_token, &remote)?; + self.fetch_all_from_remote(&repo, &remote)?; self.get_branch_status_inner(&repo, &branch_ref, &base_branch_ref) } @@ -1385,7 +1382,6 @@ impl GitService { new_base_branch: &str, old_base_branch: &str, task_branch: &str, - github_token: Option, ) -> Result { let worktree_repo = Repository::open(worktree_path)?; let main_repo = self.open_repo(repo_path)?; @@ -1406,8 +1402,7 @@ impl GitService { let nbr = Self::find_branch(&main_repo, new_base_branch)?.into_reference(); // If the target base is remote, update it first so CLI sees latest if nbr.is_remote() { - let github_token = github_token.ok_or(GitServiceError::TokenUnavailable)?; - self.fetch_branch_from_remote(&main_repo, &github_token, &nbr)?; + self.fetch_branch_from_remote(&main_repo, &nbr)?; } // Ensure identity for any commits produced by rebase @@ -1752,7 +1747,6 @@ impl GitService { &self, worktree_path: &Path, branch_name: &str, - github_token: &str, ) -> Result<(), GitServiceError> { let repo = Repository::open(worktree_path)?; self.check_worktree_clean(&repo)?; @@ -1764,11 +1758,8 @@ impl GitService { let remote_url = remote .url() .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; - let https_url = self.convert_to_https_url(remote_url); let git_cli = GitCli::new(); - if let Err(e) = - git_cli.push_with_token(worktree_path, &https_url, branch_name, github_token) - { + if let Err(e) = git_cli.push(worktree_path, remote_url, branch_name) { tracing::error!("Push to GitHub failed: {}", e); return Err(e.into()); } @@ -1790,30 +1781,10 @@ impl GitService { Ok(()) } - pub fn convert_to_https_url(&self, url: &str) -> String { - // Convert SSH URL to HTTPS URL if necessary - let new_url = if url.starts_with("git@github.com:") { - // Convert git@github.com:owner/repo.git to https://github.com/owner/repo.git - url.replace("git@github.com:", "https://github.com/") - } else if url.starts_with("ssh://git@github.com/") { - // Convert ssh://git@github.com/owner/repo.git to https://github.com/owner/repo.git - url.replace("ssh://git@github.com/", "https://github.com/") - } else { - url.to_string() - }; - let mut normalized = new_url.trim_end_matches('/').to_string(); - if !normalized.ends_with(".git") { - normalized.push_str(".git"); - } - - normalized - } - - /// Fetch from remote repository using GitHub token authentication + /// Fetch from remote repository using native git authentication fn fetch_from_remote( &self, repo: &Repository, - github_token: &str, remote: &Remote, refspec: &str, ) -> Result<(), GitServiceError> { @@ -1822,22 +1793,18 @@ impl GitService { .url() .ok_or_else(|| GitServiceError::InvalidRepository("Remote has no URL".to_string()))?; - let https_url = self.convert_to_https_url(remote_url); let git_cli = GitCli::new(); - if let Err(e) = - git_cli.fetch_with_token_and_refspec(repo.path(), &https_url, refspec, github_token) - { + if let Err(e) = git_cli.fetch_with_refspec(repo.path(), remote_url, refspec) { tracing::error!("Fetch from GitHub failed: {}", e); return Err(e.into()); } Ok(()) } - /// Fetch from remote repository using GitHub token authentication + /// Fetch from remote repository using native git authentication fn fetch_branch_from_remote( &self, repo: &Repository, - github_token: &str, branch: &Reference, ) -> Result<(), GitServiceError> { let remote = self.get_remote_from_branch_ref(repo, branch)?; @@ -1849,20 +1816,19 @@ impl GitService { let remote_prefix = format!("refs/remotes/{remote_name}/"); let src_ref = dest_ref.replacen(&remote_prefix, "refs/heads/", 1); let refspec = format!("+{src_ref}:{dest_ref}"); - self.fetch_from_remote(repo, github_token, &remote, &refspec) + self.fetch_from_remote(repo, &remote, &refspec) } - /// Fetch from remote repository using GitHub token authentication + /// Fetch from remote repository using native git authentication fn fetch_all_from_remote( &self, repo: &Repository, - github_token: &str, remote: &Remote, ) -> Result<(), GitServiceError> { let default_remote_name = self.default_remote_name(repo); let remote_name = remote.name().unwrap_or(&default_remote_name); let refspec = format!("+refs/heads/*:refs/remotes/{remote_name}/*"); - self.fetch_from_remote(repo, github_token, remote, &refspec) + self.fetch_from_remote(repo, remote, &refspec) } /// Clone a repository to the specified directory diff --git a/crates/services/src/services/git_cli.rs b/crates/services/src/services/git_cli.rs index 0c9bf9db..3ed9ae11 100644 --- a/crates/services/src/services/git_cli.rs +++ b/crates/services/src/services/git_cli.rs @@ -21,7 +21,6 @@ use std::{ process::Command, }; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD}; use thiserror::Error; use utils::shell::resolve_executable_path_blocking; // TODO: make GitCli async @@ -298,22 +297,16 @@ impl GitCli { self.git(worktree_path, ["commit", "-m", message])?; Ok(()) } - /// Fetch a branch to the given remote using an HTTPS token for authentication. - pub fn fetch_with_token_and_refspec( + /// Fetch a branch to the given remote using native git authentication. + pub fn fetch_with_refspec( &self, repo_path: &Path, remote_url: &str, refspec: &str, - token: &str, ) -> Result<(), GitCliError> { - let auth_header = self.build_auth_header(token); - let envs = self.build_token_env(&auth_header); + let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; let args = [ - OsString::from("-c"), - OsString::from("credential.helper="), - OsString::from("--config-env"), - OsString::from("http.extraHeader=GIT_HTTP_EXTRAHEADER"), OsString::from("fetch"), OsString::from(remote_url), OsString::from(refspec), @@ -326,23 +319,17 @@ impl GitCli { } } - /// Push a branch to the given remote using an HTTPS token for authentication. - pub fn push_with_token( + /// Push a branch to the given remote using native git authentication. + pub fn push( &self, repo_path: &Path, remote_url: &str, branch: &str, - token: &str, ) -> Result<(), GitCliError> { let refspec = format!("refs/heads/{branch}:refs/heads/{branch}"); - let auth_header = self.build_auth_header(token); - let envs = self.build_token_env(&auth_header); + let envs = vec![(OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0"))]; let args = [ - OsString::from("-c"), - OsString::from("credential.helper="), - OsString::from("--config-env"), - OsString::from("http.extraHeader=GIT_HTTP_EXTRAHEADER"), OsString::from("push"), OsString::from(remote_url), OsString::from(refspec), @@ -607,23 +594,6 @@ impl GitCli { } } - fn build_auth_header(&self, token: &str) -> String { - let auth_value = BASE64_STANDARD.encode(format!("x-access-token:{token}")); - format!("Authorization: Basic {auth_value}") - } - - fn build_token_env(&self, auth_header: &str) -> Vec<(OsString, OsString)> { - vec![ - (OsString::from("GIT_TERMINAL_PROMPT"), OsString::from("0")), - (OsString::from("GIT_ASKPASS"), OsString::from("")), - (OsString::from("SSH_ASKPASS"), OsString::from("")), - ( - OsString::from("GIT_HTTP_EXTRAHEADER"), - OsString::from(auth_header), - ), - ] - } - /// Ensure `git` is available on PATH fn ensure_available(&self) -> Result<(), GitCliError> { let git = resolve_executable_path_blocking("git").ok_or(GitCliError::NotAvailable)?; diff --git a/crates/services/src/services/github_service.rs b/crates/services/src/services/github_service.rs index 848f32f4..298b5e90 100644 --- a/crates/services/src/services/github_service.rs +++ b/crates/services/src/services/github_service.rs @@ -1,24 +1,24 @@ use std::time::Duration; use backon::{ExponentialBuilder, Retryable}; -use db::models::merge::{MergeStatus, PullRequestInfo}; -use octocrab::{Octocrab, OctocrabBuilder, models::IssueState}; +use db::models::merge::PullRequestInfo; use regex::Regex; use serde::{Deserialize, Serialize}; use thiserror::Error; +use tokio::task; use tracing::info; use ts_rs::TS; -use crate::services::{git::GitServiceError, git_cli::GitCliError}; +use crate::services::{ + gh_cli::{GhCli, GhCliError}, + git::GitServiceError, + git_cli::GitCliError, +}; #[derive(Debug, Error, Serialize, Deserialize, TS)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] #[ts(use_ts_enum)] pub enum GitHubServiceError { - #[ts(skip)] - #[serde(skip)] - #[error(transparent)] - Client(octocrab::Error), #[ts(skip)] #[error("Repository error: {0}")] Repository(String), @@ -34,31 +34,36 @@ pub enum GitHubServiceError { InsufficientPermissions, #[error("GitHub repository not found or no access")] RepoNotFoundOrNoAccess, + #[error( + "GitHub CLI is not installed or not available in PATH. Please install it from https://cli.github.com/ and authenticate with 'gh auth login'" + )] + GhCliNotInstalled, #[ts(skip)] #[serde(skip)] #[error(transparent)] GitService(GitServiceError), } -impl From for GitHubServiceError { - fn from(err: octocrab::Error) -> Self { - match &err { - octocrab::Error::GitHub { source, .. } => { - let status = source.status_code.as_u16(); - let msg = source.message.to_ascii_lowercase(); - if status == 401 || msg.contains("bad credentials") || msg.contains("token expired") - { - GitHubServiceError::TokenInvalid - } else if status == 403 { - GitHubServiceError::InsufficientPermissions +impl From for GitHubServiceError { + fn from(error: GhCliError) -> Self { + match error { + GhCliError::AuthFailed(_) => Self::TokenInvalid, + GhCliError::NotAvailable => Self::GhCliNotInstalled, + GhCliError::CommandFailed(msg) => { + let lower = msg.to_ascii_lowercase(); + if lower.contains("403") || lower.contains("forbidden") { + Self::InsufficientPermissions + } else if lower.contains("404") || lower.contains("not found") { + Self::RepoNotFoundOrNoAccess } else { - GitHubServiceError::Client(err) + Self::PullRequest(msg) } } - _ => GitHubServiceError::Client(err), + GhCliError::UnexpectedOutput(msg) => Self::PullRequest(msg), } } } + impl From for GitHubServiceError { fn from(error: GitServiceError) -> Self { match error { @@ -78,28 +83,6 @@ impl From for GitHubServiceError { } } -fn format_octocrab_error(error: &octocrab::Error) -> String { - match error { - octocrab::Error::GitHub { source, .. } => { - let details = source.as_ref().to_string(); - let trimmed = details.trim(); - if trimmed.is_empty() { - format!( - "GitHub API responded with status {}", - source.status_code.as_u16() - ) - } else { - format!( - "GitHub API responded with status {}: {}", - source.status_code.as_u16(), - trimmed - ) - } - } - _ => error.to_string(), - } -} - impl GitHubServiceError { pub fn is_api_data(&self) -> bool { matches!( @@ -107,6 +90,7 @@ impl GitHubServiceError { GitHubServiceError::TokenInvalid | GitHubServiceError::InsufficientPermissions | GitHubServiceError::RepoNotFoundOrNoAccess + | GitHubServiceError::GhCliNotInstalled ) } @@ -132,10 +116,27 @@ impl GitHubRepoInfo { GitHubServiceError::Repository(format!("Invalid GitHub URL format: {remote_url}")) })?; - Ok(Self { - owner: caps.name("owner").unwrap().as_str().to_string(), - repo_name: caps.name("repo").unwrap().as_str().to_string(), - }) + let owner = caps + .name("owner") + .ok_or_else(|| { + GitHubServiceError::Repository(format!( + "Failed to extract owner from GitHub URL: {remote_url}" + )) + })? + .as_str() + .to_string(); + + let repo_name = caps + .name("repo") + .ok_or_else(|| { + GitHubServiceError::Repository(format!( + "Failed to extract repo name from GitHub URL: {remote_url}" + )) + })? + .as_str() + .to_string(); + + Ok(Self { owner, repo_name }) } } @@ -147,6 +148,11 @@ pub struct CreatePrRequest { pub base_branch: String, } +#[derive(Debug, Clone)] +pub struct GitHubService { + gh_cli: GhCli, +} + #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct RepositoryInfo { pub id: i64, @@ -160,24 +166,33 @@ pub struct RepositoryInfo { pub private: bool, } -#[derive(Debug, Clone)] -pub struct GitHubService { - client: Octocrab, -} - impl GitHubService { /// Create a new GitHub service with authentication - pub fn new(github_token: &str) -> Result { - let client = OctocrabBuilder::new() - .personal_token(github_token.to_string()) - .build()?; - - Ok(Self { client }) + pub fn new() -> Result { + Ok(Self { + gh_cli: GhCli::new(), + }) } pub async fn check_token(&self) -> Result<(), GitHubServiceError> { - self.client.current().user().await?; - Ok(()) + let cli = self.gh_cli.clone(); + task::spawn_blocking(move || cli.check_auth()) + .await + .map_err(|err| { + GitHubServiceError::Repository(format!( + "Failed to execute GitHub CLI for auth check: {err}" + )) + })? + .map_err(|err| match err { + GhCliError::NotAvailable => GitHubServiceError::GhCliNotInstalled, + GhCliError::AuthFailed(_) => GitHubServiceError::TokenInvalid, + GhCliError::CommandFailed(msg) => { + GitHubServiceError::Repository(format!("GitHub CLI auth check failed: {msg}")) + } + GhCliError::UnexpectedOutput(msg) => GitHubServiceError::Repository(format!( + "Unexpected output from GitHub CLI auth check: {msg}" + )), + }) } /// Create a pull request on GitHub @@ -186,7 +201,7 @@ impl GitHubService { repo_info: &GitHubRepoInfo, request: &CreatePrRequest, ) -> Result { - (|| async { self.create_pr_internal(repo_info, request).await }) + (|| async { self.create_pr_via_cli(repo_info, request).await }) .retry( &ExponentialBuilder::default() .with_min_delay(Duration::from_secs(1)) @@ -194,7 +209,7 @@ impl GitHubService { .with_max_times(3) .with_jitter(), ) - .when(|e| e.should_retry()) + .when(|e: &GitHubServiceError| e.should_retry()) .notify(|err: &GitHubServiceError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", @@ -205,91 +220,49 @@ impl GitHubService { .await } - async fn create_pr_internal( + pub async fn fetch_repository_id( + &self, + owner: &str, + repo: &str, + ) -> Result { + let owner = owner.to_string(); + let repo = repo.to_string(); + let cli = self.gh_cli.clone(); + let owner_for_cli = owner.clone(); + let repo_for_cli = repo.clone(); + task::spawn_blocking(move || cli.repo_database_id(&owner_for_cli, &repo_for_cli)) + .await + .map_err(|err| { + GitHubServiceError::Repository(format!( + "Failed to execute GitHub CLI for repo lookup: {err}" + )) + })? + .map_err(GitHubServiceError::from) + } + + async fn create_pr_via_cli( &self, repo_info: &GitHubRepoInfo, request: &CreatePrRequest, ) -> Result { - // Verify repository access - self.client - .repos(&repo_info.owner, &repo_info.repo_name) - .get() + let cli = self.gh_cli.clone(); + let request_clone = request.clone(); + let repo_clone = repo_info.clone(); + let cli_result = task::spawn_blocking(move || cli.create_pr(&request_clone, &repo_clone)) .await - .map_err(|error| match GitHubServiceError::from(error) { - GitHubServiceError::Client(source) => GitHubServiceError::Repository(format!( - "Cannot access repository {}/{}: {}", - repo_info.owner, - repo_info.repo_name, - format_octocrab_error(&source) - )), - other => other, - })?; - - // Check if the base branch exists - self.client - .repos(&repo_info.owner, &repo_info.repo_name) - .get_ref(&octocrab::params::repos::Reference::Branch( - request.base_branch.to_string(), - )) - .await - .map_err(|err| match GitHubServiceError::from(err) { - GitHubServiceError::Client(source) => { - let hint = if request.base_branch != "main" { - " Perhaps you meant to use main as your base branch instead?" - } else { - "" - }; - GitHubServiceError::Branch(format!( - "Base branch '{}' does not exist: {}{}", - request.base_branch, - format_octocrab_error(&source), - hint - )) - } - other => other, - })?; - - // Check if the head branch exists - self.client - .repos(&repo_info.owner, &repo_info.repo_name) - .get_ref(&octocrab::params::repos::Reference::Branch( - request.head_branch.to_string(), - )) - .await - .map_err(|err| match GitHubServiceError::from(err) { - GitHubServiceError::Client(source) => GitHubServiceError::Branch(format!( - "Head branch '{}' does not exist: {}", - request.head_branch, - format_octocrab_error(&source) - )), - other => other, - })?; - - // Create the pull request - let pr_info = self - .client - .pulls(&repo_info.owner, &repo_info.repo_name) - .create(&request.title, &request.head_branch, &request.base_branch) - .body(request.body.as_deref().unwrap_or("")) - .send() - .await - .map(Self::map_pull_request) - .map_err(|err| match GitHubServiceError::from(err) { - GitHubServiceError::Client(source) => GitHubServiceError::PullRequest(format!( - "Failed to create PR for '{} -> {}': {}", - request.head_branch, - request.base_branch, - format_octocrab_error(&source) - )), - other => other, - })?; + .map_err(|err| { + GitHubServiceError::PullRequest(format!( + "Failed to execute GitHub CLI for PR creation: {err}" + )) + })? + .map_err(GitHubServiceError::from)?; info!( "Created GitHub PR #{} for branch {} in {}/{}", - pr_info.number, request.head_branch, repo_info.owner, repo_info.repo_name + cli_result.number, request.head_branch, repo_info.owner, repo_info.repo_name ); - Ok(pr_info) + Ok(cli_result) } /// Update and get the status of a pull request @@ -299,18 +272,22 @@ impl GitHubService { pr_number: i64, ) -> Result { (|| async { - self.client - .pulls(&repo_info.owner, &repo_info.repo_name) - .get(pr_number as u64) - .await - .map(Self::map_pull_request) - .map_err(|err| match GitHubServiceError::from(err) { - GitHubServiceError::Client(source) => GitHubServiceError::PullRequest(format!( - "Failed to get PR #{pr_number}: {source}", - source = format_octocrab_error(&source), - )), - other => other, - }) + let owner = repo_info.owner.clone(); + let repo = repo_info.repo_name.clone(); + let cli = self.gh_cli.clone(); + let pr = task::spawn_blocking({ + let owner = owner.clone(); + let repo = repo.clone(); + move || cli.view_pr(&owner, &repo, pr_number) + }) + .await + .map_err(|err| { + GitHubServiceError::PullRequest(format!( + "Failed to execute GitHub CLI for viewing PR #{pr_number}: {err}" + )) + })?; + let pr = pr.map_err(GitHubServiceError::from)?; + Ok(pr) }) .retry( &ExponentialBuilder::default() @@ -319,7 +296,7 @@ impl GitHubService { .with_max_times(3) .with_jitter(), ) - .when(|err| err.should_retry()) + .when(|err: &GitHubServiceError| err.should_retry()) .notify(|err: &GitHubServiceError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", @@ -330,29 +307,6 @@ impl GitHubService { .await } - fn map_pull_request(pr: octocrab::models::pulls::PullRequest) -> PullRequestInfo { - let state = match pr.state { - Some(IssueState::Open) => MergeStatus::Open, - Some(IssueState::Closed) => { - if pr.merged_at.is_some() { - MergeStatus::Merged - } else { - MergeStatus::Closed - } - } - None => MergeStatus::Unknown, - Some(_) => MergeStatus::Unknown, - }; - - PullRequestInfo { - number: pr.number as i64, - url: pr.html_url.map(|url| url.to_string()).unwrap_or_default(), - status: state, - merged_at: pr.merged_at.map(|dt| dt.naive_utc().and_utc()), - merge_commit_sha: pr.merge_commit_sha, - } - } - /// List all pull requests for a branch (including closed/merged) pub async fn list_all_prs_for_branch( &self, @@ -360,8 +314,24 @@ impl GitHubService { branch_name: &str, ) -> Result, GitHubServiceError> { (|| async { - self.list_all_prs_for_branch_internal(repo_info, branch_name) - .await + let owner = repo_info.owner.clone(); + let repo = repo_info.repo_name.clone(); + let branch = branch_name.to_string(); + let cli = self.gh_cli.clone(); + let prs = task::spawn_blocking({ + let owner = owner.clone(); + let repo = repo.clone(); + let branch = branch.clone(); + move || cli.list_prs_for_branch(&owner, &repo, &branch) + }) + .await + .map_err(|err| { + GitHubServiceError::PullRequest(format!( + "Failed to execute GitHub CLI for listing PRs on branch '{branch_name}': {err}" + )) + })?; + let prs = prs.map_err(GitHubServiceError::from)?; + Ok(prs) }) .retry( &ExponentialBuilder::default() @@ -370,7 +340,7 @@ impl GitHubService { .with_max_times(3) .with_jitter(), ) - .when(|e| e.should_retry()) + .when(|e: &GitHubServiceError| e.should_retry()) .notify(|err: &GitHubServiceError, dur: Duration| { tracing::warn!( "GitHub API call failed, retrying after {:.2}s: {}", @@ -381,102 +351,13 @@ impl GitHubService { .await } - async fn list_all_prs_for_branch_internal( - &self, - repo_info: &GitHubRepoInfo, - branch_name: &str, - ) -> Result, GitHubServiceError> { - let prs = self - .client - .pulls(&repo_info.owner, &repo_info.repo_name) - .list() - .state(octocrab::params::State::All) - .head(format!("{}:{}", repo_info.owner, branch_name)) - .per_page(100) - .send() - .await - .map_err(|err| match GitHubServiceError::from(err) { - GitHubServiceError::Client(source) => GitHubServiceError::PullRequest(format!( - "Failed to list all PRs for branch '{branch_name}': {source}", - source = format_octocrab_error(&source), - )), - other => other, - })?; - - let pr_infos = prs.items.into_iter().map(Self::map_pull_request).collect(); - - Ok(pr_infos) - } - - /// List repositories for the authenticated user with pagination #[cfg(feature = "cloud")] pub async fn list_repositories( &self, - page: u8, + _page: u8, ) -> Result, GitHubServiceError> { - (|| async { self.list_repositories_internal(page).await }) - .retry( - &ExponentialBuilder::default() - .with_min_delay(Duration::from_secs(1)) - .with_max_delay(Duration::from_secs(30)) - .with_max_times(3) - .with_jitter(), - ) - .when(|err| err.should_retry()) - .notify(|err: &GitHubServiceError, dur: Duration| { - tracing::warn!( - "GitHub API call failed, retrying after {:.2}s: {}", - dur.as_secs_f64(), - err - ); - }) - .await - } - - #[cfg(feature = "cloud")] - async fn list_repositories_internal( - &self, - page: u8, - ) -> Result, GitHubServiceError> { - let repos_page = self - .client - .current() - .list_repos_for_authenticated_user() - .type_("all") - .sort("updated") - .direction("desc") - .per_page(50) - .page(page) - .send() - .await - .map_err(|e| { - GitHubServiceError::Repository(format!("Failed to list repositories: {e}")) - })?; - - let repositories: Vec = repos_page - .items - .into_iter() - .map(|repo| RepositoryInfo { - id: repo.id.0 as i64, - name: repo.name, - full_name: repo.full_name.unwrap_or_default(), - owner: repo.owner.map(|o| o.login).unwrap_or_default(), - description: repo.description, - clone_url: repo - .clone_url - .map(|url| url.to_string()) - .unwrap_or_default(), - ssh_url: repo.ssh_url.unwrap_or_default(), - default_branch: repo.default_branch.unwrap_or_else(|| "main".to_string()), - private: repo.private.unwrap_or(false), - }) - .collect(); - - tracing::info!( - "Retrieved {} repositories from GitHub (page {})", - repositories.len(), - page - ); - Ok(repositories) + Err(GitHubServiceError::Repository( + "Listing repositories via GitHub CLI is not supported.".into(), + )) } } diff --git a/crates/services/src/services/mod.rs b/crates/services/src/services/mod.rs index fc8a3c12..5c5a57d2 100644 --- a/crates/services/src/services/mod.rs +++ b/crates/services/src/services/mod.rs @@ -10,10 +10,14 @@ pub mod file_ranker; pub mod file_search_cache; pub mod filesystem; pub mod filesystem_watcher; +pub mod gh_cli; pub mod git; pub mod git_cli; pub mod github_service; pub mod image; pub mod notification; +pub mod oauth_credentials; pub mod pr_monitor; +pub mod remote_client; +pub mod share; pub mod worktree_manager; diff --git a/crates/services/src/services/oauth_credentials.rs b/crates/services/src/services/oauth_credentials.rs new file mode 100644 index 00000000..0bc71c94 --- /dev/null +++ b/crates/services/src/services/oauth_credentials.rs @@ -0,0 +1,208 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +/// OAuth credentials containing the JWT access token. +/// The access_token is a JWT from the remote OAuth service and should be treated as opaque. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Credentials { + pub access_token: String, +} + +/// Service for managing OAuth credentials (JWT tokens) in memory and persistent storage. +/// The token is loaded into memory on startup and persisted to disk/keychain on save. +pub struct OAuthCredentials { + backend: Backend, + inner: RwLock>, +} + +impl OAuthCredentials { + pub fn new(path: PathBuf) -> Self { + Self { + backend: Backend::detect(path), + inner: RwLock::new(None), + } + } + + pub async fn load(&self) -> std::io::Result<()> { + let creds = self.backend.load().await?; + *self.inner.write().await = creds; + Ok(()) + } + + pub async fn save(&self, creds: &Credentials) -> std::io::Result<()> { + self.backend.save(creds).await?; + *self.inner.write().await = Some(creds.clone()); + Ok(()) + } + + pub async fn clear(&self) -> std::io::Result<()> { + self.backend.clear().await?; + *self.inner.write().await = None; + Ok(()) + } + + pub async fn get(&self) -> Option { + self.inner.read().await.clone() + } +} + +trait StoreBackend { + async fn load(&self) -> std::io::Result>; + async fn save(&self, creds: &Credentials) -> std::io::Result<()>; + async fn clear(&self) -> std::io::Result<()>; +} + +enum Backend { + File(FileBackend), + #[cfg(target_os = "macos")] + Keychain(KeychainBackend), +} + +impl Backend { + fn detect(path: PathBuf) -> Self { + #[cfg(target_os = "macos")] + { + let use_file = match std::env::var("OAUTH_CREDENTIALS_BACKEND") { + Ok(v) if v.eq_ignore_ascii_case("file") => true, + Ok(v) if v.eq_ignore_ascii_case("keychain") => false, + _ => cfg!(debug_assertions), + }; + if use_file { + tracing::info!("OAuth credentials backend: file"); + Backend::File(FileBackend { path }) + } else { + tracing::info!("OAuth credentials backend: keychain"); + Backend::Keychain(KeychainBackend) + } + } + #[cfg(not(target_os = "macos"))] + { + tracing::info!("OAuth credentials backend: file"); + Backend::File(FileBackend { path }) + } + } +} + +impl StoreBackend for Backend { + async fn load(&self) -> std::io::Result> { + match self { + Backend::File(b) => b.load().await, + #[cfg(target_os = "macos")] + Backend::Keychain(b) => b.load().await, + } + } + + async fn save(&self, creds: &Credentials) -> std::io::Result<()> { + match self { + Backend::File(b) => b.save(creds).await, + #[cfg(target_os = "macos")] + Backend::Keychain(b) => b.save(creds).await, + } + } + + async fn clear(&self) -> std::io::Result<()> { + match self { + Backend::File(b) => b.clear().await, + #[cfg(target_os = "macos")] + Backend::Keychain(b) => b.clear().await, + } + } +} + +struct FileBackend { + path: PathBuf, +} + +impl FileBackend { + async fn load(&self) -> std::io::Result> { + if !self.path.exists() { + return Ok(None); + } + + let bytes = std::fs::read(&self.path)?; + match serde_json::from_slice::(&bytes) { + Ok(creds) => Ok(Some(creds)), + Err(e) => { + tracing::warn!(?e, "failed to parse credentials file, renaming to .bad"); + let bad = self.path.with_extension("bad"); + let _ = std::fs::rename(&self.path, bad); + Ok(None) + } + } + } + + async fn save(&self, creds: &Credentials) -> std::io::Result<()> { + let tmp = self.path.with_extension("tmp"); + + let file = { + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).truncate(true).write(true); + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + + opts.open(&tmp)? + }; + + serde_json::to_writer_pretty(&file, &creds)?; + file.sync_all()?; + drop(file); + + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } + + async fn clear(&self) -> std::io::Result<()> { + let _ = std::fs::remove_file(&self.path); + Ok(()) + } +} + +#[cfg(target_os = "macos")] +struct KeychainBackend; + +#[cfg(target_os = "macos")] +impl KeychainBackend { + const SERVICE_NAME: &'static str = concat!(env!("CARGO_PKG_NAME"), ":oauth"); + const ACCOUNT_NAME: &'static str = "default"; + const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; + + async fn load(&self) -> std::io::Result> { + use security_framework::passwords::get_generic_password; + + match get_generic_password(Self::SERVICE_NAME, Self::ACCOUNT_NAME) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(creds) => Ok(Some(creds)), + Err(e) => { + tracing::warn!(?e, "failed to parse keychain credentials; ignoring"); + Ok(None) + } + }, + Err(e) if e.code() == Self::ERR_SEC_ITEM_NOT_FOUND => Ok(None), + Err(e) => Err(std::io::Error::other(e)), + } + } + + async fn save(&self, creds: &Credentials) -> std::io::Result<()> { + use security_framework::passwords::set_generic_password; + + let bytes = serde_json::to_vec_pretty(creds).map_err(std::io::Error::other)?; + set_generic_password(Self::SERVICE_NAME, Self::ACCOUNT_NAME, &bytes) + .map_err(std::io::Error::other) + } + + async fn clear(&self) -> std::io::Result<()> { + use security_framework::passwords::delete_generic_password; + + match delete_generic_password(Self::SERVICE_NAME, Self::ACCOUNT_NAME) { + Ok(()) => Ok(()), + Err(e) if e.code() == Self::ERR_SEC_ITEM_NOT_FOUND => Ok(()), + Err(e) => Err(std::io::Error::other(e)), + } + } +} diff --git a/crates/services/src/services/pr_monitor.rs b/crates/services/src/services/pr_monitor.rs index adf260f4..39e27af0 100644 --- a/crates/services/src/services/pr_monitor.rs +++ b/crates/services/src/services/pr_monitor.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use db::{ DBService, @@ -11,19 +11,17 @@ use db::{ use serde_json::json; use sqlx::error::Error as SqlxError; use thiserror::Error; -use tokio::{sync::RwLock, time::interval}; -use tracing::{debug, error, info, warn}; +use tokio::time::interval; +use tracing::{debug, error, info}; use crate::services::{ analytics::AnalyticsContext, - config::Config, github_service::{GitHubRepoInfo, GitHubService, GitHubServiceError}, + share::SharePublisher, }; #[derive(Debug, Error)] enum PrMonitorError { - #[error("No GitHub token configured")] - NoGitHubToken, #[error(transparent)] GitHubServiceError(#[from] GitHubServiceError), #[error(transparent)] @@ -35,22 +33,22 @@ enum PrMonitorError { /// Service to monitor GitHub PRs and update task status when they are merged pub struct PrMonitorService { db: DBService, - config: Arc>, poll_interval: Duration, analytics: Option, + publisher: Option, } impl PrMonitorService { pub async fn spawn( db: DBService, - config: Arc>, analytics: Option, + publisher: Option, ) -> tokio::task::JoinHandle<()> { let service = Self { db, - config, poll_interval: Duration::from_secs(60), // Check every minute analytics, + publisher, }; tokio::spawn(async move { service.start().await; @@ -85,17 +83,11 @@ impl PrMonitorService { info!("Checking {} open PRs", open_prs.len()); for pr_merge in open_prs { - match self.check_pr_status(&pr_merge).await { - Err(PrMonitorError::NoGitHubToken) => { - warn!("No GitHub token configured, cannot check PR status"); - } - Err(e) => { - error!( - "Error checking PR #{} for attempt {}: {}", - pr_merge.pr_info.number, pr_merge.task_attempt_id, e - ); - } - Ok(_) => {} + if let Err(e) = self.check_pr_status(&pr_merge).await { + error!( + "Error checking PR #{} for attempt {}: {}", + pr_merge.pr_info.number, pr_merge.task_attempt_id, e + ); } } Ok(()) @@ -103,11 +95,8 @@ impl PrMonitorService { /// Check the status of a specific PR async fn check_pr_status(&self, pr_merge: &PrMerge) -> Result<(), PrMonitorError> { - let github_config = self.config.read().await.github.clone(); - let github_token = github_config.token().ok_or(PrMonitorError::NoGitHubToken)?; - - let github_service = GitHubService::new(&github_token)?; - + // GitHubService now uses gh CLI, no token needed + let github_service = GitHubService::new()?; let repo_info = GitHubRepoInfo::from_remote_url(&pr_merge.pr_info.url)?; let pr_status = github_service @@ -156,6 +145,18 @@ impl PrMonitorService { })), ); } + + if let Some(publisher) = &self.publisher + && let Err(err) = publisher + .update_shared_task_by_id(task_attempt.task_id) + .await + { + tracing::warn!( + ?err, + "Failed to propagate shared task update for {}", + task_attempt.task_id + ); + } } } diff --git a/crates/services/src/services/remote_client.rs b/crates/services/src/services/remote_client.rs new file mode 100644 index 00000000..3ba2c15d --- /dev/null +++ b/crates/services/src/services/remote_client.rs @@ -0,0 +1,558 @@ +//! OAuth client for authorization-code handoffs with automatic retries. + +use std::time::Duration; + +use backon::{ExponentialBuilder, Retryable}; +use remote::{ + activity::ActivityResponse, + routes::tasks::{ + AssignSharedTaskRequest, BulkSharedTasksResponse, CreateSharedTaskRequest, + DeleteSharedTaskRequest, SharedTaskResponse, UpdateSharedTaskRequest, + }, +}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tracing::warn; +use url::Url; +use utils::api::{ + oauth::{ + HandoffInitRequest, HandoffInitResponse, HandoffRedeemRequest, HandoffRedeemResponse, + ProfileResponse, + }, + organizations::{ + AcceptInvitationResponse, CreateInvitationRequest, CreateInvitationResponse, + CreateOrganizationRequest, CreateOrganizationResponse, GetInvitationResponse, + GetOrganizationResponse, ListInvitationsResponse, ListMembersResponse, + ListOrganizationsResponse, Organization, RevokeInvitationRequest, UpdateMemberRoleRequest, + UpdateMemberRoleResponse, UpdateOrganizationRequest, + }, + projects::{ListProjectsResponse, RemoteProject}, +}; +use uuid::Uuid; + +use super::auth::AuthContext; + +#[derive(Debug, Clone, Error)] +pub enum RemoteClientError { + #[error("network error: {0}")] + Transport(String), + #[error("timeout")] + Timeout, + #[error("http {status}: {body}")] + Http { status: u16, body: String }, + #[error("api error: {0:?}")] + Api(HandoffErrorCode), + #[error("unauthorized")] + Auth, + #[error("json error: {0}")] + Serde(String), + #[error("url error: {0}")] + Url(String), +} + +impl RemoteClientError { + /// Returns true if the error is transient and should be retried. + pub fn should_retry(&self) -> bool { + match self { + Self::Transport(_) | Self::Timeout => true, + Self::Http { status, .. } => (500..=599).contains(status), + _ => false, + } + } +} + +#[derive(Debug, Clone)] +pub enum HandoffErrorCode { + UnsupportedProvider, + InvalidReturnUrl, + InvalidChallenge, + ProviderError, + NotFound, + Expired, + AccessDenied, + InternalError, + Other(String), +} + +fn map_error_code(code: Option<&str>) -> HandoffErrorCode { + match code.unwrap_or("internal_error") { + "unsupported_provider" => HandoffErrorCode::UnsupportedProvider, + "invalid_return_url" => HandoffErrorCode::InvalidReturnUrl, + "invalid_challenge" => HandoffErrorCode::InvalidChallenge, + "provider_error" => HandoffErrorCode::ProviderError, + "not_found" => HandoffErrorCode::NotFound, + "expired" | "expired_token" => HandoffErrorCode::Expired, + "access_denied" => HandoffErrorCode::AccessDenied, + "internal_error" => HandoffErrorCode::InternalError, + other => HandoffErrorCode::Other(other.to_string()), + } +} + +#[derive(Deserialize)] +struct ApiErrorResponse { + error: String, +} + +/// HTTP client for the remote OAuth server with automatic retries. +pub struct RemoteClient { + base: Url, + http: Client, + auth_context: AuthContext, +} + +impl std::fmt::Debug for RemoteClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteClient") + .field("base", &self.base) + .field("http", &self.http) + .field("auth_context", &"") + .finish() + } +} + +impl Clone for RemoteClient { + fn clone(&self) -> Self { + Self { + base: self.base.clone(), + http: self.http.clone(), + auth_context: self.auth_context.clone(), + } + } +} + +impl RemoteClient { + const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + + pub fn new(base_url: &str, auth_context: AuthContext) -> Result { + let base = Url::parse(base_url).map_err(|e| RemoteClientError::Url(e.to_string()))?; + let http = Client::builder() + .timeout(Self::REQUEST_TIMEOUT) + .user_agent(concat!("remote-client/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| RemoteClientError::Transport(e.to_string()))?; + Ok(Self { + base, + http, + auth_context, + }) + } + + /// Returns the token if available. + async fn require_token(&self) -> Result { + let creds = self + .auth_context + .get_credentials() + .await + .ok_or(RemoteClientError::Auth)?; + Ok(creds.access_token) + } + + /// Returns the base URL for the client. + pub fn base_url(&self) -> &str { + self.base.as_str() + } + + /// Initiates an authorization-code handoff for the given provider. + pub async fn handoff_init( + &self, + request: &HandoffInitRequest, + ) -> Result { + self.post_public("/v1/oauth/web/init", Some(request)) + .await + .map_err(|e| self.map_api_error(e)) + } + + /// Redeems an application code for an access token. + pub async fn handoff_redeem( + &self, + request: &HandoffRedeemRequest, + ) -> Result { + self.post_public("/v1/oauth/web/redeem", Some(request)) + .await + .map_err(|e| self.map_api_error(e)) + } + + /// Gets an invitation by token (public, no auth required). + pub async fn get_invitation( + &self, + invitation_token: &str, + ) -> Result { + self.get_public(&format!("/v1/invitations/{invitation_token}")) + .await + } + + async fn send( + &self, + method: reqwest::Method, + path: &str, + token: Option<&str>, + body: Option<&B>, + ) -> Result + where + B: Serialize, + { + let url = self + .base + .join(path) + .map_err(|e| RemoteClientError::Url(e.to_string()))?; + + (|| async { + let mut req = self.http.request(method.clone(), url.clone()); + + if let Some(t) = token { + req = req.bearer_auth(t); + } + + if let Some(b) = body { + req = req.json(b); + } + + let res = req.send().await.map_err(map_reqwest_error)?; + + match res.status() { + s if s.is_success() => Ok(res), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(RemoteClientError::Auth), + s => { + let status = s.as_u16(); + let body = res.text().await.unwrap_or_default(); + Err(RemoteClientError::Http { status, body }) + } + } + }) + .retry( + &ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_max_times(3) + .with_jitter(), + ) + .when(|e: &RemoteClientError| e.should_retry()) + .notify(|e, dur| { + warn!( + "Remote call failed, retrying after {:.2}s: {}", + dur.as_secs_f64(), + e + ) + }) + .await + } + + // Public endpoint helpers (no auth required) + async fn get_public(&self, path: &str) -> Result + where + T: for<'de> Deserialize<'de>, + { + let res = self + .send(reqwest::Method::GET, path, None, None::<&()>) + .await?; + res.json::() + .await + .map_err(|e| RemoteClientError::Serde(e.to_string())) + } + + async fn post_public(&self, path: &str, body: Option<&B>) -> Result + where + T: for<'de> Deserialize<'de>, + B: Serialize, + { + let res = self.send(reqwest::Method::POST, path, None, body).await?; + res.json::() + .await + .map_err(|e| RemoteClientError::Serde(e.to_string())) + } + + // Authenticated endpoint helpers (require token) + async fn get_authed(&self, path: &str) -> Result + where + T: for<'de> Deserialize<'de>, + { + let token = self.require_token().await?; + let res = self + .send(reqwest::Method::GET, path, Some(&token), None::<&()>) + .await?; + res.json::() + .await + .map_err(|e| RemoteClientError::Serde(e.to_string())) + } + + async fn post_authed(&self, path: &str, body: Option<&B>) -> Result + where + T: for<'de> Deserialize<'de>, + B: Serialize, + { + let token = self.require_token().await?; + let res = self + .send(reqwest::Method::POST, path, Some(&token), body) + .await?; + res.json::() + .await + .map_err(|e| RemoteClientError::Serde(e.to_string())) + } + + async fn patch_authed(&self, path: &str, body: &B) -> Result + where + T: for<'de> Deserialize<'de>, + B: Serialize, + { + let token = self.require_token().await?; + let res = self + .send(reqwest::Method::PATCH, path, Some(&token), Some(body)) + .await?; + res.json::() + .await + .map_err(|e| RemoteClientError::Serde(e.to_string())) + } + + async fn delete_authed(&self, path: &str) -> Result<(), RemoteClientError> { + let token = self.require_token().await?; + self.send(reqwest::Method::DELETE, path, Some(&token), None::<&()>) + .await?; + Ok(()) + } + + fn map_api_error(&self, err: RemoteClientError) -> RemoteClientError { + if let RemoteClientError::Http { body, .. } = &err + && let Ok(api_err) = serde_json::from_str::(body) + { + return RemoteClientError::Api(map_error_code(Some(&api_err.error))); + } + err + } + + /// Fetches user profile. + pub async fn profile(&self) -> Result { + self.get_authed("/v1/profile").await + } + + /// Revokes the session associated with the token. + pub async fn logout(&self) -> Result<(), RemoteClientError> { + self.delete_authed("/v1/oauth/logout").await + } + + /// Lists organizations for the authenticated user. + pub async fn list_organizations(&self) -> Result { + self.get_authed("/v1/organizations").await + } + + /// Lists projects for a given organization. + pub async fn list_projects( + &self, + organization_id: Uuid, + ) -> Result { + self.get_authed(&format!("/v1/projects?organization_id={organization_id}")) + .await + } + + pub async fn get_project(&self, project_id: Uuid) -> Result { + self.get_authed(&format!("/v1/projects/{project_id}")).await + } + + pub async fn create_project( + &self, + request: &CreateRemoteProjectPayload, + ) -> Result { + self.post_authed("/v1/projects", Some(request)).await + } + + /// Gets a specific organization by ID. + pub async fn get_organization( + &self, + org_id: Uuid, + ) -> Result { + self.get_authed(&format!("/v1/organizations/{org_id}")) + .await + } + + /// Creates a new organization. + pub async fn create_organization( + &self, + request: &CreateOrganizationRequest, + ) -> Result { + self.post_authed("/v1/organizations", Some(request)).await + } + + /// Updates an organization's name. + pub async fn update_organization( + &self, + org_id: Uuid, + request: &UpdateOrganizationRequest, + ) -> Result { + self.patch_authed(&format!("/v1/organizations/{org_id}"), request) + .await + } + + /// Deletes an organization. + pub async fn delete_organization(&self, org_id: Uuid) -> Result<(), RemoteClientError> { + self.delete_authed(&format!("/v1/organizations/{org_id}")) + .await + } + + /// Creates an invitation to an organization. + pub async fn create_invitation( + &self, + org_id: Uuid, + request: &CreateInvitationRequest, + ) -> Result { + self.post_authed( + &format!("/v1/organizations/{org_id}/invitations"), + Some(request), + ) + .await + } + + /// Lists invitations for an organization. + pub async fn list_invitations( + &self, + org_id: Uuid, + ) -> Result { + self.get_authed(&format!("/v1/organizations/{org_id}/invitations")) + .await + } + + pub async fn revoke_invitation( + &self, + org_id: Uuid, + invitation_id: Uuid, + ) -> Result<(), RemoteClientError> { + let body = RevokeInvitationRequest { invitation_id }; + self.post_authed( + &format!("/v1/organizations/{org_id}/invitations/revoke"), + Some(&body), + ) + .await + } + + /// Accepts an invitation. + pub async fn accept_invitation( + &self, + invitation_token: &str, + ) -> Result { + self.post_authed( + &format!("/v1/invitations/{invitation_token}/accept"), + None::<&()>, + ) + .await + } + + /// Lists members of an organization. + pub async fn list_members( + &self, + org_id: Uuid, + ) -> Result { + self.get_authed(&format!("/v1/organizations/{org_id}/members")) + .await + } + + /// Removes a member from an organization. + pub async fn remove_member( + &self, + org_id: Uuid, + user_id: Uuid, + ) -> Result<(), RemoteClientError> { + self.delete_authed(&format!("/v1/organizations/{org_id}/members/{user_id}")) + .await + } + + /// Updates a member's role in an organization. + pub async fn update_member_role( + &self, + org_id: Uuid, + user_id: Uuid, + request: &UpdateMemberRoleRequest, + ) -> Result { + self.patch_authed( + &format!("/v1/organizations/{org_id}/members/{user_id}/role"), + request, + ) + .await + } + + /// Creates a shared task. + pub async fn create_shared_task( + &self, + request: &CreateSharedTaskRequest, + ) -> Result { + self.post_authed("/v1/tasks", Some(request)).await + } + + /// Updates a shared task. + pub async fn update_shared_task( + &self, + task_id: Uuid, + request: &UpdateSharedTaskRequest, + ) -> Result { + self.patch_authed(&format!("/v1/tasks/{task_id}"), request) + .await + } + + /// Assigns a shared task to a user. + pub async fn assign_shared_task( + &self, + task_id: Uuid, + request: &AssignSharedTaskRequest, + ) -> Result { + self.post_authed(&format!("/v1/tasks/{task_id}/assign"), Some(request)) + .await + } + + /// Deletes a shared task. + pub async fn delete_shared_task( + &self, + task_id: Uuid, + request: &DeleteSharedTaskRequest, + ) -> Result { + let token = self.require_token().await?; + let res = self + .send( + reqwest::Method::DELETE, + &format!("/v1/tasks/{task_id}"), + Some(&token), + Some(request), + ) + .await?; + res.json::() + .await + .map_err(|e| RemoteClientError::Serde(e.to_string())) + } + + /// Fetches activity events for a project. + pub async fn fetch_activity( + &self, + project_id: Uuid, + after: Option, + limit: u32, + ) -> Result { + let mut path = format!("/v1/activity?project_id={project_id}&limit={limit}"); + if let Some(seq) = after { + path.push_str(&format!("&after={seq}")); + } + self.get_authed(&path).await + } + + /// Fetches bulk snapshot of shared tasks for a project. + pub async fn fetch_bulk_snapshot( + &self, + project_id: Uuid, + ) -> Result { + self.get_authed(&format!("/v1/tasks/bulk?project_id={project_id}")) + .await + } +} + +#[derive(Debug, Serialize)] +pub struct CreateRemoteProjectPayload { + pub organization_id: Uuid, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +fn map_reqwest_error(e: reqwest::Error) -> RemoteClientError { + if e.is_timeout() { + RemoteClientError::Timeout + } else { + RemoteClientError::Transport(e.to_string()) + } +} diff --git a/crates/services/src/services/share.rs b/crates/services/src/services/share.rs new file mode 100644 index 00000000..04b49220 --- /dev/null +++ b/crates/services/src/services/share.rs @@ -0,0 +1,651 @@ +mod config; +mod processor; +mod publisher; +mod status; + +use std::{ + collections::{HashMap, HashSet}, + io, + sync::{Arc, Mutex as StdMutex}, + time::Duration, +}; + +use async_trait::async_trait; +use axum::http::{HeaderName, HeaderValue, header::AUTHORIZATION}; +pub use config::ShareConfig; +use db::{ + DBService, + models::{ + shared_task::{SharedActivityCursor, SharedTask, SharedTaskInput}, + task::{SyncTask, Task}, + }, +}; +use processor::ActivityProcessor; +pub use publisher::SharePublisher; +use remote::{ + ServerMessage, + db::{tasks::SharedTask as RemoteSharedTask, users::UserData as RemoteUserData}, +}; +use sqlx::{Executor, Sqlite, SqlitePool}; +use thiserror::Error; +use tokio::{ + sync::{mpsc, oneshot}, + task::JoinHandle, + time::{MissedTickBehavior, interval, sleep}, +}; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use url::Url; +use utils::ws::{WsClient, WsConfig, WsError, WsHandler, WsResult, run_ws_client}; +use uuid::Uuid; + +use crate::{ + RemoteClientError, + services::{ + auth::AuthContext, git::GitServiceError, github_service::GitHubServiceError, + remote_client::RemoteClient, + }, +}; + +#[derive(Debug, Error)] +pub enum ShareError { + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Transport(#[from] reqwest::Error), + #[error(transparent)] + Serialization(#[from] serde_json::Error), + #[error(transparent)] + Url(#[from] url::ParseError), + #[error(transparent)] + WebSocket(#[from] WsError), + #[error("share configuration missing: {0}")] + MissingConfig(&'static str), + #[error("task {0} not found")] + TaskNotFound(Uuid), + #[error("project {0} not found")] + ProjectNotFound(Uuid), + #[error("project {0} is not linked to a remote project")] + ProjectNotLinked(Uuid), + #[error("invalid response from remote share service")] + InvalidResponse, + #[error("task {0} is already shared")] + AlreadyShared(Uuid), + #[error("GitHub token is required to fetch repository ID")] + MissingGitHubToken, + #[error(transparent)] + Git(#[from] GitServiceError), + #[error(transparent)] + GitHub(#[from] GitHubServiceError), + #[error("share authentication missing or expired")] + MissingAuth, + #[error("invalid user ID format")] + InvalidUserId, + #[error("invalid organization ID format")] + InvalidOrganizationId, + #[error(transparent)] + RemoteClientError(#[from] RemoteClientError), +} + +const WS_BACKOFF_BASE_DELAY: Duration = Duration::from_secs(1); +const WS_BACKOFF_MAX_DELAY: Duration = Duration::from_secs(30); + +struct Backoff { + current: Duration, +} + +impl Backoff { + fn new() -> Self { + Self { + current: WS_BACKOFF_BASE_DELAY, + } + } + + fn reset(&mut self) { + self.current = WS_BACKOFF_BASE_DELAY; + } + + async fn wait(&mut self) { + let wait = self.current; + sleep(wait).await; + let doubled = wait.checked_mul(2).unwrap_or(WS_BACKOFF_MAX_DELAY); + self.current = std::cmp::min(doubled, WS_BACKOFF_MAX_DELAY); + } +} + +struct ProjectWatcher { + shutdown: oneshot::Sender<()>, + join: JoinHandle<()>, +} + +struct ProjectWatcherEvent { + project_id: Uuid, + result: Result<(), ShareError>, +} + +pub struct RemoteSync { + db: DBService, + processor: ActivityProcessor, + config: ShareConfig, + auth_ctx: AuthContext, +} + +impl RemoteSync { + pub fn spawn(db: DBService, config: ShareConfig, auth_ctx: AuthContext) -> RemoteSyncHandle { + tracing::info!(api = %config.api_base, "starting shared task synchronizer"); + let remote_client = RemoteClient::new(config.api_base.as_str(), auth_ctx.clone()) + .expect("failed to create remote client"); + let processor = + ActivityProcessor::new(db.clone(), config.clone(), remote_client, auth_ctx.clone()); + let sync = Self { + db, + processor, + config, + auth_ctx, + }; + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let join = tokio::spawn(async move { + if let Err(e) = sync.run(shutdown_rx).await { + tracing::error!(?e, "remote sync terminated unexpectedly"); + } + }); + + RemoteSyncHandle::new(shutdown_tx, join) + } + + pub async fn run(self, mut shutdown_rx: oneshot::Receiver<()>) -> Result<(), ShareError> { + let mut watchers: HashMap = HashMap::new(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let mut refresh_interval = interval(Duration::from_secs(5)); + refresh_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + self.reconcile_watchers(&mut watchers, &event_tx).await?; + + loop { + tokio::select! { + _ = &mut shutdown_rx => { + tracing::info!("remote sync shutdown requested"); + for (project_id, watcher) in watchers.drain() { + tracing::info!(%project_id, "stopping watcher due to shutdown"); + let _ = watcher.shutdown.send(()); + tokio::spawn(async move { + if let Err(err) = watcher.join.await { + tracing::debug!(?err, %project_id, "project watcher join failed during shutdown"); + } + }); + } + return Ok(()); + } + Some(event) = event_rx.recv() => { + match event.result { + Ok(()) => { + tracing::debug!(project_id = %event.project_id, "project watcher exited cleanly"); + } + Err(err) => { + tracing::warn!(project_id = %event.project_id, ?err, "project watcher terminated with error"); + } + } + watchers.remove(&event.project_id); + } + _ = refresh_interval.tick() => { + self.reconcile_watchers(&mut watchers, &event_tx).await?; + } + } + } + } + + async fn reconcile_watchers( + &self, + watchers: &mut HashMap, + events_tx: &mpsc::UnboundedSender, + ) -> Result<(), ShareError> { + let linked_projects = self.linked_remote_projects().await?; + let desired: HashSet = linked_projects.iter().copied().collect(); + + for project_id in linked_projects { + if let std::collections::hash_map::Entry::Vacant(e) = watchers.entry(project_id) { + tracing::info!(%project_id, "starting watcher for linked remote project"); + let watcher = self + .spawn_project_watcher(project_id, events_tx.clone()) + .await?; + e.insert(watcher); + } + } + + let to_remove: Vec = watchers + .keys() + .copied() + .filter(|id| !desired.contains(id)) + .collect(); + + for project_id in to_remove { + if let Some(watcher) = watchers.remove(&project_id) { + tracing::info!(%project_id, "remote project unlinked; shutting down watcher"); + let _ = watcher.shutdown.send(()); + tokio::spawn(async move { + if let Err(err) = watcher.join.await { + tracing::debug!(?err, %project_id, "project watcher join failed during teardown"); + } + }); + } + } + + Ok(()) + } + + async fn linked_remote_projects(&self) -> Result, ShareError> { + let rows = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT remote_project_id + FROM projects + WHERE remote_project_id IS NOT NULL + "#, + ) + .fetch_all(&self.db.pool) + .await?; + + Ok(rows) + } + + async fn spawn_project_watcher( + &self, + project_id: Uuid, + events_tx: mpsc::UnboundedSender, + ) -> Result { + let processor = self.processor.clone(); + let config = self.config.clone(); + let auth_ctx = self.auth_ctx.clone(); + let db = self.db.clone(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + let join = tokio::spawn(async move { + let result = + project_watcher_task(db, processor, config, auth_ctx, project_id, shutdown_rx) + .await; + + let _ = events_tx.send(ProjectWatcherEvent { project_id, result }); + }); + + Ok(ProjectWatcher { + shutdown: shutdown_tx, + join, + }) + } +} + +struct SharedWsHandler { + processor: ActivityProcessor, + close_tx: Option>, + remote_project_id: Uuid, +} + +#[async_trait] +impl WsHandler for SharedWsHandler { + async fn handle_message(&mut self, msg: WsMessage) -> Result<(), WsError> { + if let WsMessage::Text(txt) = msg { + match serde_json::from_str::(&txt) { + Ok(ServerMessage::Activity(event)) => { + let seq = event.seq; + if event.project_id != self.remote_project_id { + tracing::warn!( + expected = %self.remote_project_id, + received = %event.project_id, + "received activity for unexpected project via websocket" + ); + return Ok(()); + } + self.processor + .process_event(event) + .await + .map_err(|err| WsError::Handler(Box::new(err)))?; + + tracing::debug!(seq, "processed remote activity"); + } + Ok(ServerMessage::Error { message }) => { + tracing::warn!(?message, "received WS error message"); + // Remote sends this error when client has lagged too far behind. + // Return Err will trigger the `on_close` handler. + return Err(WsError::Handler(Box::new(io::Error::other(format!( + "remote websocket error: {message}" + ))))); + } + Err(err) => { + tracing::error!(raw = %txt, ?err, "unable to parse WS message"); + } + } + } + Ok(()) + } + + async fn on_close(&mut self) -> Result<(), WsError> { + tracing::info!("WebSocket closed, handler cleanup if needed"); + if let Some(tx) = self.close_tx.take() { + let _ = tx.send(()); + } + Ok(()) + } +} + +async fn spawn_shared_remote( + processor: ActivityProcessor, + auth_ctx: &AuthContext, + url: Url, + close_tx: oneshot::Sender<()>, + remote_project_id: Uuid, +) -> Result { + let auth_source = auth_ctx.clone(); + let ws_config = WsConfig { + url, + ping_interval: Some(std::time::Duration::from_secs(30)), + header_factory: Some(Arc::new(move || { + let auth_source = auth_source.clone(); + Box::pin(async move { + if let Some(creds) = auth_source.get_credentials().await { + build_ws_headers(&creds.access_token) + } else { + Err(WsError::MissingAuth) + } + }) + })), + }; + + let handler = SharedWsHandler { + processor, + close_tx: Some(close_tx), + remote_project_id, + }; + let client = run_ws_client(handler, ws_config) + .await + .map_err(ShareError::from)?; + + Ok(client) +} + +async fn project_watcher_task( + db: DBService, + processor: ActivityProcessor, + config: ShareConfig, + auth_ctx: AuthContext, + remote_project_id: Uuid, + mut shutdown_rx: oneshot::Receiver<()>, +) -> Result<(), ShareError> { + let mut backoff = Backoff::new(); + + loop { + if auth_ctx.cached_profile().await.is_none() { + tracing::debug!(%remote_project_id, "waiting for authentication before syncing project"); + tokio::select! { + _ = &mut shutdown_rx => return Ok(()), + _ = backoff.wait() => {} + } + continue; + } + + let mut last_seq = SharedActivityCursor::get(&db.pool, remote_project_id) + .await? + .map(|cursor| cursor.last_seq); + + match processor + .catch_up_project(remote_project_id, last_seq) + .await + { + Ok(seq) => { + last_seq = seq; + } + Err(ShareError::MissingAuth) => { + tracing::debug!(%remote_project_id, "missing auth during catch-up; retrying after backoff"); + tokio::select! { + _ = &mut shutdown_rx => return Ok(()), + _ = backoff.wait() => {} + } + continue; + } + Err(err) => return Err(err), + } + + let ws_url = match config.websocket_endpoint(remote_project_id, last_seq) { + Ok(url) => url, + Err(err) => return Err(ShareError::Url(err)), + }; + + let (close_tx, close_rx) = oneshot::channel(); + let ws_connection = match spawn_shared_remote( + processor.clone(), + &auth_ctx, + ws_url, + close_tx, + remote_project_id, + ) + .await + { + Ok(conn) => { + backoff.reset(); + conn + } + Err(ShareError::MissingAuth) => { + tracing::debug!(%remote_project_id, "missing auth during websocket connect; retrying"); + tokio::select! { + _ = &mut shutdown_rx => return Ok(()), + _ = backoff.wait() => {} + } + continue; + } + Err(err) => { + tracing::error!(%remote_project_id, ?err, "failed to establish websocket; retrying"); + tokio::select! { + _ = &mut shutdown_rx => return Ok(()), + _ = backoff.wait() => {} + } + continue; + } + }; + + tokio::select! { + _ = &mut shutdown_rx => { + tracing::info!(%remote_project_id, "shutdown signal received for project watcher"); + if let Err(err) = ws_connection.close() { + tracing::debug!(?err, %remote_project_id, "failed to close websocket during shutdown"); + } + return Ok(()); + } + res = close_rx => { + match res { + Ok(()) => { + tracing::info!(%remote_project_id, "project websocket closed; scheduling reconnect"); + } + Err(_) => { + tracing::warn!(%remote_project_id, "project websocket close signal dropped"); + } + } + if let Err(err) = ws_connection.close() { + tracing::debug!(?err, %remote_project_id, "project websocket already closed when reconnecting"); + } + tokio::select! { + _ = &mut shutdown_rx => { + tracing::info!(%remote_project_id, "shutdown received during reconnect wait"); + return Ok(()); + } + _ = backoff.wait() => {} + } + } + } + } +} + +fn build_ws_headers(access_token: &str) -> WsResult> { + let mut headers = Vec::new(); + let value = format!("Bearer {access_token}"); + let header = HeaderValue::from_str(&value).map_err(|err| WsError::Header(err.to_string()))?; + headers.push((AUTHORIZATION, header)); + Ok(headers) +} + +#[derive(Clone)] +pub struct RemoteSyncHandle { + inner: Arc, +} + +struct RemoteSyncHandleInner { + shutdown: StdMutex>>, + join: StdMutex>>, +} + +impl RemoteSyncHandle { + fn new(shutdown: oneshot::Sender<()>, join: JoinHandle<()>) -> Self { + Self { + inner: Arc::new(RemoteSyncHandleInner { + shutdown: StdMutex::new(Some(shutdown)), + join: StdMutex::new(Some(join)), + }), + } + } + + pub fn request_shutdown(&self) { + if let Some(tx) = self.inner.shutdown.lock().unwrap().take() { + let _ = tx.send(()); + } + } + + pub async fn shutdown(&self) { + self.request_shutdown(); + let join = { + let mut guard = self.inner.join.lock().unwrap(); + guard.take() + }; + + if let Some(join) = join + && let Err(err) = join.await + { + tracing::warn!(?err, "remote sync task join failed"); + } + } +} + +impl Drop for RemoteSyncHandleInner { + fn drop(&mut self) { + if let Some(tx) = self.shutdown.lock().unwrap().take() { + let _ = tx.send(()); + } + if let Some(join) = self.join.lock().unwrap().take() { + join.abort(); + } + } +} + +pub(super) fn convert_remote_task( + task: &RemoteSharedTask, + user: Option<&RemoteUserData>, + last_event_seq: Option, +) -> SharedTaskInput { + SharedTaskInput { + id: task.id, + remote_project_id: task.project_id, + title: task.title.clone(), + description: task.description.clone(), + status: status::from_remote(&task.status), + assignee_user_id: task.assignee_user_id, + assignee_first_name: user.and_then(|u| u.first_name.clone()), + assignee_last_name: user.and_then(|u| u.last_name.clone()), + assignee_username: user.and_then(|u| u.username.clone()), + version: task.version, + last_event_seq, + created_at: task.created_at, + updated_at: task.updated_at, + } +} + +pub(super) async fn sync_local_task_for_shared_task<'e, E>( + executor: E, + shared_task: &SharedTask, + current_user_id: Option, + creator_user_id: Option, + project_id: Option, +) -> Result<(), ShareError> +where + E: Executor<'e, Database = Sqlite>, +{ + let Some(project_id) = project_id else { + return Ok(()); + }; + + let create_task_if_not_exists = { + let assignee_is_current_user = matches!( + (shared_task.assignee_user_id.as_ref(), current_user_id.as_ref()), + (Some(assignee), Some(current)) if assignee == current + ); + let creator_is_current_user = matches!((creator_user_id.as_ref(), current_user_id.as_ref()), (Some(creator), Some(current)) if creator == current); + + assignee_is_current_user + && !(creator_is_current_user && SHARED_TASK_LINKING_LOCK.lock().unwrap().is_locked()) + }; + + Task::sync_from_shared_task( + executor, + SyncTask { + shared_task_id: shared_task.id, + project_id, + title: shared_task.title.clone(), + description: shared_task.description.clone(), + status: shared_task.status.clone(), + }, + create_task_if_not_exists, + ) + .await?; + + Ok(()) +} + +pub async fn link_shared_tasks_to_project( + pool: &SqlitePool, + current_user_id: Option, + project_id: Uuid, + remote_project_id: Uuid, +) -> Result<(), ShareError> { + let tasks = SharedTask::list_by_remote_project_id(pool, remote_project_id).await?; + + if tasks.is_empty() { + return Ok(()); + } + + for task in tasks { + sync_local_task_for_shared_task(pool, &task, current_user_id, None, Some(project_id)) + .await?; + } + + Ok(()) +} + +// Prevent duplicate local tasks from being created during task sharing. +// The activity event handler can create a duplicate local task when it receives a shared task assigned to the current user. +lazy_static::lazy_static! { + pub(super) static ref SHARED_TASK_LINKING_LOCK: StdMutex = StdMutex::new(SharedTaskLinkingLock::new()); +} + +#[derive(Debug)] +pub(super) struct SharedTaskLinkingLock { + count: usize, +} + +impl SharedTaskLinkingLock { + fn new() -> Self { + Self { count: 0 } + } + + pub(super) fn is_locked(&self) -> bool { + self.count > 0 + } + + #[allow(dead_code)] + pub(super) fn guard(&mut self) -> SharedTaskLinkingGuard { + self.count += 1; + SharedTaskLinkingGuard + } +} + +#[allow(dead_code)] +pub(super) struct SharedTaskLinkingGuard; + +impl Drop for SharedTaskLinkingGuard { + fn drop(&mut self) { + SHARED_TASK_LINKING_LOCK.lock().unwrap().count -= 1; + } +} diff --git a/crates/services/src/services/share/config.rs b/crates/services/src/services/share/config.rs new file mode 100644 index 00000000..0f6dbe2a --- /dev/null +++ b/crates/services/src/services/share/config.rs @@ -0,0 +1,52 @@ +use url::Url; +use utils::ws::{WS_BULK_SYNC_THRESHOLD, derive_ws_url}; +use uuid::Uuid; + +const DEFAULT_ACTIVITY_LIMIT: u32 = 200; + +#[derive(Clone)] +pub struct ShareConfig { + pub api_base: Url, + pub websocket_base: Url, + pub activity_page_limit: u32, + pub bulk_sync_threshold: u32, +} + +impl ShareConfig { + pub fn from_env() -> Option { + let raw_base = std::env::var("VK_SHARED_API_BASE").ok()?; + let api_base = Url::parse(raw_base.trim()).ok()?; + let websocket_base = derive_ws_url(api_base.clone()).ok()?; + + Some(Self { + api_base, + websocket_base, + activity_page_limit: DEFAULT_ACTIVITY_LIMIT, + bulk_sync_threshold: WS_BULK_SYNC_THRESHOLD, + }) + } + + pub fn activity_endpoint(&self) -> Result { + self.api_base.join("/v1/activity") + } + + pub fn bulk_tasks_endpoint(&self) -> Result { + self.api_base.join("/v1/tasks/bulk") + } + + pub fn websocket_endpoint( + &self, + project_id: Uuid, + cursor: Option, + ) -> Result { + let mut url = self.websocket_base.join("/v1/ws")?; + { + let mut qp = url.query_pairs_mut(); + qp.append_pair("project_id", &project_id.to_string()); + if let Some(c) = cursor { + qp.append_pair("cursor", &c.to_string()); + } + } + Ok(url) + } +} diff --git a/crates/services/src/services/share/processor.rs b/crates/services/src/services/share/processor.rs new file mode 100644 index 00000000..7d05847f --- /dev/null +++ b/crates/services/src/services/share/processor.rs @@ -0,0 +1,336 @@ +use std::collections::HashSet; + +use db::{ + DBService, + models::{ + project::Project, + shared_task::{SharedActivityCursor, SharedTask, SharedTaskInput}, + task::Task, + }, +}; +use remote::{ + activity::ActivityEvent, db::tasks::SharedTaskActivityPayload, + routes::tasks::BulkSharedTasksResponse, +}; +use sqlx::{Sqlite, Transaction}; +use uuid::Uuid; + +use super::{ShareConfig, ShareError, convert_remote_task, sync_local_task_for_shared_task}; +use crate::services::{auth::AuthContext, remote_client::RemoteClient}; + +struct PreparedBulkTask { + input: SharedTaskInput, + creator_user_id: Option, + project_id: Option, +} + +/// Processor for handling activity events and synchronizing shared tasks. +#[derive(Clone)] +pub struct ActivityProcessor { + db: DBService, + config: ShareConfig, + remote_client: RemoteClient, + auth_ctx: AuthContext, +} + +impl ActivityProcessor { + pub fn new( + db: DBService, + config: ShareConfig, + remote_client: RemoteClient, + auth_ctx: AuthContext, + ) -> Self { + Self { + db, + config, + remote_client, + auth_ctx, + } + } + + pub async fn process_event(&self, event: ActivityEvent) -> Result<(), ShareError> { + let mut tx = self.db.pool.begin().await?; + match event.event_type.as_str() { + "task.deleted" => self.process_deleted_task_event(&mut tx, &event).await?, + _ => self.process_upsert_event(&mut tx, &event).await?, + } + + SharedActivityCursor::upsert(tx.as_mut(), event.project_id, event.seq).await?; + tx.commit().await?; + Ok(()) + } + + /// Fetch and process activity events until caught up, falling back to bulk syncs when needed. + pub async fn catch_up_project( + &self, + remote_project_id: Uuid, + mut last_seq: Option, + ) -> Result, ShareError> { + if last_seq.is_none() { + last_seq = self.bulk_sync(remote_project_id).await?; + } + + loop { + let events = self.fetch_activity(remote_project_id, last_seq).await?; + if events.is_empty() { + break; + } + + // Perform a bulk sync if we've fallen too far behind + if let Some(prev_seq) = last_seq + && let Some(newest) = events.last() + && newest.seq.saturating_sub(prev_seq) > self.config.bulk_sync_threshold as i64 + { + last_seq = self.bulk_sync(remote_project_id).await?; + continue; + } + + let page_len = events.len(); + for ev in events { + if ev.project_id != remote_project_id { + tracing::warn!( + expected = %remote_project_id, + received = %ev.project_id, + "received activity for unexpected project; ignoring" + ); + continue; + } + self.process_event(ev.clone()).await?; + last_seq = Some(ev.seq); + } + + if page_len < (self.config.activity_page_limit as usize) { + break; + } + } + + Ok(last_seq) + } + + /// Fetch a page of activity events from the remote service. + async fn fetch_activity( + &self, + remote_project_id: Uuid, + after: Option, + ) -> Result, ShareError> { + let resp = self + .remote_client + .fetch_activity(remote_project_id, after, self.config.activity_page_limit) + .await?; + Ok(resp.data) + } + + async fn resolve_project( + &self, + task_id: Uuid, + remote_project_id: Uuid, + ) -> Result, ShareError> { + if let Some(existing) = SharedTask::find_by_id(&self.db.pool, task_id).await? + && let Some(project) = + Project::find_by_remote_project_id(&self.db.pool, existing.remote_project_id) + .await? + { + return Ok(Some(project)); + } + + if let Some(project) = + Project::find_by_remote_project_id(&self.db.pool, remote_project_id).await? + { + return Ok(Some(project)); + } + + Ok(None) + } + + async fn process_upsert_event( + &self, + tx: &mut Transaction<'_, Sqlite>, + event: &ActivityEvent, + ) -> Result<(), ShareError> { + let Some(payload) = &event.payload else { + tracing::warn!(event_id = %event.event_id, "received activity event with empty payload"); + return Ok(()); + }; + + match serde_json::from_value::(payload.clone()) { + Ok(SharedTaskActivityPayload { task, user }) => { + let project = self.resolve_project(task.id, event.project_id).await?; + if project.is_none() { + tracing::debug!( + task_id = %task.id, + remote_project_id = %task.project_id, + "stored shared task without local project; awaiting link" + ); + } + + let project_id = project.as_ref().map(|p| p.id); + let input = convert_remote_task(&task, user.as_ref(), Some(event.seq)); + let shared_task = SharedTask::upsert(tx.as_mut(), input).await?; + + let current_profile = self.auth_ctx.cached_profile().await; + let current_user_id = current_profile.as_ref().map(|p| p.user_id); + sync_local_task_for_shared_task( + tx.as_mut(), + &shared_task, + current_user_id, + task.creator_user_id, + project_id, + ) + .await?; + } + Err(error) => { + tracing::warn!( + ?error, + event_id = %event.event_id, + "unrecognized shared task payload; skipping" + ); + } + } + + Ok(()) + } + + async fn process_deleted_task_event( + &self, + tx: &mut Transaction<'_, Sqlite>, + event: &ActivityEvent, + ) -> Result<(), ShareError> { + let Some(payload) = &event.payload else { + tracing::warn!( + event_id = %event.event_id, + "received delete event without payload; skipping" + ); + return Ok(()); + }; + + let SharedTaskActivityPayload { task, .. } = + match serde_json::from_value::(payload.clone()) { + Ok(payload) => payload, + Err(error) => { + tracing::warn!( + ?error, + event_id = %event.event_id, + "failed to parse deleted task payload; skipping" + ); + return Ok(()); + } + }; + + if let Some(local_task) = Task::find_by_shared_task_id(tx.as_mut(), task.id).await? { + Task::set_shared_task_id(tx.as_mut(), local_task.id, None).await?; + } + + SharedTask::remove(tx.as_mut(), task.id).await?; + Ok(()) + } + + async fn bulk_sync(&self, remote_project_id: Uuid) -> Result, ShareError> { + let bulk_resp = self.fetch_bulk_snapshot(remote_project_id).await?; + let latest_seq = bulk_resp.latest_seq; + + let mut keep_ids = HashSet::new(); + let mut replacements = Vec::new(); + + for payload in bulk_resp.tasks { + let project = self + .resolve_project(payload.task.id, remote_project_id) + .await?; + + if project.is_none() { + tracing::debug!( + task_id = %payload.task.id, + remote_project_id = %payload.task.project_id, + "storing shared task during bulk sync without local project" + ); + } + + let project_id = project.as_ref().map(|p| p.id); + keep_ids.insert(payload.task.id); + let input = convert_remote_task(&payload.task, payload.user.as_ref(), latest_seq); + replacements.push(PreparedBulkTask { + input, + creator_user_id: payload.task.creator_user_id, + project_id, + }); + } + + let mut stale: HashSet = + SharedTask::list_by_remote_project_id(&self.db.pool, remote_project_id) + .await? + .into_iter() + .filter_map(|task| { + if keep_ids.contains(&task.id) { + None + } else { + Some(task.id) + } + }) + .collect(); + + for deleted in bulk_resp.deleted_task_ids { + if !keep_ids.contains(&deleted) { + stale.insert(deleted); + } + } + + let stale_vec: Vec = stale.into_iter().collect(); + let current_profile = self.auth_ctx.cached_profile().await; + let current_user_id = current_profile.as_ref().map(|p| p.user_id); + + let mut tx = self.db.pool.begin().await?; + self.remove_stale_tasks(&mut tx, &stale_vec).await?; + + for PreparedBulkTask { + input, + creator_user_id, + project_id, + } in replacements + { + let shared_task = SharedTask::upsert(tx.as_mut(), input).await?; + sync_local_task_for_shared_task( + tx.as_mut(), + &shared_task, + current_user_id, + creator_user_id, + project_id, + ) + .await?; + } + + if let Some(seq) = latest_seq { + SharedActivityCursor::upsert(tx.as_mut(), remote_project_id, seq).await?; + } + + tx.commit().await?; + Ok(latest_seq) + } + + async fn remove_stale_tasks( + &self, + tx: &mut Transaction<'_, Sqlite>, + ids: &[Uuid], + ) -> Result<(), ShareError> { + if ids.is_empty() { + return Ok(()); + } + + for id in ids { + if let Some(local_task) = Task::find_by_shared_task_id(tx.as_mut(), *id).await? { + Task::set_shared_task_id(tx.as_mut(), local_task.id, None).await?; + } + } + + SharedTask::remove_many(tx.as_mut(), ids).await?; + Ok(()) + } + + async fn fetch_bulk_snapshot( + &self, + remote_project_id: Uuid, + ) -> Result { + Ok(self + .remote_client + .fetch_bulk_snapshot(remote_project_id) + .await?) + } +} diff --git a/crates/services/src/services/share/publisher.rs b/crates/services/src/services/share/publisher.rs new file mode 100644 index 00000000..e2799770 --- /dev/null +++ b/crates/services/src/services/share/publisher.rs @@ -0,0 +1,156 @@ +use db::{ + DBService, + models::{project::Project, shared_task::SharedTask, task::Task}, +}; +use remote::routes::tasks::{ + AssignSharedTaskRequest, CreateSharedTaskRequest, DeleteSharedTaskRequest, SharedTaskResponse, + UpdateSharedTaskRequest, +}; +use uuid::Uuid; + +use super::{ShareError, convert_remote_task, status}; +use crate::services::remote_client::RemoteClient; + +#[derive(Clone)] +pub struct SharePublisher { + db: DBService, + client: RemoteClient, +} + +impl SharePublisher { + pub fn new(db: DBService, client: RemoteClient) -> Self { + Self { db, client } + } + + pub async fn share_task(&self, task_id: Uuid, user_id: Uuid) -> Result { + let task = Task::find_by_id(&self.db.pool, task_id) + .await? + .ok_or(ShareError::TaskNotFound(task_id))?; + + if task.shared_task_id.is_some() { + return Err(ShareError::AlreadyShared(task.id)); + } + + let project = Project::find_by_id(&self.db.pool, task.project_id) + .await? + .ok_or(ShareError::ProjectNotFound(task.project_id))?; + let remote_project_id = project + .remote_project_id + .ok_or(ShareError::ProjectNotLinked(project.id))?; + + let payload = CreateSharedTaskRequest { + project_id: remote_project_id, + title: task.title.clone(), + description: task.description.clone(), + assignee_user_id: Some(user_id), + }; + + let remote_task = self.client.create_shared_task(&payload).await?; + + self.sync_shared_task(&task, &remote_task).await?; + Ok(remote_task.task.id) + } + + pub async fn update_shared_task(&self, task: &Task) -> Result<(), ShareError> { + // early exit if task has not been shared + let Some(shared_task_id) = task.shared_task_id else { + return Ok(()); + }; + + let payload = UpdateSharedTaskRequest { + title: Some(task.title.clone()), + description: task.description.clone(), + status: Some(status::to_remote(&task.status)), + version: None, + }; + + let remote_task = self + .client + .update_shared_task(shared_task_id, &payload) + .await?; + + self.sync_shared_task(task, &remote_task).await?; + + Ok(()) + } + + pub async fn update_shared_task_by_id(&self, task_id: Uuid) -> Result<(), ShareError> { + let task = Task::find_by_id(&self.db.pool, task_id) + .await? + .ok_or(ShareError::TaskNotFound(task_id))?; + + self.update_shared_task(&task).await + } + + pub async fn assign_shared_task( + &self, + shared_task: &SharedTask, + new_assignee_user_id: Option, + version: Option, + ) -> Result { + let assignee_uuid = new_assignee_user_id + .map(|id| uuid::Uuid::parse_str(&id)) + .transpose() + .map_err(|_| ShareError::InvalidUserId)?; + + let payload = AssignSharedTaskRequest { + new_assignee_user_id: assignee_uuid, + version, + }; + + let SharedTaskResponse { + task: remote_task, + user, + } = self + .client + .assign_shared_task(shared_task.id, &payload) + .await?; + + let input = convert_remote_task(&remote_task, user.as_ref(), None); + let record = SharedTask::upsert(&self.db.pool, input).await?; + Ok(record) + } + + pub async fn delete_shared_task(&self, shared_task_id: Uuid) -> Result<(), ShareError> { + let shared_task = SharedTask::find_by_id(&self.db.pool, shared_task_id) + .await? + .ok_or(ShareError::TaskNotFound(shared_task_id))?; + + let payload = DeleteSharedTaskRequest { + version: Some(shared_task.version), + }; + + self.client + .delete_shared_task(shared_task.id, &payload) + .await?; + + if let Some(local_task) = + Task::find_by_shared_task_id(&self.db.pool, shared_task.id).await? + { + Task::set_shared_task_id(&self.db.pool, local_task.id, None).await?; + } + + SharedTask::remove(&self.db.pool, shared_task.id).await?; + Ok(()) + } + + async fn sync_shared_task( + &self, + task: &Task, + remote_task: &SharedTaskResponse, + ) -> Result<(), ShareError> { + let SharedTaskResponse { + task: remote_task, + user, + } = remote_task; + + Project::find_by_id(&self.db.pool, task.project_id) + .await? + .ok_or(ShareError::ProjectNotFound(task.project_id))?; + + let input = convert_remote_task(remote_task, user.as_ref(), None); + SharedTask::upsert(&self.db.pool, input).await?; + Task::set_shared_task_id(&self.db.pool, task.id, Some(remote_task.id)).await?; + Ok(()) + } +} diff --git a/crates/services/src/services/share/status.rs b/crates/services/src/services/share/status.rs new file mode 100644 index 00000000..a614698b --- /dev/null +++ b/crates/services/src/services/share/status.rs @@ -0,0 +1,22 @@ +use db::models::task::TaskStatus; +use remote::db::tasks::TaskStatus as RemoteTaskStatus; + +pub(super) fn to_remote(status: &TaskStatus) -> RemoteTaskStatus { + match status { + TaskStatus::Todo => RemoteTaskStatus::Todo, + TaskStatus::InProgress => RemoteTaskStatus::InProgress, + TaskStatus::InReview => RemoteTaskStatus::InReview, + TaskStatus::Done => RemoteTaskStatus::Done, + TaskStatus::Cancelled => RemoteTaskStatus::Cancelled, + } +} + +pub(super) fn from_remote(status: &RemoteTaskStatus) -> TaskStatus { + match status { + RemoteTaskStatus::Todo => TaskStatus::Todo, + RemoteTaskStatus::InProgress => TaskStatus::InProgress, + RemoteTaskStatus::InReview => TaskStatus::InReview, + RemoteTaskStatus::Done => TaskStatus::Done, + RemoteTaskStatus::Cancelled => TaskStatus::Cancelled, + } +} diff --git a/crates/services/tests/git_ops_safety.rs b/crates/services/tests/git_ops_safety.rs index 9f292ef4..5e69db5b 100644 --- a/crates/services/tests/git_ops_safety.rs +++ b/crates/services/tests/git_ops_safety.rs @@ -231,7 +231,7 @@ fn setup_direct_conflict_repo(root: &TempDir) -> (PathBuf, PathBuf) { } #[test] -fn push_with_token_reports_non_fast_forward() { +fn push_reports_non_fast_forward() { let temp_dir = TempDir::new().unwrap(); let remote_path = temp_dir.path().join("remote.git"); Repository::init_bare(&remote_path).expect("init bare remote"); @@ -277,7 +277,7 @@ fn push_with_token_reports_non_fast_forward() { let remote_url_string = remote.url().expect("origin url").to_string(); let git_cli = GitCli::new(); - let result = git_cli.push_with_token(&local_path, &remote_url_string, "main", "dummy-token"); + let result = git_cli.push(&local_path, &remote_url_string, "main"); match result { Err(GitCliError::PushRejected(msg)) => { let lower = msg.to_ascii_lowercase(); @@ -292,7 +292,7 @@ fn push_with_token_reports_non_fast_forward() { } #[test] -fn fetch_with_token_missing_ref_returns_error() { +fn fetch_with_missing_ref_returns_error() { let temp_dir = TempDir::new().unwrap(); let remote_path = temp_dir.path().join("remote.git"); Repository::init_bare(&remote_path).expect("init bare remote"); @@ -317,8 +317,7 @@ fn fetch_with_token_missing_ref_returns_error() { let git_cli = GitCli::new(); let refspec = "+refs/heads/missing:refs/remotes/origin/missing"; - let result = - git_cli.fetch_with_token_and_refspec(&local_path, remote_url, refspec, "dummy-token"); + let result = git_cli.fetch_with_refspec(&local_path, remote_url, refspec); match result { Err(GitCliError::CommandFailed(msg)) => { assert!( @@ -376,7 +375,7 @@ fn push_and_fetch_roundtrip_updates_tracking_branch() { let git_cli = GitCli::new(); git_cli - .push_with_token(&producer_path, &remote_url_string, "main", "dummy-token") + .push(&producer_path, &remote_url_string, "main") .expect("push succeeded"); let new_oid = producer_repo @@ -387,11 +386,10 @@ fn push_and_fetch_roundtrip_updates_tracking_branch() { assert_ne!(old_oid, new_oid, "producer created new commit"); git_cli - .fetch_with_token_and_refspec( + .fetch_with_refspec( &consumer_path, &remote_url_string, "+refs/heads/main:refs/remotes/origin/main", - "dummy-token", ) .expect("fetch succeeded"); @@ -420,7 +418,6 @@ fn rebase_preserves_untracked_files() { "new-base", "old-base", "feature", - None, ); assert!(res.is_ok(), "rebase should succeed: {res:?}"); @@ -443,7 +440,6 @@ fn rebase_aborts_on_uncommitted_tracked_changes() { "new-base", "old-base", "feature", - None, ); assert!(res.is_err(), "rebase should fail on dirty worktree"); @@ -465,7 +461,6 @@ fn rebase_aborts_if_untracked_would_be_overwritten_by_base() { "new-base", "old-base", "feature", - None, ); assert!( res.is_err(), @@ -697,7 +692,6 @@ fn rebase_refuses_to_abort_existing_rebase() { "new-base", "old-base", "feature", - None, ) .expect_err("first rebase should error and leave in-progress state"); @@ -709,7 +703,6 @@ fn rebase_refuses_to_abort_existing_rebase() { "new-base", "old-base", "feature", - None, ); assert!(res.is_err(), "should error because rebase is in progress"); // Note: We do not auto-abort; user should resolve or abort explicitly @@ -730,7 +723,6 @@ fn rebase_fast_forwards_when_no_unique_commits() { "new-base", "old-base", "feature", - None, ) .expect("rebase should succeed"); let after_oid = g.get_head_info(&worktree_path).unwrap().oid; @@ -762,7 +754,6 @@ fn rebase_applies_multiple_commits_onto_ahead_base() { "new-base", "old-base", "feature", - None, ) .expect("rebase should succeed"); @@ -908,7 +899,6 @@ fn rebase_preserves_rename_changes() { "new-base", "old-base", "feature", - None, ) .expect("rebase should succeed"); // after rebase, renamed file present; original absent diff --git a/crates/services/tests/git_remote_ops.rs b/crates/services/tests/git_remote_ops.rs deleted file mode 100644 index f30710fe..00000000 --- a/crates/services/tests/git_remote_ops.rs +++ /dev/null @@ -1,88 +0,0 @@ -use std::{ - net::{TcpStream, ToSocketAddrs}, - path::{Path, PathBuf}, - time::Duration, -}; - -use git2::Repository; -use services::services::{ - git::GitService, - git_cli::{GitCli, GitCliError}, -}; - -fn workspace_root() -> PathBuf { - // CARGO_MANIFEST_DIR for this crate is /crates/services - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - manifest_dir - .parent() - .and_then(Path::parent) - .expect("workspace root") - .to_path_buf() -} - -fn repo_https_remote(repo_path: &Path) -> Option { - let repo = Repository::open(repo_path).ok()?; - let remote = repo.find_remote("origin").ok()?; - let url = remote.url()?; - Some(GitService::new().convert_to_https_url(url)) -} - -fn assert_auth_failed(result: Result<(), GitCliError>) { - match result { - Err(GitCliError::AuthFailed(_)) => {} - Err(other) => panic!("expected auth failure, got {other:?}"), - Ok(_) => panic!("operation unexpectedly succeeded"), - } -} - -fn can_reach_github() -> bool { - let addr = match ("github.com", 443).to_socket_addrs() { - Ok(mut addrs) => addrs.next(), - Err(_) => return false, - }; - if let Some(addr) = addr { - TcpStream::connect_timeout(&addr, Duration::from_secs(2)).is_ok() - } else { - false - } -} - -#[ignore] -#[test] -fn fetch_with_invalid_token_returns_auth_error() { - let repo_path = workspace_root(); - let Some(remote_url) = repo_https_remote(&repo_path) else { - eprintln!("Skipping fetch test: origin remote not configured"); - return; - }; - - if !can_reach_github() { - eprintln!("Skipping fetch test: cannot reach github.com"); - return; - } - - let cli = GitCli::new(); - let refspec = "+refs/heads/main:refs/remotes/origin/main"; - let result = - cli.fetch_with_token_and_refspec(&repo_path, &remote_url, refspec, "invalid-token"); - assert_auth_failed(result); -} - -#[ignore] -#[test] -fn push_with_invalid_token_returns_auth_error() { - let repo_path = workspace_root(); - let Some(remote_url) = repo_https_remote(&repo_path) else { - eprintln!("Skipping push test: origin remote not configured"); - return; - }; - - if !can_reach_github() { - eprintln!("Skipping push test: cannot reach github.com"); - return; - } - - let cli = GitCli::new(); - let result = cli.push_with_token(&repo_path, &remote_url, "main", "invalid-token"); - assert_auth_failed(result); -} diff --git a/crates/services/tests/git_workflow.rs b/crates/services/tests/git_workflow.rs index ded4d2a9..f26218bc 100644 --- a/crates/services/tests/git_workflow.rs +++ b/crates/services/tests/git_workflow.rs @@ -540,32 +540,6 @@ fn delete_file_commit_has_author_without_user() { } } -#[test] -fn convert_to_https_url_handles_common_git_forms() { - let svc = GitService::new(); - - let ssh_url = "git@github.com:owner/repo.git"; - assert_eq!( - svc.convert_to_https_url(ssh_url), - "https://github.com/owner/repo.git" - ); - - let ssh_scheme_url = "ssh://git@github.com/owner/repo"; - assert_eq!( - svc.convert_to_https_url(ssh_scheme_url), - "https://github.com/owner/repo.git" - ); - - let https_without_suffix = "https://github.com/owner/repo"; - assert_eq!( - svc.convert_to_https_url(https_without_suffix), - "https://github.com/owner/repo.git" - ); - - let converted = svc.convert_to_https_url("https://github.com/owner/repo/"); - assert_eq!(converted, "https://github.com/owner/repo.git"); -} - #[test] fn github_repo_info_parses_https_and_ssh_urls() { let info = GitHubRepoInfo::from_remote_url("https://github.com/owner/repo.git").unwrap(); diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 0ad24635..9d437e63 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -15,7 +15,6 @@ tracing-subscriber = { workspace = true } chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1.0", features = ["v4", "serde"] } ts-rs = { workspace = true } -libc = "0.2" rust-embed = "8.2" directories = "6.0.0" open = "5.3.2" @@ -24,16 +23,23 @@ sentry = { version = "0.41.0", features = ["anyhow", "backtrace", "panic", "debu sentry-tracing = { version = "0.41.0", features = ["backtrace"] } futures-util = "0.3" json-patch = "2.0" -base64 = "0.22" +jsonwebtoken = { version = "10.0.0", features = ["rust_crypto"] } tokio = { workspace = true } futures = "0.3.31" tokio-stream = { version = "0.1.17", features = ["sync"] } +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots", "url"] } async-stream = "0.3" shellexpand = "3.1.1" which = "8.0.0" similar = "2" git2 = "0.18" dirs = "5.0" +async-trait = { workspace = true } +thiserror = { workspace = true } +dashmap = "6.1" +url = "2.5" +reqwest = { version = "0.12", features = ["json"] } +sqlx = { version = "0.8.6", default-features = false, features = ["postgres", "uuid", "chrono"] } [target.'cfg(windows)'.dependencies] winreg = "0.55" diff --git a/crates/utils/src/api/mod.rs b/crates/utils/src/api/mod.rs new file mode 100644 index 00000000..f8291903 --- /dev/null +++ b/crates/utils/src/api/mod.rs @@ -0,0 +1,3 @@ +pub mod oauth; +pub mod organizations; +pub mod projects; diff --git a/crates/utils/src/api/oauth.rs b/crates/utils/src/api/oauth.rs new file mode 100644 index 00000000..ea2f4b78 --- /dev/null +++ b/crates/utils/src/api/oauth.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[ts(export)] +pub struct HandoffInitRequest { + pub provider: String, + pub return_to: String, + pub app_challenge: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[ts(export)] +pub struct HandoffInitResponse { + pub handoff_id: Uuid, + pub authorize_url: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[ts(export)] +pub struct HandoffRedeemRequest { + pub handoff_id: Uuid, + pub app_code: String, + pub app_verifier: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[ts(export)] +pub struct HandoffRedeemResponse { + pub access_token: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +pub struct ProviderProfile { + pub provider: String, + pub username: Option, + pub display_name: Option, + pub email: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +pub struct ProfileResponse { + pub user_id: Uuid, + pub username: Option, + pub email: String, + pub providers: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +#[serde(tag = "status", rename_all = "lowercase")] +pub enum LoginStatus { + LoggedOut, + LoggedIn { profile: ProfileResponse }, +} + +#[derive(Debug, Serialize, Deserialize, Clone, TS)] +pub struct StatusResponse { + pub logged_in: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub degraded: Option, +} diff --git a/crates/utils/src/api/organizations.rs b/crates/utils/src/api/organizations.rs new file mode 100644 index 00000000..a91a4b30 --- /dev/null +++ b/crates/utils/src/api/organizations.rs @@ -0,0 +1,182 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::Type; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[sqlx(type_name = "member_role", rename_all = "lowercase")] +#[ts(export)] +#[ts(use_ts_enum)] +#[ts(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum MemberRole { + Admin, + Member, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[sqlx(type_name = "invitation_status", rename_all = "lowercase")] +#[ts(use_ts_enum)] +#[ts(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum InvitationStatus { + Pending, + Accepted, + Declined, + Expired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] +#[ts(export)] +pub struct Organization { + pub id: Uuid, + pub name: String, + pub slug: String, + pub is_personal: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, TS)] +#[ts(export)] +pub struct OrganizationWithRole { + pub id: Uuid, + pub name: String, + pub slug: String, + pub is_personal: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub user_role: MemberRole, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ListOrganizationsResponse { + pub organizations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct GetOrganizationResponse { + pub organization: Organization, + pub user_role: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct CreateOrganizationRequest { + pub name: String, + pub slug: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct CreateOrganizationResponse { + pub organization: OrganizationWithRole, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct UpdateOrganizationRequest { + pub name: String, +} + +// Invitation types + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Invitation { + pub id: Uuid, + pub organization_id: Uuid, + pub invited_by_user_id: Option, + pub email: String, + pub role: MemberRole, + pub status: InvitationStatus, + pub token: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct CreateInvitationRequest { + pub email: String, + pub role: MemberRole, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct CreateInvitationResponse { + pub invitation: Invitation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ListInvitationsResponse { + pub invitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct GetInvitationResponse { + pub id: Uuid, + pub organization_slug: String, + pub role: MemberRole, + pub expires_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct AcceptInvitationResponse { + pub organization_id: String, + pub organization_slug: String, + pub role: MemberRole, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct RevokeInvitationRequest { + pub invitation_id: Uuid, +} + +// Member types + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct OrganizationMember { + pub user_id: Uuid, + pub role: MemberRole, + pub joined_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct OrganizationMemberWithProfile { + pub user_id: Uuid, + pub role: MemberRole, + pub joined_at: DateTime, + pub first_name: Option, + pub last_name: Option, + pub username: Option, + pub email: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ListMembersResponse { + pub members: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct UpdateMemberRoleRequest { + pub role: MemberRole, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct UpdateMemberRoleResponse { + pub user_id: Uuid, + pub role: MemberRole, +} diff --git a/crates/utils/src/api/projects.rs b/crates/utils/src/api/projects.rs new file mode 100644 index 00000000..baa9bc6f --- /dev/null +++ b/crates/utils/src/api/projects.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use ts_rs::TS; +use uuid::Uuid; + +use super::organizations::OrganizationMemberWithProfile; + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct RemoteProject { + pub id: Uuid, + pub organization_id: Uuid, + pub name: String, + #[ts(type = "Record")] + pub metadata: Value, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct ListProjectsResponse { + pub projects: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct RemoteProjectMembersResponse { + pub organization_id: Uuid, + pub members: Vec, +} diff --git a/crates/utils/src/assets.rs b/crates/utils/src/assets.rs index fd2e2413..d25463ef 100644 --- a/crates/utils/src/assets.rs +++ b/crates/utils/src/assets.rs @@ -32,6 +32,10 @@ pub fn profiles_path() -> std::path::PathBuf { asset_dir().join("profiles.json") } +pub fn credentials_path() -> std::path::PathBuf { + asset_dir().join("credentials.json") +} + #[derive(RustEmbed)] #[folder = "../../assets/sounds"] pub struct SoundAssets; diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index ab94f6b9..d32403b9 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -2,6 +2,7 @@ use std::{env, sync::OnceLock}; use directories::ProjectDirs; +pub mod api; pub mod approvals; pub mod assets; pub mod browser; @@ -19,6 +20,7 @@ pub mod stream_lines; pub mod text; pub mod tokio; pub mod version; +pub mod ws; /// Cache for WSL2 detection result static WSL2_CACHE: OnceLock = OnceLock::new(); diff --git a/crates/utils/src/ws.rs b/crates/utils/src/ws.rs new file mode 100644 index 00000000..648369b7 --- /dev/null +++ b/crates/utils/src/ws.rs @@ -0,0 +1,229 @@ +use std::{sync::Arc, time::Duration}; + +use axum::http::{self, HeaderName, HeaderValue}; +use futures::future::BoxFuture; +use futures_util::{SinkExt, StreamExt}; +use thiserror::Error; +use tokio::sync::{mpsc, watch}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{client::IntoClientRequest, protocol::Message}, +}; +use url::Url; + +/// Interval between authentication refresh probes for websocket connections. +pub const WS_AUTH_REFRESH_INTERVAL: Duration = Duration::from_secs(30); +/// Grace period to tolerate expired tokens while a websocket client refreshes its session. +pub const WS_TOKEN_EXPIRY_GRACE: Duration = Duration::from_secs(120); +/// Maximum time allowed between REST catch-up and websocket connection establishment. +pub const WS_MAX_DELAY_BETWEEN_CATCHUP_AND_WS: Duration = WS_TOKEN_EXPIRY_GRACE; +/// Maximum backlog accepted before forcing clients to do a full bulk sync. +pub const WS_BULK_SYNC_THRESHOLD: u32 = 500; + +pub type HeaderFuture = BoxFuture<'static, WsResult>>; +pub type HeaderFactory = Arc HeaderFuture + Send + Sync>; + +#[derive(Error, Debug)] +pub enum WsError { + #[error("WebSocket connection error: {0}")] + Connection(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Send error: {0}")] + Send(String), + + #[error("Handler error: {0}")] + Handler(#[from] Box), + + #[error("Shutdown channel closed unexpectedly")] + ShutdownChannelClosed, + + #[error("failed to build websocket request: {0}")] + Request(#[from] http::Error), + + #[error("failed to prepare websocket headers: {0}")] + Header(String), + + #[error("share authentication missing or expired")] + MissingAuth, +} + +pub type WsResult = std::result::Result; + +#[async_trait::async_trait] +pub trait WsHandler: Send + Sync + 'static { + /// Called when a new `Message` is received. + async fn handle_message(&mut self, msg: Message) -> WsResult<()>; + + /// Called when the socket is closed (either remote closed or error). + async fn on_close(&mut self) -> WsResult<()>; +} + +pub struct WsConfig { + pub url: Url, + pub ping_interval: Option, + pub header_factory: Option, +} + +#[derive(Clone)] +pub struct WsClient { + msg_tx: mpsc::UnboundedSender, + cancelation_token: watch::Sender<()>, +} + +impl WsClient { + pub fn send(&self, msg: Message) -> WsResult<()> { + self.msg_tx + .send(msg) + .map_err(|e| WsError::Send(format!("WebSocket send error: {e}"))) + } + + pub fn close(&self) -> WsResult<()> { + self.cancelation_token + .send(()) + .map_err(|_| WsError::ShutdownChannelClosed) + } + + pub fn subscribe_close(&self) -> watch::Receiver<()> { + self.cancelation_token.subscribe() + } +} + +/// Launches a WebSocket connection with read/write tasks. +/// Returns a `WsClient` which you can use to send messages or request shutdown. +pub async fn run_ws_client(mut handler: H, config: WsConfig) -> WsResult +where + H: WsHandler, +{ + let (msg_tx, mut msg_rx) = mpsc::unbounded_channel(); + let (cancel_tx, cancel_rx) = watch::channel(()); + let task_tx = msg_tx.clone(); + + tokio::spawn(async move { + tracing::debug!(url = %config.url, "WebSocket connecting"); + let request = match build_request(&config).await { + Ok(req) => req, + Err(err) => { + tracing::error!(?err, "failed to build websocket request"); + return; + } + }; + + match connect_async(request).await { + Ok((ws_stream, _resp)) => { + tracing::info!("WebSocket connected"); + + let (mut ws_sink, mut ws_stream) = ws_stream.split(); + + let ping_task = if let Some(interval) = config.ping_interval { + let mut intv = tokio::time::interval(interval); + let mut cancel_rx2 = cancel_rx.clone(); + let ping_tx2 = task_tx.clone(); + Some(tokio::spawn(async move { + loop { + tokio::select! { + _ = intv.tick() => { + if ping_tx2.send(Message::Ping(Vec::new().into())).is_err() { break; } + } + _ = cancel_rx2.changed() => { break; } + } + } + })) + } else { + None + }; + + loop { + let mut cancel_rx2 = cancel_rx.clone(); + tokio::select! { + maybe = msg_rx.recv() => { + match maybe { + Some(msg) => { + if let Err(err) = ws_sink.send(msg).await { + tracing::error!("WebSocket send failed: {:?}", err); + break; + } + } + None => { + tracing::debug!("WebSocket msg_rx closed"); + break; + } + } + } + + incoming = ws_stream.next() => { + match incoming { + Some(Ok(msg)) => { + if let Err(err) = handler.handle_message(msg).await { + tracing::error!("WsHandler failed: {:?}", err); + break; + } + } + Some(Err(err)) => { + tracing::error!("WebSocket stream error: {:?}", err); + break; + } + None => { + tracing::debug!("WebSocket stream ended"); + break; + } + } + } + + _ = cancel_rx2.changed() => { + tracing::debug!("WebSocket shutdown requested"); + break; + } + } + } + + if let Err(err) = handler.on_close().await { + tracing::error!("WsHandler on_close failed: {:?}", err); + } + + if let Err(err) = ws_sink.close().await { + tracing::error!("WebSocket close failed: {:?}", err); + } + + if let Some(task) = ping_task { + task.abort(); + } + } + Err(err) => { + tracing::error!("WebSocket connect error: {:?}", err); + } + } + + tracing::info!("WebSocket client task exiting"); + }); + + Ok(WsClient { + msg_tx, + cancelation_token: cancel_tx, + }) +} + +async fn build_request(config: &WsConfig) -> WsResult> { + let mut request = config.url.clone().into_client_request()?; + if let Some(factory) = &config.header_factory { + let headers = factory().await?; + for (name, value) in headers { + request.headers_mut().insert(name, value); + } + } + + Ok(request) +} + +pub fn derive_ws_url(mut base: Url) -> Result { + match base.scheme() { + "https" => base.set_scheme("wss").unwrap(), + "http" => base.set_scheme("ws").unwrap(), + _ => { + return Err(url::ParseError::RelativeUrlWithoutBase); + } + } + Ok(base) +} diff --git a/dev_assets_seed/dev.db b/dev_assets_seed/dev.db new file mode 100644 index 00000000..e69de29b diff --git a/frontend/package.json b/frontend/package.json index b267f800..1506adf5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.7", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7de0f96..6e797a87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,11 +7,13 @@ import { ProjectTasks } from '@/pages/project-tasks'; import { FullAttemptLogsPage } from '@/pages/full-attempt-logs'; import { NormalLayout } from '@/components/layout/NormalLayout'; import { usePostHog } from 'posthog-js/react'; +import { useAuth } from '@/hooks'; import { AgentSettings, GeneralSettings, McpSettings, + OrganizationSettings, ProjectSettings, SettingsLayout, } from '@/pages/settings/'; @@ -39,14 +41,13 @@ function AppContent() { const { config, analyticsUserId, updateAndSaveConfig, loading } = useUserSystem(); const posthog = usePostHog(); + const { isSignedIn } = useAuth(); // Handle opt-in/opt-out and user identification when config loads useEffect(() => { if (!posthog || !analyticsUserId) return; - const userOptedIn = config?.analytics_enabled !== false; - - if (userOptedIn) { + if (config?.analytics_enabled) { posthog.opt_in_capturing(); posthog.identify(analyticsUserId); console.log('[Analytics] Analytics enabled and user identified'); @@ -57,92 +58,51 @@ function AppContent() { }, [config?.analytics_enabled, analyticsUserId, posthog]); useEffect(() => { + if (!config) return; let cancelled = false; - const handleOnboardingComplete = async ( - onboardingConfig: OnboardingResult - ) => { - if (cancelled) return; - const updatedConfig = { - ...config, - onboarding_acknowledged: true, - executor_profile: onboardingConfig.profile, - editor: onboardingConfig.editor, - }; - - updateAndSaveConfig(updatedConfig); - }; - - const handleDisclaimerAccept = async () => { - if (cancelled) return; - await updateAndSaveConfig({ disclaimer_acknowledged: true }); - }; - - const handleGitHubLoginComplete = async () => { - if (cancelled) return; - await updateAndSaveConfig({ github_login_acknowledged: true }); - }; - - const handleTelemetryOptIn = async (analyticsEnabled: boolean) => { - if (cancelled) return; - await updateAndSaveConfig({ - telemetry_acknowledged: true, - analytics_enabled: analyticsEnabled, - }); - }; - - const handleReleaseNotesClose = async () => { - if (cancelled) return; - await updateAndSaveConfig({ show_release_notes: false }); - }; - - const checkOnboardingSteps = async () => { - if (!config || cancelled) return; - + const showNextStep = async () => { + // 1) Disclaimer - first step if (!config.disclaimer_acknowledged) { await NiceModal.show('disclaimer'); - await handleDisclaimerAccept(); + if (!cancelled) { + await updateAndSaveConfig({ disclaimer_acknowledged: true }); + } await NiceModal.hide('disclaimer'); + return; } + // 2) Onboarding - configure executor and editor if (!config.onboarding_acknowledged) { - const onboardingResult: OnboardingResult = - await NiceModal.show('onboarding'); - await handleOnboardingComplete(onboardingResult); + const result: OnboardingResult = await NiceModal.show('onboarding'); + if (!cancelled) { + await updateAndSaveConfig({ + onboarding_acknowledged: true, + executor_profile: result.profile, + editor: result.editor, + }); + } await NiceModal.hide('onboarding'); + return; } - if (!config.github_login_acknowledged) { - await NiceModal.show('github-login'); - await handleGitHubLoginComplete(); - await NiceModal.hide('github-login'); - } - - if (!config.telemetry_acknowledged) { - const analyticsEnabled: boolean = - await NiceModal.show('privacy-opt-in'); - await handleTelemetryOptIn(analyticsEnabled); - await NiceModal.hide('privacy-opt-in'); - } - + // 3) Release notes - last step if (config.show_release_notes) { await NiceModal.show('release-notes'); - await handleReleaseNotesClose(); + if (!cancelled) { + await updateAndSaveConfig({ show_release_notes: false }); + } await NiceModal.hide('release-notes'); + return; } }; - const runOnboarding = async () => { - if (!config || cancelled) return; - await checkOnboardingSteps(); - }; - - runOnboarding(); + showNextStep(); return () => { cancelled = true; }; - }, [config]); + }, [config, isSignedIn]); if (loading) { return ( @@ -176,6 +136,10 @@ function AppContent() { } /> } /> } /> + } + /> } /> } /> diff --git a/frontend/src/components/DevBanner.tsx b/frontend/src/components/DevBanner.tsx index b6e13607..2294ca35 100644 --- a/frontend/src/components/DevBanner.tsx +++ b/frontend/src/components/DevBanner.tsx @@ -1,6 +1,9 @@ import { AlertTriangle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; export function DevBanner() { + const { t } = useTranslation(); + // Only show in development mode if (import.meta.env.MODE !== 'development') { return null; @@ -10,7 +13,7 @@ export function DevBanner() {
- Development Mode - This is a development build + {t('devMode.banner')}
); diff --git a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx index 8dd36d26..e44499a2 100644 --- a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx +++ b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx @@ -428,6 +428,7 @@ const ToolCallCard: React.FC<{ defaultExpanded?: boolean; statusAppearance?: ToolStatusAppearance; forceExpanded?: boolean; + linkifyUrls?: boolean; }> = ({ entryType, action, @@ -436,6 +437,7 @@ const ToolCallCard: React.FC<{ entryContent, defaultExpanded = false, forceExpanded = false, + linkifyUrls = false, }) => { const { t } = useTranslation('common'); const at: any = entryType?.action_type || action; @@ -530,7 +532,7 @@ const ToolCallCard: React.FC<{ {t('conversation.output')}
- +
)} @@ -689,7 +691,9 @@ function DisplayConversationEntry({ const isPlanPresentation = toolEntry.action_type.action === 'plan_presentation'; const isPendingApproval = status.status === 'pending_approval'; - const defaultExpanded = isPendingApproval || isPlanPresentation; + const isGithubCliSetup = toolEntry.tool_name === 'GitHub CLI Setup Script'; + const defaultExpanded = + isPendingApproval || isPlanPresentation || isGithubCliSetup; const body = (() => { if (isFileEdit(toolEntry.action_type)) { @@ -730,6 +734,7 @@ function DisplayConversationEntry({ defaultExpanded={defaultExpanded} statusAppearance={statusAppearance} forceExpanded={isPendingApproval} + linkifyUrls={isGithubCliSetup} /> ); })(); diff --git a/frontend/src/components/NormalizedConversation/NextActionCard.tsx b/frontend/src/components/NormalizedConversation/NextActionCard.tsx index 9b59056f..a692d602 100644 --- a/frontend/src/components/NormalizedConversation/NextActionCard.tsx +++ b/frontend/src/components/NormalizedConversation/NextActionCard.tsx @@ -108,9 +108,8 @@ export function NextActionCard({ if (!attempt?.task_id) return; NiceModal.show('create-attempt', { taskId: attempt.task_id, - latestAttempt: attemptId, }); - }, [attempt?.task_id, attemptId]); + }, [attempt?.task_id]); const handleGitActions = useCallback(() => { if (!attemptId) return; diff --git a/frontend/src/components/OrgMemberAvatars.tsx b/frontend/src/components/OrgMemberAvatars.tsx new file mode 100644 index 00000000..bef62a68 --- /dev/null +++ b/frontend/src/components/OrgMemberAvatars.tsx @@ -0,0 +1,47 @@ +import { useOrganizationMembers } from '@/hooks/useOrganizationMembers'; +import { UserAvatar } from '@/components/tasks/UserAvatar'; +import { useTranslation } from 'react-i18next'; + +interface OrgMemberAvatarsProps { + limit?: number; + className?: string; + organizationId?: string; +} + +export function OrgMemberAvatars({ + limit = 5, + className = '', + organizationId, +}: OrgMemberAvatarsProps) { + const { t } = useTranslation('common'); + const { data: members, isPending } = useOrganizationMembers(organizationId); + + if (!organizationId || isPending || !members || members.length === 0) { + return null; + } + + const displayMembers = members.slice(0, limit); + const remainingCount = members.length - limit; + + return ( +
+
+ {displayMembers.map((member) => ( + + ))} +
+ {remainingCount > 0 && ( + + {t('orgMembers.moreCount', { count: remainingCount })} + + )} +
+ ); +} diff --git a/frontend/src/components/common/RawLogText.tsx b/frontend/src/components/common/RawLogText.tsx index c43d0083..f1b06661 100644 --- a/frontend/src/components/common/RawLogText.tsx +++ b/frontend/src/components/common/RawLogText.tsx @@ -8,6 +8,7 @@ interface RawLogTextProps { channel?: 'stdout' | 'stderr'; as?: 'div' | 'span'; className?: string; + linkifyUrls?: boolean; } const RawLogText = memo( @@ -16,11 +17,40 @@ const RawLogText = memo( channel = 'stdout', as: Component = 'div', className, + linkifyUrls = false, }: RawLogTextProps) => { // Only apply stderr fallback color when no ANSI codes are present const hasAnsiCodes = hasAnsi(content); const shouldApplyStderrFallback = channel === 'stderr' && !hasAnsiCodes; + const renderContent = () => { + if (!linkifyUrls) { + return ; + } + + const urlRegex = /(https?:\/\/\S+)/g; + const parts = content.split(urlRegex); + + return parts.map((part, index) => { + if (/^https?:\/\/\S+$/.test(part)) { + return ( + e.stopPropagation()} + > + {part} + + ); + } + // For non-URL parts, apply ANSI formatting + return ; + }); + }; + return ( - + {renderContent()} ); } diff --git a/frontend/src/components/config-provider.tsx b/frontend/src/components/config-provider.tsx index 50e13190..6c58af56 100644 --- a/frontend/src/components/config-provider.tsx +++ b/frontend/src/components/config-provider.tsx @@ -12,10 +12,10 @@ import { type Environment, type UserSystemInfo, type BaseAgentCapability, - CheckTokenResponse, + type LoginStatus, } from 'shared/types'; import type { ExecutorConfig } from 'shared/types'; -import { configApi, githubAuthApi } from '../lib/api'; +import { configApi } from '../lib/api'; import { updateLanguageFromConfig } from '../i18n/config'; interface UserSystemState { @@ -24,6 +24,7 @@ interface UserSystemState { profiles: Record | null; capabilities: Record | null; analyticsUserId: string | null; + loginStatus: LoginStatus | null; } interface UserSystemContextType { @@ -41,6 +42,7 @@ interface UserSystemContextType { profiles: Record | null; capabilities: Record | null; analyticsUserId: string | null; + loginStatus: LoginStatus | null; setEnvironment: (env: Environment | null) => void; setProfiles: (profiles: Record | null) => void; setCapabilities: (caps: Record | null) => void; @@ -50,7 +52,6 @@ interface UserSystemContextType { // State loading: boolean; - githubTokenInvalid: boolean; } const UserSystemContext = createContext( @@ -74,8 +75,8 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { BaseAgentCapability[] > | null>(null); const [analyticsUserId, setAnalyticsUserId] = useState(null); + const [loginStatus, setLoginStatus] = useState(null); const [loading, setLoading] = useState(true); - const [githubTokenInvalid, setGithubTokenInvalid] = useState(false); useEffect(() => { const loadUserSystem = async () => { @@ -84,6 +85,7 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { setConfig(userSystemInfo.config); setEnvironment(userSystemInfo.environment); setAnalyticsUserId(userSystemInfo.analytics_user_id); + setLoginStatus(userSystemInfo.login_status); setProfiles( userSystemInfo.executors as Record | null ); @@ -110,27 +112,6 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { } }, [config?.language]); - // Check GitHub token validity after config loads - useEffect(() => { - if (loading) return; - const checkToken = async () => { - const valid = await githubAuthApi.checkGithubToken(); - if (valid === undefined) { - // Network/server error: do not update githubTokenInvalid - return; - } - switch (valid) { - case CheckTokenResponse.VALID: - setGithubTokenInvalid(false); - break; - case CheckTokenResponse.INVALID: - setGithubTokenInvalid(true); - break; - } - }; - checkToken(); - }, [loading]); - const updateConfig = useCallback((updates: Partial) => { setConfig((prev) => (prev ? { ...prev, ...updates } : null)); }, []); @@ -168,11 +149,13 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { ); const reloadSystem = useCallback(async () => { + setLoading(true); try { const userSystemInfo: UserSystemInfo = await configApi.getConfig(); setConfig(userSystemInfo.config); setEnvironment(userSystemInfo.environment); setAnalyticsUserId(userSystemInfo.analytics_user_id); + setLoginStatus(userSystemInfo.login_status); setProfiles( userSystemInfo.executors as Record | null ); @@ -184,18 +167,28 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { ); } catch (err) { console.error('Error reloading user system:', err); + } finally { + setLoading(false); } }, []); // Memoize context value to prevent unnecessary re-renders const value = useMemo( () => ({ - system: { config, environment, profiles, capabilities, analyticsUserId }, + system: { + config, + environment, + profiles, + capabilities, + analyticsUserId, + loginStatus, + }, config, environment, profiles, capabilities, analyticsUserId, + loginStatus, updateConfig, saveConfig, updateAndSaveConfig, @@ -204,7 +197,6 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { setCapabilities, reloadSystem, loading, - githubTokenInvalid, }), [ config, @@ -212,12 +204,12 @@ export function UserSystemProvider({ children }: UserSystemProviderProps) { profiles, capabilities, analyticsUserId, + loginStatus, updateConfig, saveConfig, updateAndSaveConfig, reloadSystem, loading, - githubTokenInvalid, ] ); diff --git a/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx b/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx new file mode 100644 index 00000000..cdc24fd2 --- /dev/null +++ b/frontend/src/components/dialogs/auth/GhCliSetupDialog.tsx @@ -0,0 +1,248 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { attemptsApi } from '@/lib/api'; +import type { GhCliSetupError } from 'shared/types'; +import { useRef, useState } from 'react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface GhCliSetupDialogProps { + attemptId: string; +} + +export type GhCliSupportVariant = 'homebrew' | 'manual'; + +export interface GhCliSupportContent { + message: string; + variant: GhCliSupportVariant | null; +} + +export const mapGhCliErrorToUi = ( + error: GhCliSetupError | null, + fallbackMessage: string, + t: (key: string) => string +): GhCliSupportContent => { + if (!error) { + return { message: fallbackMessage, variant: null }; + } + + if (error === 'BREW_MISSING') { + return { + message: t('settings:integrations.github.cliSetup.errors.brewMissing'), + variant: 'homebrew', + }; + } + + if (error === 'SETUP_HELPER_NOT_SUPPORTED') { + return { + message: t('settings:integrations.github.cliSetup.errors.notSupported'), + variant: 'manual', + }; + } + + if (typeof error === 'object' && 'OTHER' in error) { + return { + message: error.OTHER.message || fallbackMessage, + variant: null, + }; + } + + return { message: fallbackMessage, variant: null }; +}; + +export const GhCliHelpInstructions = ({ + variant, + t, +}: { + variant: GhCliSupportVariant; + t: (key: string) => string; +}) => { + if (variant === 'homebrew') { + return ( +
+

+ {t('settings:integrations.github.cliSetup.help.homebrew.description')}{' '} + + {t('settings:integrations.github.cliSetup.help.homebrew.brewSh')} + {' '} + {t( + 'settings:integrations.github.cliSetup.help.homebrew.manualInstall' + )} +

+
+          brew install gh
+        
+

+ {t( + 'settings:integrations.github.cliSetup.help.homebrew.afterInstall' + )} +
+ + gh auth login --web --git-protocol https + +

+
+ ); + } + + return ( +
+

+ {t('settings:integrations.github.cliSetup.help.manual.description')}{' '} + + {t('settings:integrations.github.cliSetup.help.manual.officialDocs')} + {' '} + {t('settings:integrations.github.cliSetup.help.manual.andAuthenticate')} +

+
+        gh auth login --web --git-protocol https
+      
+
+ ); +}; + +export const GhCliSetupDialog = NiceModal.create( + ({ attemptId }) => { + const modal = useModal(); + const { t } = useTranslation(); + const [isRunning, setIsRunning] = useState(false); + const [errorInfo, setErrorInfo] = useState<{ + error: GhCliSetupError; + message: string; + variant: GhCliSupportVariant | null; + } | null>(null); + const pendingResultRef = useRef(null); + const hasResolvedRef = useRef(false); + + const handleRunSetup = async () => { + setIsRunning(true); + setErrorInfo(null); + pendingResultRef.current = null; + + try { + await attemptsApi.setupGhCli(attemptId); + hasResolvedRef.current = true; + modal.resolve(null); + modal.hide(); + } catch (err: any) { + const rawMessage = + typeof err?.message === 'string' + ? err.message + : t('settings:integrations.github.cliSetup.errors.setupFailed'); + + const errorData = err?.error_data as GhCliSetupError | undefined; + const resolvedError: GhCliSetupError = errorData ?? { + OTHER: { message: rawMessage }, + }; + const ui = mapGhCliErrorToUi(resolvedError, rawMessage, t); + + pendingResultRef.current = resolvedError; + setErrorInfo({ + error: resolvedError, + message: ui.message, + variant: ui.variant, + }); + } finally { + setIsRunning(false); + } + }; + + const handleClose = () => { + if (!hasResolvedRef.current) { + modal.resolve(pendingResultRef.current); + } + modal.hide(); + }; + + return ( + !open && handleClose()} + > + + + + {t('settings:integrations.github.cliSetup.title')} + + +
+

{t('settings:integrations.github.cliSetup.description')}

+ +
+

+ {t('settings:integrations.github.cliSetup.setupWillTitle')} +

+
    +
  1. + {t( + 'settings:integrations.github.cliSetup.steps.checkInstalled' + )} +
  2. +
  3. + {t( + 'settings:integrations.github.cliSetup.steps.installHomebrew' + )} +
  4. +
  5. + {t( + 'settings:integrations.github.cliSetup.steps.authenticate' + )} +
  6. +
+

+ {t('settings:integrations.github.cliSetup.setupNote')} +

+
+ {errorInfo && ( + + +

{errorInfo.message}

+ {errorInfo.variant && ( + + )} +
+
+ )} +
+ + + + +
+
+ ); + } +); diff --git a/frontend/src/components/dialogs/auth/GitHubLoginDialog.tsx b/frontend/src/components/dialogs/auth/GitHubLoginDialog.tsx deleted file mode 100644 index 13874e81..00000000 --- a/frontend/src/components/dialogs/auth/GitHubLoginDialog.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Alert } from '@/components/ui/alert'; -import { useUserSystem } from '@/components/config-provider'; -import { Check, Clipboard, Github } from 'lucide-react'; -import { Loader } from '@/components/ui/loader'; -import { githubAuthApi } from '@/lib/api'; -import { DeviceFlowStartResponse, DevicePollStatus } from 'shared/types'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import NiceModal, { useModal } from '@ebay/nice-modal-react'; - -const GitHubLoginDialog = NiceModal.create(() => { - const modal = useModal(); - const { config, loading, githubTokenInvalid, reloadSystem } = useUserSystem(); - const [fetching, setFetching] = useState(false); - const [error, setError] = useState(null); - const [deviceState, setDeviceState] = - useState(null); - const [polling, setPolling] = useState(false); - const [copied, setCopied] = useState(false); - - const isAuthenticated = - !!(config?.github?.username && config?.github?.oauth_token) && - !githubTokenInvalid; - - const handleLogin = async () => { - setFetching(true); - setError(null); - setDeviceState(null); - try { - const data = await githubAuthApi.start(); - setDeviceState(data); - setPolling(true); - } catch (e: any) { - console.error(e); - setError(e?.message || 'Network error'); - } finally { - setFetching(false); - } - }; - - // Poll for completion - useEffect(() => { - let timer: ReturnType | null = null; - if (polling && deviceState) { - const poll = async () => { - try { - const poll_status = await githubAuthApi.poll(); - switch (poll_status) { - case DevicePollStatus.SUCCESS: - setPolling(false); - setError(null); - await reloadSystem(); - modal.resolve(true); - modal.hide(); - setDeviceState(null); - break; - case DevicePollStatus.AUTHORIZATION_PENDING: - timer = setTimeout(poll, deviceState.interval * 1000); - break; - case DevicePollStatus.SLOW_DOWN: - timer = setTimeout(poll, (deviceState.interval + 5) * 1000); - } - } catch (e: any) { - if (e?.message === 'expired_token') { - setPolling(false); - setError('Device code expired. Please try again.'); - setDeviceState(null); - } else { - setPolling(false); - setError(e?.message || 'Login failed.'); - setDeviceState(null); - } - } - }; - timer = setTimeout(poll, deviceState.interval * 1000); - } - return () => { - if (timer) clearTimeout(timer); - }; - }, [polling, deviceState]); - - // Automatically copy code to clipboard and open GitHub URL when deviceState is set - useEffect(() => { - if (deviceState?.user_code) { - copyToClipboard(deviceState.user_code); - } - }, [deviceState?.user_code, deviceState?.verification_uri]); - - const copyToClipboard = async (text: string) => { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } else { - // Fallback for environments where clipboard API is not available - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-999999px'; - textArea.style.top = '-999999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - document.execCommand('copy'); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.warn('Copy to clipboard failed:', err); - } - document.body.removeChild(textArea); - } - } catch (err) { - console.warn('Copy to clipboard failed:', err); - } - }; - - return ( - { - if (!open) { - modal.resolve(isAuthenticated ? true : false); - modal.hide(); - } - }} - > - - -
- - Sign in with GitHub -
- - Connect your GitHub account to create and manage pull requests - directly from Vibe Kanban. - -
- {loading ? ( - - ) : isAuthenticated ? ( -
- - -
- - -
-
- Successfully connected! -
-
- You are signed in as {config?.github?.username ?? ''} -
-
-
- - - -
- ) : deviceState ? ( -
-
- - 1 - -
-

- Go to GitHub Device Authorization -

- - {deviceState.verification_uri} - -
-
- -
- - 2 - -
-

Enter this code:

-
- - {deviceState.user_code} - - -
-
-
- -
- - - {copied - ? 'Code copied to clipboard! Complete the authorization on GitHub.' - : 'Waiting for you to authorize this application on GitHub...'} - -
- - {error && {error}} - - - - -
- ) : ( -
- - - - Why do you need GitHub access? - - - -
- -
-

Create pull requests

-

- Generate PRs directly from your task attempts -

-
-
-
- -
-

Manage repositories

-

- Access your repos to push changes and create branches -

-
-
-
- -
-

Streamline workflow

-

- Skip manual PR creation and focus on coding -

-
-
-
-
- - {error && {error}} - - - - - -
- )} -
-
- ); -}); - -export { GitHubLoginDialog }; diff --git a/frontend/src/components/dialogs/auth/ProvidePatDialog.tsx b/frontend/src/components/dialogs/auth/ProvidePatDialog.tsx deleted file mode 100644 index 643f3f05..00000000 --- a/frontend/src/components/dialogs/auth/ProvidePatDialog.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useState } from 'react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { useUserSystem } from '@/components/config-provider'; -import { Alert, AlertDescription } from '@/components/ui/alert'; -import NiceModal, { useModal } from '@ebay/nice-modal-react'; - -export interface ProvidePatDialogProps { - errorMessage?: string; -} - -export const ProvidePatDialog = NiceModal.create( - ({ errorMessage }) => { - const modal = useModal(); - const { config, updateAndSaveConfig } = useUserSystem(); - const [pat, setPat] = useState(''); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const handleSave = async () => { - if (!config) return; - setSaving(true); - setError(null); - try { - await updateAndSaveConfig({ - github: { - ...config.github, - pat, - }, - }); - modal.resolve(true); - modal.hide(); - } catch (err) { - setError('Failed to save Personal Access Token'); - } finally { - setSaving(false); - } - }; - - return ( - !open && modal.hide()} - > - - - Provide GitHub Personal Access Token - -
-

- {errorMessage || - 'Your GitHub OAuth token does not have sufficient permissions to open a PR in this repository.'} -
-
- Please provide a Personal Access Token with repo{' '} - permissions. -

- setPat(e.target.value)} - autoFocus - /> -

- - Create a token here - -

- {error && ( - - {error} - - )} -
- - - - -
-
- ); - } -); diff --git a/frontend/src/components/dialogs/global/OAuthDialog.tsx b/frontend/src/components/dialogs/global/OAuthDialog.tsx new file mode 100644 index 00000000..85e466fd --- /dev/null +++ b/frontend/src/components/dialogs/global/OAuthDialog.tsx @@ -0,0 +1,306 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { LogIn, Github, Loader2, Chrome } from 'lucide-react'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useState, useRef, useEffect } from 'react'; +import { useAuthMutations } from '@/hooks/auth/useAuthMutations'; +import { useAuthStatus } from '@/hooks/auth/useAuthStatus'; +import { useUserSystem } from '@/components/config-provider'; +import type { ProfileResponse } from 'shared/types'; +import { useTranslation } from 'react-i18next'; + +type OAuthProvider = 'github' | 'google'; + +type OAuthState = + | { type: 'select' } + | { type: 'waiting'; provider: OAuthProvider } + | { type: 'success'; profile: ProfileResponse } + | { type: 'error'; message: string }; + +const OAuthDialog = NiceModal.create(() => { + const modal = useModal(); + const { t } = useTranslation('common'); + const { reloadSystem } = useUserSystem(); + const [state, setState] = useState({ type: 'select' }); + const popupRef = useRef(null); + const [isPolling, setIsPolling] = useState(false); + + // Auth mutations hook + const { initHandoff } = useAuthMutations({ + onInitSuccess: (data) => { + // Open popup window with authorize URL + const width = 600; + const height = 700; + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + + popupRef.current = window.open( + data.authorize_url, + 'oauth-popup', + `width=${width},height=${height},left=${left},top=${top},popup=yes,noopener=yes` + ); + + // Start polling + setIsPolling(true); + }, + onInitError: (error) => { + setState({ + type: 'error', + message: + error instanceof Error + ? error.message + : 'Failed to initialize OAuth flow', + }); + }, + }); + + // Poll for auth status using proper query hook + const { data: statusData, isError: isStatusError } = useAuthStatus({ + enabled: isPolling, + }); + + // Handle status check errors + useEffect(() => { + if (isStatusError && isPolling) { + setIsPolling(false); + setState({ + type: 'error', + message: 'Failed to check OAuth status', + }); + } + }, [isStatusError, isPolling]); + + // Monitor status changes + useEffect(() => { + if (!isPolling || !statusData) return; + + // Check if popup is closed + if (popupRef.current?.closed) { + setIsPolling(false); + if (!statusData.logged_in) { + setState({ + type: 'error', + message: 'OAuth window was closed before completing authentication', + }); + } + } + + // If logged in, stop polling and trigger success + if (statusData.logged_in && statusData.profile) { + setIsPolling(false); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + + // Reload user system to refresh login status + reloadSystem(); + + setState({ type: 'success', profile: statusData.profile }); + setTimeout(() => { + modal.resolve(statusData.profile); + modal.hide(); + }, 1500); + } + }, [statusData, isPolling, modal, reloadSystem]); + + const handleProviderSelect = (provider: OAuthProvider) => { + setState({ type: 'waiting', provider }); + + // Get the current window location as return_to + const returnTo = `${window.location.origin}/api/auth/handoff/complete`; + + // Initialize handoff flow + initHandoff.mutate({ provider, returnTo }); + }; + + const handleClose = () => { + setIsPolling(false); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + setState({ type: 'select' }); + modal.resolve(null); + modal.hide(); + }; + + const handleBack = () => { + setIsPolling(false); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + setState({ type: 'select' }); + }; + + // Cleanup polling when dialog closes + useEffect(() => { + if (!modal.visible) { + setIsPolling(false); + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + } + }, [modal.visible]); + + const renderContent = () => { + switch (state.type) { + case 'select': + return ( + <> + +
+ + {t('oauth.title')} +
+ + {t('oauth.description')} + +
+ +
+ + + +
+ + + + + + ); + + case 'waiting': + return ( + <> + +
+ + {t('oauth.waitingTitle')} +
+ + {t('oauth.waitingDescription')} + +
+ +
+
+ + {t('oauth.waitingForAuth')} +
+

+ {t('oauth.popupInstructions')} +

+
+ + + + + + + ); + + case 'success': + return ( + <> + + {t('oauth.successTitle')} + + {t('oauth.welcomeBack', { + name: state.profile.username || state.profile.email, + })} + + + +
+
+ + + +
+
+ + ); + + case 'error': + return ( + <> + + {t('oauth.errorTitle')} + + {t('oauth.errorDescription')} + + + +
+ + {state.message} + +
+ + + + + + + ); + } + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + > + + {renderContent()} + + + ); +}); + +export { OAuthDialog }; diff --git a/frontend/src/components/dialogs/global/PrivacyOptInDialog.tsx b/frontend/src/components/dialogs/global/PrivacyOptInDialog.tsx deleted file mode 100644 index d97b3056..00000000 --- a/frontend/src/components/dialogs/global/PrivacyOptInDialog.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Shield, CheckCircle, XCircle, Settings } from 'lucide-react'; -import { useUserSystem } from '@/components/config-provider'; -import NiceModal, { useModal } from '@ebay/nice-modal-react'; - -const PrivacyOptInDialog = NiceModal.create(() => { - const modal = useModal(); - const { config } = useUserSystem(); - - // Check if user is authenticated with GitHub - const isGitHubAuthenticated = - config?.github?.username && config?.github?.oauth_token; - - const handleOptIn = () => { - modal.resolve(true); - }; - - const handleOptOut = () => { - modal.resolve(false); - }; - - return ( - - - -
- - Feedback -
- - Help us improve Vibe Kanban by sharing usage data and allowing us to - contact you if needed. - -
- -
-

What data do we collect?

-
- {isGitHubAuthenticated && ( -
- -
-

- GitHub profile information -

-

- Username and email address to send you only very important - updates about the project. We promise not to abuse this -

-
-
- )} -
- -
-

High-level usage metrics

-

- Number of tasks created, projects managed, feature usage -

-
-
-
- -
-

- Performance and error data -

-

- Application crashes, response times, technical issues -

-
-
-
- -
-

We do NOT collect

-

- Task contents, code snippets, project names, or other personal - data -

-
-
-
- -
- - - This helps us prioritize improvements. You can change this - preference anytime in Settings. - -
-
- - - - - -
-
- ); -}); - -export { PrivacyOptInDialog }; diff --git a/frontend/src/components/dialogs/index.ts b/frontend/src/components/dialogs/index.ts index c9bbba2d..882a3b67 100644 --- a/frontend/src/components/dialogs/index.ts +++ b/frontend/src/components/dialogs/index.ts @@ -1,15 +1,18 @@ // Global app dialogs export { DisclaimerDialog } from './global/DisclaimerDialog'; export { OnboardingDialog } from './global/OnboardingDialog'; -export { PrivacyOptInDialog } from './global/PrivacyOptInDialog'; export { ReleaseNotesDialog } from './global/ReleaseNotesDialog'; +export { OAuthDialog } from './global/OAuthDialog'; -// Authentication dialogs -export { GitHubLoginDialog } from './auth/GitHubLoginDialog'; +// Organization dialogs export { - ProvidePatDialog, - type ProvidePatDialogProps, -} from './auth/ProvidePatDialog'; + CreateOrganizationDialog, + type CreateOrganizationResult, +} from './org/CreateOrganizationDialog'; +export { + InviteMemberDialog, + type InviteMemberResult, +} from './org/InviteMemberDialog'; // Project-related dialogs export { @@ -21,6 +24,10 @@ export { ProjectEditorSelectionDialog, type ProjectEditorSelectionDialogProps, } from './projects/ProjectEditorSelectionDialog'; +export { + LinkProjectDialog, + type LinkProjectResult, +} from './projects/LinkProjectDialog'; // Task-related dialogs export { @@ -37,6 +44,7 @@ export { DeleteTaskConfirmationDialog, type DeleteTaskConfirmationDialogProps, } from './tasks/DeleteTaskConfirmationDialog'; +export { ShareDialog, type ShareDialogProps } from './tasks/ShareDialog'; export { TagEditDialog, type TagEditDialogProps, @@ -65,6 +73,14 @@ export { GitActionsDialog, type GitActionsDialogProps, } from './tasks/GitActionsDialog'; +export { + ReassignDialog, + type ReassignDialogProps, +} from './tasks/ReassignDialog'; +export { + StopShareTaskDialog, + type StopShareTaskDialogProps, +} from './tasks/StopShareTaskDialog'; // Settings dialogs export { diff --git a/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx b/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx new file mode 100644 index 00000000..637cf61f --- /dev/null +++ b/frontend/src/components/dialogs/org/CreateOrganizationDialog.tsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useOrganizationMutations } from '@/hooks/useOrganizationMutations'; +import { useTranslation } from 'react-i18next'; + +export type CreateOrganizationResult = { + action: 'created' | 'canceled'; + organizationId?: string; +}; + +export const CreateOrganizationDialog = NiceModal.create(() => { + const modal = useModal(); + const { t } = useTranslation('organization'); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [isManualSlug, setIsManualSlug] = useState(false); + const [error, setError] = useState(null); + + const { createOrganization } = useOrganizationMutations({ + onCreateSuccess: (result) => { + modal.resolve({ + action: 'created', + organizationId: result.organization.id, + } as CreateOrganizationResult); + modal.hide(); + }, + onCreateError: (err) => { + setError( + err instanceof Error ? err.message : 'Failed to create organization' + ); + }, + }); + + useEffect(() => { + // Reset form when dialog opens + if (modal.visible) { + setName(''); + setSlug(''); + setIsManualSlug(false); + setError(null); + } + }, [modal.visible]); + + // Auto-generate slug from name if not manually edited + useEffect(() => { + if (!isManualSlug && name) { + const generatedSlug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + setSlug(generatedSlug); + } + }, [name, isManualSlug]); + + const validateName = (value: string): string | null => { + const trimmedValue = value.trim(); + if (!trimmedValue) return 'Organization name is required'; + if (trimmedValue.length < 3) + return 'Organization name must be at least 3 characters'; + if (trimmedValue.length > 50) + return 'Organization name must be 50 characters or less'; + return null; + }; + + const validateSlug = (value: string): string | null => { + const trimmedValue = value.trim(); + if (!trimmedValue) return 'Slug is required'; + if (trimmedValue.length < 3) return 'Slug must be at least 3 characters'; + if (trimmedValue.length > 50) return 'Slug must be 50 characters or less'; + if (!/^[a-z0-9-]+$/.test(trimmedValue)) { + return 'Slug can only contain lowercase letters, numbers, and hyphens'; + } + if (trimmedValue.startsWith('-') || trimmedValue.endsWith('-')) { + return 'Slug cannot start or end with a hyphen'; + } + return null; + }; + + const handleCreate = () => { + const nameError = validateName(name); + if (nameError) { + setError(nameError); + return; + } + + const slugError = validateSlug(slug); + if (slugError) { + setError(slugError); + return; + } + + setError(null); + createOrganization.mutate({ + name: name.trim(), + slug: slug.trim(), + }); + }; + + const handleCancel = () => { + modal.resolve({ action: 'canceled' } as CreateOrganizationResult); + modal.hide(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCancel(); + } + }; + + const handleSlugChange = (e: React.ChangeEvent) => { + setIsManualSlug(true); + setSlug(e.target.value); + setError(null); + }; + + return ( + + + + {t('createDialog.title')} + {t('createDialog.description')} + + +
+
+ + { + setName(e.target.value); + setError(null); + }} + placeholder={t('createDialog.namePlaceholder')} + maxLength={50} + autoFocus + disabled={createOrganization.isPending} + /> +
+ +
+ + +

+ {t('createDialog.slugHelper')} +

+
+ + {error && ( + + {error} + + )} +
+ + + + + +
+
+ ); +}); diff --git a/frontend/src/components/dialogs/org/InviteMemberDialog.tsx b/frontend/src/components/dialogs/org/InviteMemberDialog.tsx new file mode 100644 index 00000000..20cb75eb --- /dev/null +++ b/frontend/src/components/dialogs/org/InviteMemberDialog.tsx @@ -0,0 +1,193 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useOrganizationMutations } from '@/hooks/useOrganizationMutations'; +import { MemberRole } from 'shared/types'; +import { useTranslation } from 'react-i18next'; + +export type InviteMemberResult = { + action: 'invited' | 'canceled'; +}; + +export interface InviteMemberDialogProps { + organizationId: string; +} + +export const InviteMemberDialog = NiceModal.create( + (props) => { + const modal = useModal(); + const { organizationId } = props; + const { t } = useTranslation('organization'); + const [email, setEmail] = useState(''); + const [role, setRole] = useState(MemberRole.MEMBER); + const [error, setError] = useState(null); + + const { createInvitation } = useOrganizationMutations({ + onInviteSuccess: () => { + modal.resolve({ action: 'invited' } as InviteMemberResult); + modal.hide(); + }, + onInviteError: (err) => { + setError( + err instanceof Error ? err.message : 'Failed to send invitation' + ); + }, + }); + + useEffect(() => { + // Reset form when dialog opens + if (modal.visible) { + setEmail(''); + setRole(MemberRole.MEMBER); + setError(null); + } + }, [modal.visible]); + + const validateEmail = (value: string): string | null => { + const trimmedValue = value.trim(); + if (!trimmedValue) return 'Email is required'; + + // Basic email validation regex + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(trimmedValue)) { + return 'Please enter a valid email address'; + } + + return null; + }; + + const handleInvite = () => { + const emailError = validateEmail(email); + if (emailError) { + setError(emailError); + return; + } + + if (!organizationId) { + setError('No organization selected'); + return; + } + + setError(null); + createInvitation.mutate({ + orgId: organizationId, + data: { + email: email.trim(), + role: role, + }, + }); + }; + + const handleCancel = () => { + modal.resolve({ action: 'canceled' } as InviteMemberResult); + modal.hide(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCancel(); + } + }; + + return ( + + + + {t('inviteDialog.title')} + + {t('inviteDialog.description')} + + + +
+
+ + { + setEmail(e.target.value); + setError(null); + }} + placeholder={t('inviteDialog.emailPlaceholder')} + autoFocus + disabled={createInvitation.isPending} + /> +
+ +
+ + +

+ {t('inviteDialog.roleHelper')} +

+
+ + {error && ( + + {error} + + )} +
+ + + + + +
+
+ ); + } +); diff --git a/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx b/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx new file mode 100644 index 00000000..ba7a564e --- /dev/null +++ b/frontend/src/components/dialogs/projects/LinkProjectDialog.tsx @@ -0,0 +1,343 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useUserOrganizations } from '@/hooks/useUserOrganizations'; +import { useOrganizationProjects } from '@/hooks/useOrganizationProjects'; +import { useProjectMutations } from '@/hooks/useProjectMutations'; +import { useAuth } from '@/hooks/auth/useAuth'; +import { LoginRequiredPrompt } from '@/components/dialogs/shared/LoginRequiredPrompt'; +import type { Project } from 'shared/types'; +import { useTranslation } from 'react-i18next'; + +export type LinkProjectResult = { + action: 'linked' | 'canceled'; + project?: Project; +}; + +interface LinkProjectDialogProps { + projectId: string; + projectName: string; +} + +type LinkMode = 'existing' | 'create'; + +export const LinkProjectDialog = NiceModal.create( + ({ projectId, projectName }) => { + const modal = useModal(); + const { t } = useTranslation('projects'); + const { t: tCommon } = useTranslation('common'); + const { isSignedIn } = useAuth(); + const { data: orgsResponse, isLoading: orgsLoading } = + useUserOrganizations(); + + const [selectedOrgId, setSelectedOrgId] = useState(''); + const [linkMode, setLinkMode] = useState('existing'); + const [selectedRemoteProjectId, setSelectedRemoteProjectId] = + useState(''); + const [newProjectName, setNewProjectName] = useState(''); + const [error, setError] = useState(null); + + // Compute default organization (prefer non-personal) + const defaultOrgId = useMemo(() => { + const orgs = orgsResponse?.organizations ?? []; + return orgs.find((o) => !o.is_personal)?.id ?? orgs[0]?.id ?? ''; + }, [orgsResponse]); + + // Use selected or default + const currentOrgId = selectedOrgId || defaultOrgId; + + const { data: remoteProjects = [], isLoading: isLoadingProjects } = + useOrganizationProjects(linkMode === 'existing' ? currentOrgId : null); + + // Compute default project (first in list) + const defaultProjectId = useMemo(() => { + return remoteProjects[0]?.id ?? ''; + }, [remoteProjects]); + + // Use selected or default + const currentProjectId = selectedRemoteProjectId || defaultProjectId; + + const { linkToExisting, createAndLink } = useProjectMutations({ + onLinkSuccess: (project) => { + modal.resolve({ + action: 'linked', + project, + } as LinkProjectResult); + modal.hide(); + }, + onLinkError: (err) => { + setError( + err instanceof Error ? err.message : t('linkDialog.errors.linkFailed') + ); + }, + }); + + const isSubmitting = linkToExisting.isPending || createAndLink.isPending; + + useEffect(() => { + if (modal.visible) { + // Reset form when dialog opens + setLinkMode('existing'); + setSelectedOrgId(defaultOrgId); + setSelectedRemoteProjectId(''); + setNewProjectName(projectName); + setError(null); + } else { + // Cleanup when dialog closes + setLinkMode('existing'); + setSelectedOrgId(''); + setSelectedRemoteProjectId(''); + setNewProjectName(''); + setError(null); + } + }, [modal.visible, projectName, defaultOrgId]); + + const handleOrgChange = (orgId: string) => { + setSelectedOrgId(orgId); + setSelectedRemoteProjectId(''); // Reset to first project of new org + setNewProjectName(projectName); // Reset to current project name + setError(null); + }; + + const handleLink = () => { + if (!currentOrgId) { + setError(t('linkDialog.errors.selectOrganization')); + return; + } + + setError(null); + + if (linkMode === 'existing') { + if (!currentProjectId) { + setError(t('linkDialog.errors.selectRemoteProject')); + return; + } + linkToExisting.mutate({ + localProjectId: projectId, + data: { remote_project_id: currentProjectId }, + }); + } else { + if (!newProjectName.trim()) { + setError(t('linkDialog.errors.enterProjectName')); + return; + } + createAndLink.mutate({ + localProjectId: projectId, + data: { organization_id: currentOrgId, name: newProjectName.trim() }, + }); + } + }; + + const handleCancel = () => { + modal.resolve({ action: 'canceled' } as LinkProjectResult); + modal.hide(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + handleCancel(); + } + }; + + const canSubmit = () => { + if (!currentOrgId || isSubmitting) return false; + if (linkMode === 'existing') { + return !!currentProjectId && !isLoadingProjects; + } else { + return !!newProjectName.trim(); + } + }; + + return ( + + + + {t('linkDialog.title')} + {t('linkDialog.description')} + + +
+
+ +
+ {projectName} +
+
+ +
+ + {orgsLoading ? ( +
+ {t('linkDialog.loadingOrganizations')} +
+ ) : !isSignedIn ? ( + + ) : !orgsResponse?.organizations?.length ? ( + + + {t('linkDialog.noOrganizations')} + + + ) : ( + + )} +
+ + {currentOrgId && ( + <> +
+ +
+ + +
+
+ + {linkMode === 'existing' ? ( +
+ + {isLoadingProjects ? ( +
+ {t('linkDialog.loadingRemoteProjects')} +
+ ) : remoteProjects.length === 0 ? ( + + + {t('linkDialog.noRemoteProjects')} + + + ) : ( + + )} +
+ ) : ( +
+ + { + setNewProjectName(e.target.value); + setError(null); + }} + placeholder={t('linkDialog.newProjectNamePlaceholder')} + disabled={isSubmitting} + /> +
+ )} + + )} + + {error && ( + + {error} + + )} +
+ + + + + +
+
+ ); + } +); diff --git a/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx b/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx new file mode 100644 index 00000000..50807ef4 --- /dev/null +++ b/frontend/src/components/dialogs/shared/LoginRequiredPrompt.tsx @@ -0,0 +1,71 @@ +import { useCallback, type ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LogIn, type LucideIcon } from 'lucide-react'; +import NiceModal from '@ebay/nice-modal-react'; +import { OAuthDialog } from '@/components/dialogs'; + +import { Alert } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface LoginRequiredPromptProps { + className?: string; + buttonVariant?: ComponentProps['variant']; + buttonSize?: ComponentProps['size']; + buttonClassName?: string; + title?: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + icon?: LucideIcon; +} + +export function LoginRequiredPrompt({ + className, + buttonVariant = 'outline', + buttonSize = 'sm', + buttonClassName, + title, + description, + actionLabel, + onAction, + icon, +}: LoginRequiredPromptProps) { + const { t } = useTranslation('tasks'); + + const handleRedirect = useCallback(() => { + if (onAction) { + onAction(); + return; + } + void NiceModal.show(OAuthDialog); + }, [onAction]); + + const Icon = icon ?? LogIn; + + return ( + + +
+
+ {title ?? t('shareDialog.loginRequired.title')} +
+

+ {description ?? t('shareDialog.loginRequired.description')} +

+ +
+
+ ); +} diff --git a/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx b/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx index 3136d00b..deeef08a 100644 --- a/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx +++ b/frontend/src/components/dialogs/tasks/CreateAttemptDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, @@ -13,26 +13,25 @@ import { Label } from '@/components/ui/label'; import BranchSelector from '@/components/tasks/BranchSelector'; import { ExecutorProfileSelector } from '@/components/settings'; import { useAttemptCreation } from '@/hooks/useAttemptCreation'; -import { useNavigateWithSearch } from '@/hooks'; +import { + useNavigateWithSearch, + useTask, + useAttempt, + useBranches, + useTaskAttempts, +} from '@/hooks'; import { useProject } from '@/contexts/project-context'; import { useUserSystem } from '@/components/config-provider'; -import { projectsApi } from '@/lib/api'; import { paths } from '@/lib/paths'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; -import type { - GitBranch, - ExecutorProfileId, - TaskAttempt, - BaseCodingAgent, -} from 'shared/types'; +import type { ExecutorProfileId, BaseCodingAgent } from 'shared/types'; export interface CreateAttemptDialogProps { taskId: string; - latestAttempt?: TaskAttempt | null; } export const CreateAttemptDialog = NiceModal.create( - ({ taskId, latestAttempt }) => { + ({ taskId }) => { const modal = useModal(); const navigate = useNavigateWithSearch(); const { projectId } = useProject(); @@ -47,75 +46,94 @@ export const CreateAttemptDialog = NiceModal.create( }, }); - const [selectedProfile, setSelectedProfile] = + const [userSelectedProfile, setUserSelectedProfile] = useState(null); - const [selectedBranch, setSelectedBranch] = useState(null); - const [branches, setBranches] = useState([]); - const [isLoadingBranches, setIsLoadingBranches] = useState(false); + const [userSelectedBranch, setUserSelectedBranch] = useState( + null + ); - useEffect(() => { - if (modal.visible && projectId) { - setIsLoadingBranches(true); - projectsApi - .getBranches(projectId) - .then((result) => { - setBranches(result); - }) - .catch((err) => { - console.error('Failed to load branches:', err); - }) - .finally(() => { - setIsLoadingBranches(false); - }); - } - }, [modal.visible, projectId]); + const { data: branches = [], isLoading: isLoadingBranches } = useBranches( + projectId, + { enabled: modal.visible && !!projectId } + ); + + const { data: attempts = [], isLoading: isLoadingAttempts } = + useTaskAttempts(taskId, { + enabled: modal.visible, + refetchInterval: 5000, + }); + + const { data: task, isLoading: isLoadingTask } = useTask(taskId, { + enabled: modal.visible, + }); + + const parentAttemptId = task?.parent_task_attempt ?? undefined; + const { data: parentAttempt, isLoading: isLoadingParent } = useAttempt( + parentAttemptId, + { enabled: modal.visible && !!parentAttemptId } + ); + + const latestAttempt = useMemo(() => { + if (attempts.length === 0) return null; + return attempts.reduce((latest, attempt) => + new Date(attempt.created_at) > new Date(latest.created_at) + ? attempt + : latest + ); + }, [attempts]); useEffect(() => { if (!modal.visible) { - setSelectedProfile(null); - setSelectedBranch(null); + setUserSelectedProfile(null); + setUserSelectedBranch(null); } }, [modal.visible]); - useEffect(() => { - if (!modal.visible) return; + const defaultProfile: ExecutorProfileId | null = useMemo(() => { + if (latestAttempt?.executor) { + return { + executor: latestAttempt.executor as BaseCodingAgent, + variant: null, + }; + } + return config?.executor_profile ?? null; + }, [latestAttempt?.executor, config?.executor_profile]); - setSelectedProfile((prev) => { - if (prev) return prev; + const currentBranchName: string | null = useMemo(() => { + return branches.find((b) => b.is_current)?.name ?? null; + }, [branches]); - const fromAttempt: ExecutorProfileId | null = latestAttempt?.executor - ? { - executor: latestAttempt.executor as BaseCodingAgent, - variant: null, - } - : null; - - return fromAttempt ?? config?.executor_profile ?? null; - }); - - setSelectedBranch((prev) => { - if (prev) return prev; - return ( - latestAttempt?.target_branch ?? - branches.find((b) => b.is_current)?.name ?? - null - ); - }); + const defaultBranch: string | null = useMemo(() => { + return ( + parentAttempt?.branch ?? + currentBranchName ?? + latestAttempt?.target_branch ?? + null + ); }, [ - modal.visible, - latestAttempt?.executor, + parentAttempt?.branch, + currentBranchName, latestAttempt?.target_branch, - config?.executor_profile, - branches, ]); - const handleCreate = async () => { - if (!selectedProfile || !selectedBranch) return; + const effectiveProfile = userSelectedProfile ?? defaultProfile; + const effectiveBranch = userSelectedBranch ?? defaultBranch; + const isLoadingInitial = + isLoadingBranches || + isLoadingAttempts || + isLoadingTask || + isLoadingParent; + const canCreate = Boolean( + effectiveProfile && effectiveBranch && !isCreating && !isLoadingInitial + ); + + const handleCreate = async () => { + if (!effectiveProfile || !effectiveBranch) return; try { await createAttempt({ - profile: selectedProfile, - baseBranch: selectedBranch, + profile: effectiveProfile, + baseBranch: effectiveBranch, }); modal.hide(); } catch (err) { @@ -123,12 +141,8 @@ export const CreateAttemptDialog = NiceModal.create( } }; - const canCreate = selectedProfile && selectedBranch && !isCreating; - const handleOpenChange = (open: boolean) => { - if (!open) { - modal.hide(); - } + if (!open) modal.hide(); }; return ( @@ -146,8 +160,8 @@ export const CreateAttemptDialog = NiceModal.create(
@@ -160,8 +174,8 @@ export const CreateAttemptDialog = NiceModal.create( { const modal = useModal(); + const { t } = useTranslation(); + const { isLoaded } = useAuth(); + const { environment } = useUserSystem(); const data = modal.args as | { attempt: TaskAttempt; task: TaskWithAttemptStatus; projectId: string } | undefined; @@ -34,46 +50,85 @@ const CreatePrDialog = NiceModal.create(() => { const [prBaseBranch, setPrBaseBranch] = useState(''); const [creatingPR, setCreatingPR] = useState(false); const [error, setError] = useState(null); + const [ghCliHelp, setGhCliHelp] = useState(null); const [branches, setBranches] = useState([]); const [branchesLoading, setBranchesLoading] = useState(false); + const getGhCliHelpTitle = (variant: GhCliSupportVariant) => + variant === 'homebrew' + ? 'Homebrew is required for automatic setup' + : 'GitHub CLI needs manual setup'; + useEffect(() => { - if (modal.visible && data) { - setPrTitle(`${data.task.title} (vibe-kanban)`); - setPrBody(data.task.description || ''); - - // Always fetch branches for dropdown population - if (data.projectId) { - setBranchesLoading(true); - projectsApi - .getBranches(data.projectId) - .then((projectBranches) => { - setBranches(projectBranches); - - // Set smart default: task target branch OR current branch - if (data.attempt.target_branch) { - setPrBaseBranch(data.attempt.target_branch); - } else { - const currentBranch = projectBranches.find((b) => b.is_current); - if (currentBranch) { - setPrBaseBranch(currentBranch.name); - } - } - }) - .catch(console.error) - .finally(() => setBranchesLoading(false)); - } - - setError(null); // Reset error when opening + if (!modal.visible || !data || !isLoaded) { + return; } - }, [modal.visible, data]); + + setPrTitle(`${data.task.title} (vibe-kanban)`); + setPrBody(data.task.description || ''); + + // Always fetch branches for dropdown population + if (data.projectId) { + setBranchesLoading(true); + projectsApi + .getBranches(data.projectId) + .then((projectBranches) => { + setBranches(projectBranches); + + // Set smart default: task target branch OR current branch + if (data.attempt.target_branch) { + setPrBaseBranch(data.attempt.target_branch); + } else { + const currentBranch = projectBranches.find((b) => b.is_current); + if (currentBranch) { + setPrBaseBranch(currentBranch.name); + } + } + }) + .catch(console.error) + .finally(() => setBranchesLoading(false)); + } + + setError(null); // Reset error when opening + setGhCliHelp(null); + }, [modal.visible, data, isLoaded]); + + const isMacEnvironment = useMemo( + () => environment?.os_type?.toLowerCase().includes('mac'), + [environment?.os_type] + ); const handleConfirmCreatePR = useCallback(async () => { if (!data?.projectId || !data?.attempt.id) return; setError(null); + setGhCliHelp(null); setCreatingPR(true); + const handleGhCliSetupOutcome = ( + setupResult: GhCliSetupError | null, + fallbackMessage: string + ) => { + if (setupResult === null) { + setError(null); + setGhCliHelp(null); + setCreatingPR(false); + modal.hide(); + return; + } + + const ui = mapGhCliErrorToUi(setupResult, fallbackMessage, t); + + if (ui.variant) { + setGhCliHelp(ui); + setError(null); + return; + } + + setGhCliHelp(null); + setError(ui.message); + }; + const result = await attemptsApi.createPR(data.attempt.id, { title: prTitle, body: prBody || null, @@ -81,53 +136,84 @@ const CreatePrDialog = NiceModal.create(() => { }); if (result.success) { - setError(null); // Clear any previous errors on success - // Reset form and close dialog setPrTitle(''); setPrBody(''); setPrBaseBranch(''); setCreatingPR(false); modal.hide(); - } else { - setCreatingPR(false); - if (result.error) { - modal.hide(); - switch (result.error) { - case GitHubServiceError.TOKEN_INVALID: { - const authSuccess = await NiceModal.show('github-login'); - if (authSuccess) { - modal.show(); - await handleConfirmCreatePR(); - } - return; - } - case GitHubServiceError.INSUFFICIENT_PERMISSIONS: { - const patProvided = await NiceModal.show('provide-pat'); - if (patProvided) { - modal.show(); - await handleConfirmCreatePR(); - } - return; - } - case GitHubServiceError.REPO_NOT_FOUND_OR_NO_ACCESS: { - const patProvided = await NiceModal.show('provide-pat', { - errorMessage: - 'Your token does not have access to this repository, or the repository does not exist. Please check the repository URL and/or provide a Personal Access Token with access.', - }); - if (patProvided) { - modal.show(); - await handleConfirmCreatePR(); - } - return; + return; + } + + setCreatingPR(false); + + const defaultGhCliErrorMessage = + result.message || 'Failed to run GitHub CLI setup.'; + + const showGhCliSetupDialog = async () => { + const setupResult = (await NiceModal.show(GhCliSetupDialog, { + attemptId: data.attempt.id, + })) as GhCliSetupError | null; + + handleGhCliSetupOutcome(setupResult, defaultGhCliErrorMessage); + }; + + if (result.error) { + switch (result.error) { + case GitHubServiceError.GH_CLI_NOT_INSTALLED: { + if (isMacEnvironment) { + await showGhCliSetupDialog(); + } else { + const ui = mapGhCliErrorToUi( + 'SETUP_HELPER_NOT_SUPPORTED', + defaultGhCliErrorMessage, + t + ); + setGhCliHelp(ui.variant ? ui : null); + setError(ui.variant ? null : ui.message); } + return; } - } else if (result.message) { - setError(result.message); - } else { - setError('Failed to create GitHub PR'); + case GitHubServiceError.TOKEN_INVALID: { + if (isMacEnvironment) { + await showGhCliSetupDialog(); + } else { + const ui = mapGhCliErrorToUi( + 'SETUP_HELPER_NOT_SUPPORTED', + defaultGhCliErrorMessage, + t + ); + setGhCliHelp(ui.variant ? ui : null); + setError(ui.variant ? null : ui.message); + } + return; + } + case GitHubServiceError.INSUFFICIENT_PERMISSIONS: + setError( + 'Insufficient permissions. Please ensure the GitHub CLI has the necessary permissions.' + ); + setGhCliHelp(null); + return; + case GitHubServiceError.REPO_NOT_FOUND_OR_NO_ACCESS: + setError( + 'Repository not found or no access. Please check your repository access and ensure you are authenticated.' + ); + setGhCliHelp(null); + return; + default: + setError(result.message || 'Failed to create GitHub PR'); + setGhCliHelp(null); + return; } } - }, [data, prBaseBranch, prBody, prTitle, modal]); + + if (result.message) { + setError(result.message); + setGhCliHelp(null); + } else { + setError('Failed to create GitHub PR'); + setGhCliHelp(null); + } + }, [data, prBaseBranch, prBody, prTitle, modal, isMacEnvironment]); const handleCancelCreatePR = useCallback(() => { modal.hide(); @@ -150,42 +236,64 @@ const CreatePrDialog = NiceModal.create(() => { Create a pull request for this task attempt on GitHub. -
-
- - setPrTitle(e.target.value)} - placeholder="Enter PR title" - /> + {!isLoaded ? ( +
+
-
- -