diff --git a/.github/workflows/publish-vscode-extension.yml b/.github/workflows/publish-vscode-extension.yml new file mode 100644 index 0000000..34f4d15 --- /dev/null +++ b/.github/workflows/publish-vscode-extension.yml @@ -0,0 +1,72 @@ +name: Publish VS Code Extension + +# Publishes the autter VS Code extension (agent-support/vscode) to the +# Open VSX Registry and, if a token is configured, the VS Code Marketplace. +# +# Triggers: +# - pushing a tag matching `vscode-v*` (e.g. vscode-v0.1.23) +# - manual run via the Actions tab (workflow_dispatch) +# +# Required repository secrets: +# OVSX_PAT - Open VSX access token (https://open-vsx.org user settings) +# VSCE_PAT - VS Code Marketplace PAT (optional; Marketplace step is skipped if unset) + +on: + push: + tags: + - "vscode-v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: agent-support/vscode + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: agent-support/vscode/package-lock.json + + - name: Install dependencies + run: npm ci + + # When triggered by a `vscode-v` tag, the tag is the source of truth + # for the version. Sync package.json to it so the packaged/published .vsix + # can't drift from the tag (e.g. tag vscode-v0.1.23 must publish 0.1.23). + - name: Sync version to tag + if: startsWith(github.ref, 'refs/tags/vscode-v') + run: | + VERSION="${GITHUB_REF_NAME#vscode-v}" + echo "Setting extension version to $VERSION (from tag $GITHUB_REF_NAME)" + npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Package extension (.vsix) + run: npm run package + + - name: Publish to Open VSX + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: npx ovsx publish *.vsix -p "$OVSX_PAT" + + - name: Publish to VS Code Marketplace + if: ${{ secrets.VSCE_PAT != '' }} + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: npx vsce publish --packagePath *.vsix -p "$VSCE_PAT" + + - name: Upload .vsix artifact + uses: actions/upload-artifact@v4 + with: + name: autter-vscode-vsix + path: agent-support/vscode/*.vsix + if-no-files-found: warn diff --git a/AGENTS.md b/AGENTS.md index 5d9e8e9..9301263 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ cargo insta accept # accept all pending snapshots Before opening a PR, make sure to run `task lint` and `task fmt` and resolve any formatting/lint issues as they will fail in CI. -When opening a PR, make sure to monitor the ubuntu-based CI jobs first. They are the fastest (roughly 15mins) and if they fail, you should quickly iterate based on those failures and update the PR -- iterating there until those jobs are all green. Additionally, while you're checking on the ubuntu-based jobs, our automated PR review bot, Devin, should have had time to leave feedback. Make sure to read all of Devin's PR review feedback commits and address them. Address them means review, understand, evaluate, and fix if necessary or comment with your thoughts if you don't the feedback is a real issue. Once the lint, fmt, and Ubuntu-based tests have passed and you have addressed all Devin PR review feedback, you can stop monitoring CI for the Mac (~35mins) and Windows (up to 3.5 hours) checks unless the user has explicitly asked for you to wait for those or you're working on a specific OS-based bug. +When opening a PR, make sure to monitor the ubuntu-based CI jobs first. They are the fastest (roughly 15mins) and if they fail, you should quickly iterate based on those failures and update the PR -- iterating there until those jobs are all green. Once the lint, fmt, and Ubuntu-based tests have passed, you can stop monitoring CI for the Mac (~35mins) and Windows (up to 3.5 hours) checks unless the user has explicitly asked for you to wait for those or you're working on a specific OS-based bug. ## Architecture diff --git a/Cargo.lock b/Cargo.lock index 1af2d67..ac60ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,17 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic" version = "0.6.1" @@ -261,6 +272,7 @@ name = "autter" version = "1.5.9" dependencies = [ "autter", + "base64", "chrono", "clap", "criterion", @@ -286,6 +298,8 @@ dependencies = [ "once_cell", "openssl", "paste", + "postgres", + "postgres-native-tls", "rand 0.10.1", "ratatui", "regex", @@ -295,7 +309,7 @@ dependencies = [ "serde_json", "serde_json_canonicalizer", "serial_test", - "sha2", + "sha2 0.10.9", "smol", "tempfile", "tokio", @@ -352,6 +366,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -527,6 +550,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.4" @@ -577,6 +606,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -743,6 +778,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -750,7 +794,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -852,8 +905,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -961,6 +1026,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1217,7 +1288,7 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -1707,6 +1778,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.4.0" @@ -1752,6 +1832,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -2107,11 +2196,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2182,13 +2272,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.8.1", ] [[package]] @@ -2272,6 +2363,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2326,7 +2427,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2454,6 +2555,24 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2564,7 +2683,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2621,7 +2740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2631,7 +2750,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -2641,7 +2770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -2650,7 +2779,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] @@ -2661,7 +2790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.117", @@ -2676,6 +2805,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2699,6 +2837,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plotters" version = "0.3.7" @@ -2756,6 +2900,63 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postgres" +version = "0.19.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", + "serde_core", + "serde_json", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3019,6 +3220,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -3101,7 +3311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ "bitflags 2.10.0", - "fallible-iterator", + "fallible-iterator 0.3.0", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", @@ -3338,7 +3548,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -3347,7 +3557,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ - "digest", + "digest 0.10.7", "sha1", ] @@ -3359,7 +3569,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3476,6 +3697,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3557,7 +3789,7 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -3594,8 +3826,8 @@ dependencies = [ "ordered-float", "pest", "pest_derive", - "phf", - "sha2", + "phf 0.11.3", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -3744,6 +3976,42 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3877,9 +4145,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -3887,6 +4155,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -3908,6 +4182,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -4035,6 +4315,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -4053,11 +4342,20 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -4068,9 +4366,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4078,9 +4376,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -4091,9 +4389,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -4134,9 +4432,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4170,7 +4468,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -4224,6 +4522,19 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6185141..03cb115 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,11 @@ +[workspace] +# The CLI writes authorship notes and prompt CAS objects straight to each org's +# own Postgres (connection URL comes from the access-token JWT's `org_db_url` +# claim), so there is no separate backend crate to build or deploy. +members = [] +default-members = ["."] +resolver = "3" + [package] name = "autter" version = "1.5.9" @@ -25,6 +33,12 @@ jsonc-parser = { version = "0.32", features = ["cst"] } dirs = "5.0" ureq = { version = "2.12", default-features = false, features = ["native-tls"] } native-tls = "0.2" +# Direct writes to each org's own Postgres (authorship notes + prompt CAS). The +# org's connection URL is read from the access-token JWT's `org_db_url` claim, so +# the CLI talks to the org database itself — there is no intermediate backend. +postgres = { version = "0.19", features = ["with-serde_json-1"] } +postgres-native-tls = "0.5" +base64 = "0.22" url = "2.5" glob = "0.3" ignore = "0.4" diff --git a/README.md b/README.md index 1e9b98f..7a13ab1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,52 @@ powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://autter.dev/i **No per-repo setup or git hooks required.** Commit with the Agent, git, or your favorite git client. Attribution will be linked to commits automatically. +During install you'll be asked whether to run **local-only** or **connect to the Autter platform**. You can change this any time with `autter onboard`. + +## Connect to the Autter platform (optional) + +Local-only mode works fully offline with no account. Connecting links this machine to your Autter account so attribution and prompt history sync to the platform's dashboards (per-user / per-team usage, prompt search, and audit logs). + +Sign in is a two-step, browser-based flow using a **Personal Access Token (PAT)**: + +```bash +# 1. Opens https://app.autter.dev in your browser +autter login + +# 2. In the dashboard: Settings → Access Tokens → Create token → copy it. +# Then complete sign-in with the token you copied: +autter login --token autter_pat_xxxxxxxx + +# Confirm who you're signed in as: +autter whoami +``` + +Once connected: + +- **Authorship notes** (which lines are AI vs human, per commit) and **prompt transcripts** are written on commit straight to **your organization's own database** — never shared across orgs. The CLI connects to that database directly using the connection URL carried in your signed access token; there is no intermediate Autter server in the data path. +- A token is scoped to your account; if you belong to multiple organizations, each push is automatically routed to the org that owns the repository (resolved from its git remote), falling back to your default org. +- Manage and revoke tokens, and view CLI activity (token created, sign-in, data pushed), under **Settings → Access Tokens** in the dashboard. + +To go back to local-only at any time: + +```bash +autter logout # clear stored credentials +autter onboard --force # re-choose local or connected +``` + +### Configuration + +`autter` reads `~/.autter/config.json`. Defaults point at the hosted platform; override per machine (e.g. for self-hosting or CI): + +| Field | Default | Purpose | +|-------|---------|---------| +| `api_base_url` | `https://api.autter.dev` | Auth + token exchange | +| `notes_backend.kind` | `git_notes` (local) / `http` (connected) | Where authorship notes go | +| `notes_backend.backend_url` | `https://cli.autter.dev` | Gate that enables cloud sync; the actual notes/prompt writes go straight to your org database (URL from your token), not to this host | +| `prompt_storage` | `local` / `default` (connected) | `default` uploads prompts, `local` keeps them on-device | + +Env overrides: `AUTTER_API_BASE_URL`, `AUTTER_WEB_URL`, `AUTTER_NOTES_BACKEND_KIND`, `AUTTER_NOTES_BACKEND_URL`, `AUTTER_API_KEY` (for CI). + **The [Autter standard](https://github.com/autter-dev/autter-cli/blob/main/specs/autter_standard_v3.0.0.md) is supported by:** diff --git a/agent-support/vscode/package.json b/agent-support/vscode/package.json index c9bead3..aa241d4 100644 --- a/agent-support/vscode/package.json +++ b/agent-support/vscode/package.json @@ -2,7 +2,7 @@ "name": "autter-vscode", "displayName": "autter", "description": "Keep track of code generated by AI.", - "version": "0.1.22", + "version": "0.1.23", "icon": "autter.png", "publisher": "autter", "repository": { diff --git a/assets/docs/autter.png b/assets/docs/autter.png index 06f67c1..56a44fa 100644 Binary files a/assets/docs/autter.png and b/assets/docs/autter.png differ diff --git a/docs/notes-backend-spec.md b/docs/notes-backend-spec.md deleted file mode 100644 index c75a149..0000000 --- a/docs/notes-backend-spec.md +++ /dev/null @@ -1,388 +0,0 @@ -# Notes Backend HTTP Contract - -This document specifies the HTTP contract that an external "notes backend" -server must implement so that a client can store and retrieve **authorship -notes** keyed by commit SHA. The contract is intentionally small: it is a -commit-addressable key/value store with bulk write and bulk read. - -A reader implementing a server from scratch needs only this document. No -prior knowledge of the client is required. - ---- - -## 1. Conceptual model - -The server stores opaque UTF-8 strings ("note content") keyed by a hex commit -SHA. There is exactly one logical store per deployment. - -- **Keys** are hex commit SHAs (lowercase hex). Validation requirements are - given in §4.1; clients reject non-hex keys before sending. -- **Values** are arbitrary UTF-8 strings. The server **must not** parse, - rewrite, or re-encode the value. Treat it as an opaque blob. -- **Cardinality**: each key holds at most one value. Writing the same key - again replaces the previous value. - -The server is the source of truth for the (key → value) mapping. Clients -maintain their own caches but always defer to the server's last-written -value when they sync. - ---- - -## 2. Transport - -- **Protocol**: HTTP/1.1 over TCP. TLS is recommended for any - non-loopback deployment but not required by this spec. -- **Encoding**: All request and response bodies are JSON - (`Content-Type: application/json`). Bodies are UTF-8. -- **Methods**: Only `GET` and `POST` are used. The server should respond - `405 Method Not Allowed` (or `404`) for any other method on a known path. -- **Connections**: The server may use any connection model - (keep-alive, close-after-response, HTTP/2, etc.). The client makes no - assumption beyond standard HTTP semantics. - -A request that does not match any of the endpoints in §3 must return `404`. - ---- - -## 3. Endpoints - -There are exactly two endpoints. Both live under the prefix `/worker/notes`. -A trailing slash on the path is permitted on both endpoints; servers must -treat `/worker/notes` and `/worker/notes/` as equivalent. - -The client allows a path prefix on the configured base URL, so a server -implementor may host the endpoints under any subpath that suits their -deployment. For example, with the client configured against -`https://app.example.com/api/autter`, the requests issued are -`POST https://app.example.com/api/autter/worker/notes/upload` and -`GET https://app.example.com/api/autter/worker/notes/?commits=...`. -The `/worker/notes` suffix is fixed; everything before it is the -deployment's choice. - -### 3.1 `POST /worker/notes/upload` — bulk write - -Stores or replaces a batch of notes. - -**Request body** (JSON): - -```json -{ - "entries": [ - { "commit_sha": "", "content": "" }, - { "commit_sha": "", "content": "" } - ] -} -``` - -| Field | Type | Required | Description | -|-------------------|-----------------|----------|----------------------------------------------| -| `entries` | array of object | yes | The notes to write. May be empty. | -| `entries[].commit_sha` | string | yes | Hex commit SHA. See §4.1. | -| `entries[].content` | string | yes | Opaque UTF-8 note content. Any length ≥ 0. | - -**Semantics**: - -- For each entry, the server **upserts** the (commit_sha → content) pair. - Any prior value for that key is replaced. -- The operation is **idempotent**: replaying the exact same request - yields the same final state. -- Atomicity across the batch is **not** required. Partial success is - allowed (see `failure_count` below). -- Order within `entries` is not significant; the server may apply writes - in any order. - -**Successful response** (`200 OK`): - -```json -{ - "success_count": 2, - "failure_count": 0 -} -``` - -| Field | Type | Description | -|-----------------|---------|-------------------------------------------------------| -| `success_count` | integer | Number of entries that were stored. | -| `failure_count` | integer | Number of entries that could not be stored. | - -`success_count + failure_count` should equal `entries.length`. - -**Error responses**: - -| Status | When | Body | -|--------|--------------------------------------------------------------|-------------------------------------| -| `400` | Body is not valid JSON, or does not match the schema above | `{ "error": "" }` | -| `401` | Authentication required and missing/invalid (see §5) | `{ "error": "" }` | -| `5xx` | Internal failure | `{ "error": "" }` | - -The client treats any non-`200` response as a retriable failure (with -backoff) **except** `400`, which it treats as a permanent failure for -that batch. - -### 3.2 `GET /worker/notes/?commits=,,...` — bulk read - -Looks up notes for a list of commit SHAs. - -**Query parameters**: - -| Name | Type | Required | Description | -|-----------|--------|----------|------------------------------------------------------------| -| `commits` | string | yes | Comma-separated list of hex commit SHAs. No whitespace. | - -The server should accept up to **100 commit SHAs per request**. Behavior -on more than 100 is implementation-defined (truncation, `400`, or fully -honoring the request are all permitted), but a compliant server should -not crash. - -**Successful response** (`200 OK`) — at least one requested SHA is known: - -```json -{ - "notes": { - "": "", - "": "" - } -} -``` - -| Field | Type | Description | -|-------------|--------|-------------------------------------------------------------| -| `notes` | object | Map from commit SHA (string) to note content (string). | - -Only SHAs that exist in the store appear as keys. Unknown SHAs are -silently omitted from the map; the client treats their absence as -"no note for that commit". - -**Empty result** (`404 Not Found`) — none of the requested SHAs are known: - -```json -{ "notes": {} } -``` - -The client treats `404` as success-with-empty. Servers may alternatively -return `200` with an empty `notes` map; both are acceptable. (The -reference server returns `404` to exercise the cold-miss path; production -servers may pick whichever they prefer.) - -**Error responses**: - -| Status | When | Body | -|--------|-------------------------------------------------|-------------------------------------| -| `400` | `commits` parameter is missing or malformed | `{ "error": "" }` | -| `401` | Authentication required and missing/invalid | `{ "error": "" }` | -| `5xx` | Internal failure | `{ "error": "" }` | - ---- - -## 4. Validation rules - -### 4.1 Commit SHA format - -A `commit_sha` is a string consisting of lowercase or uppercase -hexadecimal characters: `[0-9a-fA-F]`. Servers should accept any length -from 4 to 64 characters; in practice clients always send 40-character -SHA-1 hashes, but the spec does not constrain length so future hash -algorithms are accommodated. - -A server **must not** treat two SHAs that differ only in case as the -same key. Clients always normalize to a single case before sending, so -servers can perform exact-string comparison. - -A server **may** reject a request with `400` if any provided SHA -contains non-hex characters. - -### 4.2 Content size - -A single note is typically a few KB but may grow into the low MBs in -extreme cases. Servers should accept individual note content of at -least **8 MB**. Servers should accept upload batches with a body of at -least **64 MB**. Servers exceeding their limits should respond `413` -with an error body. - -### 4.3 Empty inputs - -- `entries: []` is a valid upload request. The response is - `{ "success_count": 0, "failure_count": 0 }`. -- `commits=` (empty value) on read may return either `200` with - `notes: {}` or `404` with `notes: {}`. Both are conforming. - ---- - -## 5. Authentication - -Authentication is **optional at the protocol level** but expected in -production. When required, servers authenticate the client via one of -two HTTP request headers (the client always sends one of these when -configured with credentials): - -| Header | Format | Notes | -|-----------------|-----------------------|------------------------------------| -| `X-API-Key` | `` | Long-lived, per-account API key. | -| `Authorization` | `Bearer ` | Short-lived access token. | - -Servers that require authentication must respond `401 Unauthorized` for -unauthenticated requests, with a JSON error body. Authorization (which -keys a given principal may write to) is out of scope for this document. - -A server that does not require authentication ignores both headers. - ---- - -## 6. Concurrency, ordering, and durability - -- **Last-writer-wins**: if two upload requests arrive concurrently with - conflicting values for the same key, either value may win. Clients do - not depend on a specific resolution rule, but the server must not - produce a value that was never sent (no merging, no truncation). -- **Read-after-write**: a successful response to `POST /worker/notes/upload` - implies the written entries are visible to subsequent - `GET /worker/notes/` requests on the same logical store. -- **Durability**: by the time `200` is returned, written entries should - be durable across server restarts. Clients treat successful uploads as - permanent; replaying them after a server restart is allowed but not - required. -- **No deletes, no list, no enumeration**: this spec does not define a - delete or list-all endpoint. Clients never need to enumerate all keys - or remove a key. - ---- - -## 7. Versioning and forward compatibility - -This document defines version 1 of the contract. There is no version -header. Clients and servers should ignore unknown JSON fields they do -not recognize so that fields can be added later without a hard break. - -Future versions will either: - -- Add new optional fields that older servers ignore (no break). -- Add new endpoints under `/worker/notes/...` (older clients won't call - them, no break). -- Or, if a breaking change is needed, ship under a new path prefix - (e.g. `/worker/notes/v2/...`) so v1 clients continue to work. - ---- - -## 8. Worked examples - -### 8.1 Upload two notes - -Request: - -```http -POST /worker/notes/upload HTTP/1.1 -Host: notes.example.com -Content-Type: application/json -X-API-Key: sk_live_abc123 -Content-Length: 178 - -{ - "entries": [ - { "commit_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "content": "note-a" }, - { "commit_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "content": "note-b" } - ] -} -``` - -Response: - -```http -HTTP/1.1 200 OK -Content-Type: application/json -Content-Length: 41 - -{"success_count":2,"failure_count":0} -``` - -### 8.2 Read three notes (one missing) - -Request: - -```http -GET /worker/notes/?commits=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,cccccccccccccccccccccccccccccccccccccccc HTTP/1.1 -Host: notes.example.com -X-API-Key: sk_live_abc123 -``` - -Response (the third SHA is unknown and is omitted): - -```http -HTTP/1.1 200 OK -Content-Type: application/json - -{ - "notes": { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": "note-a", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb": "note-b" - } -} -``` - -### 8.3 Read against an empty store - -Request: - -```http -GET /worker/notes/?commits=cccccccccccccccccccccccccccccccccccccccc HTTP/1.1 -Host: notes.example.com -``` - -Response: - -```http -HTTP/1.1 404 Not Found -Content-Type: application/json - -{ "notes": {} } -``` - -(`200` with `{ "notes": {} }` is equally acceptable here.) - -### 8.4 Malformed upload - -Request: - -```http -POST /worker/notes/upload HTTP/1.1 -Host: notes.example.com -Content-Type: application/json -Content-Length: 8 - -not json -``` - -Response: - -```http -HTTP/1.1 400 Bad Request -Content-Type: application/json - -{ "error": "invalid request body: expected value at line 1 column 1" } -``` - ---- - -## 9. Conformance checklist - -A server is considered compliant when all of the following hold: - -1. `POST /worker/notes/upload` with a valid body returns `200` and - `success_count == entries.length` (in the absence of partial - failures). -2. After a successful upload, `GET /worker/notes/?commits=` returns - `200` with `notes[sha]` equal to the uploaded content, byte-for-byte. -3. `GET /worker/notes/?commits=` returns either `404` with - `{ "notes": {} }` or `200` with `{ "notes": {} }`. -4. A bulk `GET` containing a mix of known and unknown SHAs returns - `200` and includes only the known SHAs in the `notes` map. -5. Re-uploading the same `commit_sha` with new content overwrites the - prior value (last-writer-wins). -6. A request with malformed JSON or a missing required field returns - `400` with a JSON error body. -7. `POST /worker/notes/upload` and `GET /worker/notes/` both accept the - path with and without a trailing slash. -8. Unknown paths return `404`. - -A reference, in-memory implementation that satisfies this checklist is -maintained alongside the client in this repository for use as a local -test target. diff --git a/src/api/cas.rs b/src/api/cas.rs index ab33f22..e2cff35 100644 --- a/src/api/cas.rs +++ b/src/api/cas.rs @@ -1,76 +1,46 @@ +//! CAS (prompt-transcript) storage, written directly to the org's own Postgres. +//! +//! The connection URL comes from the `org_db_url` claim in the context's access +//! token (see [`crate::api::org_db`]); there is no intermediate backend. + use crate::api::client::ApiClient; -use crate::api::types::{ - ApiErrorResponse, CAPromptStoreReadResponse, CasUploadRequest, CasUploadResponse, -}; +use crate::api::org_db; +use crate::api::types::{CAPromptStoreReadResponse, CasUploadRequest, CasUploadResponse}; +use crate::config; use crate::error::AutterError; /// CAS API endpoints impl ApiClient { - /// Upload CAS objects to the server + /// Store CAS objects in the org's database (dedup by hash). /// /// # Arguments /// * `request` - The CAS upload request containing objects to upload /// /// # Returns - /// * `Ok(CasUploadResponse)` - Success response - /// * `Err(AutterError)` - Error response + /// * `Ok(CasUploadResponse)` - Per-object results plus counts + /// * `Err(AutterError)` - When not authenticated or the DB write fails pub fn upload_cas(&self, request: CasUploadRequest) -> Result { - let response = self.context().post_json("/worker/cas/upload", &request)?; - let status_code = response.status_code; - - let body = response - .as_str() - .map_err(|e| AutterError::Generic(format!("Failed to read response body: {}", e)))?; - - match status_code { - 200 => { - let cas_response: CasUploadResponse = - serde_json::from_str(body).map_err(AutterError::JsonError)?; - Ok(cas_response) - } - 400 => { - let error_response: ApiErrorResponse = - serde_json::from_str(body).unwrap_or_else(|_| ApiErrorResponse { - error: "Invalid request body".to_string(), - details: Some(serde_json::Value::String(body.to_string())), - }); - Err(AutterError::Generic(format!( - "Bad Request: {}", - error_response.error - ))) - } - 500 => { - let error_response: ApiErrorResponse = - serde_json::from_str(body).unwrap_or_else(|_| ApiErrorResponse { - error: "Internal server error".to_string(), - details: None, - }); - Err(AutterError::Generic(format!( - "Internal Server Error: {}", - error_response.error - ))) - } - _ => Err(AutterError::Generic(format!( - "Unexpected status code {}: {}", - status_code, body - ))), - } + let identity = self.org_identity()?; + org_db::upsert_cas( + &identity, + &request.objects, + &config::get_or_create_distinct_id(), + ) } - /// Read CAS objects by hash from the server + /// Read CAS objects by hash from the org's database. /// /// # Arguments - /// * `hashes` - Slice of CAS hashes to fetch (max 100 per call) + /// * `hashes` - Slice of CAS hashes to fetch /// /// # Returns /// * `Ok(CAPromptStoreReadResponse)` - Response with results for each hash - /// * `Err(AutterError)` - On network or server errors + /// * `Err(AutterError)` - On invalid input, auth, or DB errors pub fn read_ca_prompt_store( &self, hashes: &[&str], ) -> Result { - // Validate all hashes are hex-only before building the URL to prevent - // injection via crafted hash values in the query string. + // Validate all hashes are hex-only to guard against malformed input. for hash in hashes { if !hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(AutterError::Generic(format!( @@ -80,33 +50,7 @@ impl ApiClient { } } - let query = hashes.join(","); - let endpoint = format!("/worker/cas/?hashes={}", query); - let response = self.context().get(&endpoint)?; - let status_code = response.status_code; - - let body = response - .as_str() - .map_err(|e| AutterError::Generic(format!("Failed to read response body: {}", e)))?; - - match status_code { - 200 => { - let cas_response: CAPromptStoreReadResponse = - serde_json::from_str(body).map_err(AutterError::JsonError)?; - Ok(cas_response) - } - 404 => { - // All hashes not found — return empty response gracefully - Ok(CAPromptStoreReadResponse { - results: Vec::new(), - success_count: 0, - failure_count: hashes.len(), - }) - } - _ => Err(AutterError::Generic(format!( - "CAS read failed with status {}: {}", - status_code, body - ))), - } + let identity = self.org_identity()?; + org_db::read_cas(&identity, hashes) } } diff --git a/src/api/client.rs b/src/api/client.rs index aa4f0e1..0ca44bb 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -61,6 +61,75 @@ fn try_load_auth_token() -> Option { // Mutex guard is automatically released when _guard is dropped } +/// Prefix marking a Personal Access Token (mirrors the backend). +const PAT_PREFIX: &str = "autter_pat_"; + +/// In-process cache: repo remote URL → resolved owning org id (None = untracked). +static ORG_RESOLVE_CACHE: Lazy>>> = + Lazy::new(|| Mutex::new(std::collections::HashMap::new())); + +/// In-process cache: org id → (access token, unix expiry). +static ORG_TOKEN_CACHE: Lazy>> = + Lazy::new(|| Mutex::new(std::collections::HashMap::new())); + +/// Load the stored Personal Access Token (the refresh-token slot), if the user is +/// logged in with a PAT. Per-org routing only works with a PAT; device refresh +/// tokens return None so callers fall back to the single home-org token. +fn load_stored_pat() -> Option { + let creds = CredentialStore::new().load().ok().flatten()?; + if creds.is_refresh_token_expired() { + return None; + } + if !creds.refresh_token.starts_with(PAT_PREFIX) { + return None; + } + Some(creds.refresh_token) +} + +/// Resolve which org owns the repo at `repo_url`, cached in-process. Returns None +/// when no org tracks it, the user isn't logged in with a PAT, or resolution fails +/// — callers then fall back to the home org. +pub fn resolve_org_for_repo_cached(repo_url: &str) -> Option { + if let Ok(cache) = ORG_RESOLVE_CACHE.lock() + && let Some(hit) = cache.get(repo_url) + { + return hit.clone(); + } + let pat = load_stored_pat()?; + let resolved = OAuthClient::new() + .resolve_org_for_repo(&pat, repo_url) + .ok() + .flatten(); + if let Ok(mut cache) = ORG_RESOLVE_CACHE.lock() { + cache.insert(repo_url.to_string(), resolved.clone()); + } + resolved +} + +/// Get an access token scoped to `org_id`, minted from the stored PAT and cached +/// in-process until near expiry. Returns None if not logged in with a PAT or the +/// mint fails (caller should NOT fall back to another org — retry instead). +pub fn access_token_for_org(org_id: &str) -> Option { + let now = chrono::Utc::now().timestamp(); + if let Ok(cache) = ORG_TOKEN_CACHE.lock() + && let Some((token, exp)) = cache.get(org_id) + && *exp > now + 300 + { + return Some(token.clone()); + } + let pat = load_stored_pat()?; + let creds = OAuthClient::new() + .exchange_pat_for_org(&pat, Some(org_id)) + .ok()?; + if let Ok(mut cache) = ORG_TOKEN_CACHE.lock() { + cache.insert( + org_id.to_string(), + (creds.access_token.clone(), creds.access_token_expires_at), + ); + } + Some(creds.access_token) +} + /// Resolve the autter effective author identity without requiring a Repository instance. /// /// Uses the shared git identity helper to get the current user's identity, @@ -105,7 +174,7 @@ fn resolve_username() -> Option { None } -fn resolve_hostname() -> Option { +pub(crate) fn resolve_hostname() -> Option { #[cfg(windows)] if let Ok(h) = std::env::var("COMPUTERNAME") && !h.trim().is_empty() @@ -377,6 +446,16 @@ impl ApiClient { self.context.auth_token.is_some() } + /// Decode the org routing identity (`org_db_url` + uploader identity) from the + /// context's access token. Used by the notes/CAS data path, which writes + /// straight to the org's own database. Errors when not authenticated. + pub fn org_identity(&self) -> Result { + let token = self.context.auth_token.as_deref().ok_or_else(|| { + AutterError::Generic("not authenticated: no access token for org database".to_string()) + })?; + crate::api::org_db::identity_from_token(token) + } + /// Check if an API key is configured pub fn has_api_key(&self) -> bool { self.context.api_key.is_some() diff --git a/src/api/metrics.rs b/src/api/metrics.rs index aeab03c..f24ce84 100644 --- a/src/api/metrics.rs +++ b/src/api/metrics.rs @@ -1,7 +1,8 @@ //! Metrics API endpoints use crate::api::client::ApiClient; -use crate::api::types::ApiErrorResponse; +use crate::api::org_db; +use crate::config; use crate::error::AutterError; use crate::metrics::MetricsBatch; use crate::observability::log_error; @@ -100,59 +101,30 @@ pub fn upload_metrics_with_retry( /// Metrics API endpoints impl ApiClient { - /// Upload metrics batch to the server (max 1000 events) + /// Write a metrics batch directly to the org's database. + /// + /// The destination database comes from the `org_db_url` claim in the + /// context's access token (see [`crate::api::org_db`]); there is no + /// intermediate backend. /// /// # Arguments - /// * `batch` - The metrics batch to upload + /// * `batch` - The metrics batch to write /// /// # Returns - /// * `Ok(MetricsUploadResponse)` - Response with errors (empty = all success) - /// * `Err(AutterError)` - Request failed + /// * `Ok(MetricsUploadResponse)` - Response with per-event errors (empty = all success) + /// * `Err(AutterError)` - When not authenticated or the batch can't run pub fn upload_metrics( &self, batch: &MetricsBatch, ) -> Result { - let response = self.context().post_json("/worker/metrics/upload", batch)?; - let status_code = response.status_code; - - let body = response - .as_str() - .map_err(|e| AutterError::Generic(format!("Failed to read response body: {}", e)))?; - - match status_code { - 200 => { - let metrics_response: MetricsUploadResponse = - serde_json::from_str(body).map_err(AutterError::JsonError)?; - Ok(metrics_response) - } - 400 => { - let error_response: ApiErrorResponse = - serde_json::from_str(body).unwrap_or_else(|_| ApiErrorResponse { - error: "Invalid request body".to_string(), - details: Some(serde_json::Value::String(body.to_string())), - }); - Err(AutterError::Generic(format!( - "Bad Request: {}", - error_response.error - ))) - } - 401 => Err(AutterError::Generic("Unauthorized".to_string())), - 500 => { - let error_response: ApiErrorResponse = - serde_json::from_str(body).unwrap_or_else(|_| ApiErrorResponse { - error: "Internal server error".to_string(), - details: None, - }); - Err(AutterError::Generic(format!( - "Internal Server Error: {}", - error_response.error - ))) - } - _ => Err(AutterError::Generic(format!( - "Unexpected status code {}: {}", - status_code, body - ))), - } + let identity = self.org_identity()?; + let failed = + org_db::insert_metrics(&identity, &batch.events, &config::get_or_create_distinct_id())?; + let errors = failed + .into_iter() + .map(|(index, error)| MetricsUploadError { index, error }) + .collect(); + Ok(MetricsUploadResponse { errors }) } } diff --git a/src/api/mod.rs b/src/api/mod.rs index e8de28e..eccc79f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod cas; pub mod client; pub mod metrics; pub mod notes; +pub mod org_db; pub mod types; pub use client::{ApiClient, ApiContext}; diff --git a/src/api/notes.rs b/src/api/notes.rs index 30f5e63..0235dd8 100644 --- a/src/api/notes.rs +++ b/src/api/notes.rs @@ -1,65 +1,49 @@ -//! Notes API endpoints for the HTTP notes backend. +//! Authorship-note storage, written directly to the org's own Postgres. //! -//! Authentication is handled automatically by `ApiContext`: the existing -//! `X-API-Key` / Bearer token headers are attached on every request. -//! The daemon flusher should skip uploads when neither `is_logged_in()` nor -//! `has_api_key()` is true (matching the CAS pattern). +//! The connection URL comes from the `org_db_url` claim in the context's access +//! token (see [`crate::api::org_db`]); there is no intermediate backend. Callers +//! should still gate on `is_logged_in()` / `has_api_key()` so we only attempt a +//! write when the user is authenticated (matching the CAS pattern). use crate::api::client::ApiClient; -use crate::api::types::{ - ApiErrorResponse, NotesReadResponse, NotesUploadRequest, NotesUploadResponse, -}; +use crate::api::org_db; +use crate::api::types::{NotesReadResponse, NotesUploadRequest, NotesUploadResponse}; +use crate::config; use crate::error::AutterError; impl ApiClient { - /// Upload a batch of authorship notes to the remote backend. + /// Upload a batch of authorship notes to the org's database. /// /// # Arguments /// * `request` - The notes upload request containing entries to upload /// /// # Returns /// * `Ok(NotesUploadResponse)` - Success response with counts - /// * `Err(AutterError)` - On network or server errors + /// * `Err(AutterError)` - When not authenticated or the DB write fails pub fn upload_notes( &self, request: NotesUploadRequest, ) -> Result { - let response = self.context().post_json("/worker/notes/upload", &request)?; - let status_code = response.status_code; - - let body = response - .as_str() - .map_err(|e| AutterError::Generic(format!("Failed to read response body: {}", e)))?; - - match status_code { - 200 => serde_json::from_str(body).map_err(AutterError::JsonError), - 400 => { - let err: ApiErrorResponse = - serde_json::from_str(body).unwrap_or_else(|_| ApiErrorResponse { - error: "Invalid request body".to_string(), - details: Some(serde_json::Value::String(body.to_string())), - }); - Err(AutterError::Generic(format!("Bad Request: {}", err.error))) - } - _ => Err(AutterError::Generic(format!( - "Notes upload failed with status {}: {}", - status_code, body - ))), - } + let identity = self.org_identity()?; + org_db::upsert_notes( + &identity, + &request.entries, + &config::get_or_create_distinct_id(), + ) } - /// Read authorship notes by commit SHAs. Max 100 per call. + /// Read authorship notes by commit SHAs. /// - /// Returns an empty map for any SHAs not found (404 is treated as success). + /// Returns an empty map for any SHAs not found. /// /// # Arguments /// * `commit_shas` - Slice of hex commit SHAs to fetch /// /// # Returns /// * `Ok(NotesReadResponse)` - Response mapping commit_sha → note content - /// * `Err(AutterError)` - On invalid input, network, or server errors + /// * `Err(AutterError)` - On invalid input, auth, or DB errors pub fn read_notes(&self, commit_shas: &[&str]) -> Result { - // Validate that all SHAs are hex strings before making the request + // Validate that all SHAs are hex strings before querying. for sha in commit_shas { if !sha.chars().all(|c| c.is_ascii_hexdigit()) { return Err(AutterError::Generic(format!( @@ -69,25 +53,8 @@ impl ApiClient { } } - let query = commit_shas.join(","); - let endpoint = format!("/worker/notes/?commits={}", query); - let response = self.context().get(&endpoint)?; - let status_code = response.status_code; - - let body = response - .as_str() - .map_err(|e| AutterError::Generic(format!("Failed to read response body: {}", e)))?; - - match status_code { - 200 => serde_json::from_str(body).map_err(AutterError::JsonError), - 404 => Ok(NotesReadResponse { - notes: std::collections::HashMap::new(), - }), - _ => Err(AutterError::Generic(format!( - "Notes read failed with status {}: {}", - status_code, body - ))), - } + let identity = self.org_identity()?; + org_db::read_notes(&identity, commit_shas) } } diff --git a/src/api/org_db.rs b/src/api/org_db.rs new file mode 100644 index 0000000..a4a5130 --- /dev/null +++ b/src/api/org_db.rs @@ -0,0 +1,543 @@ +//! Direct writes to an organization's own PostgreSQL database. +//! +//! The autter CLI used to POST authorship notes and prompt-transcript (CAS) +//! objects to a hosted data-plane backend, which re-decoded the caller's JWT and +//! connected to the org database on the CLI's behalf. That backend was a pure +//! pass-through: the access token the CLI already holds carries the org's +//! database URL in its `org_db_url` claim. So we cut out the middle tier — the +//! CLI reads `org_db_url` straight from its token and writes to the org database +//! itself, using the machine's own resources. +//! +//! Identity (who uploaded what) comes from the same token: `sub` (user id), +//! `email`, and `token_id` (the PAT, when present). Because every database +//! belongs to exactly one org, rows are not org-scoped — the database boundary +//! IS the tenant boundary, exactly as the old backend's schema documented. +//! +//! Connections are cached per `org_db_url` for the life of the process (the +//! daemon is long-lived, so this avoids a TLS handshake on every flush). A query +//! that fails on a dropped connection transparently reconnects once. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine; +use once_cell::sync::Lazy; +use postgres::Client; +use sha2::{Digest, Sha256}; + +use crate::api::types::{ + CAPromptStoreReadResponse, CAPromptStoreReadResult, CasObject, CasUploadResponse, + CasUploadResult, NoteEntry, NotesReadResponse, NotesUploadResponse, +}; +use crate::error::AutterError; +use crate::metrics::MetricEvent; + +/// Identity + routing decoded from the caller's access-token JWT. +#[derive(Debug, Clone)] +pub struct OrgIdentity { + /// Postgres connection URL for the org's database (`org_db_url` claim). + pub org_db_url: String, + /// Better Auth user id (`sub`), recorded as `uploaded_by`. + pub user_id: Option, + /// User email, recorded in audit rows. + pub email: Option, + /// PAT id (`token_id`), recorded in audit rows when the session used a PAT. + pub token_id: Option, +} + +/// Decode the (unverified) payload of a JWT and pull out the org routing claims. +/// +/// The token is the CLI's own access token — it was minted and signed by +/// autter.dev and verified there at issue time, so we only need to *read* its +/// claims here, not re-verify the signature. +pub fn identity_from_token(token: &str) -> Result { + let payload = token.split('.').nth(1).ok_or_else(|| { + AutterError::Generic("access token is not a well-formed JWT".to_string()) + })?; + + // JWT uses base64url without padding; tolerate padding just in case. + let trimmed = payload.trim_end_matches('='); + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(trimmed) + .map_err(|e| AutterError::Generic(format!("failed to decode token payload: {e}")))?; + let claims: serde_json::Value = + serde_json::from_slice(&bytes).map_err(AutterError::JsonError)?; + + let org_db_url = claims + .get("org_db_url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| AutterError::Generic("token missing org_db_url claim".to_string()))? + .to_string(); + + let str_claim = |key: &str| { + claims + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + }; + + Ok(OrgIdentity { + org_db_url, + user_id: str_claim("sub"), + email: str_claim("email"), + token_id: str_claim("token_id"), + }) +} + +/// Process-wide cache: `org_db_url` → live Postgres client. +static CONNECTIONS: Lazy>>>> = + Lazy::new(|| Mutex::new(HashMap::new())); + +/// Per-org schema, created on first connect (idempotent). Mirrors the old +/// backend's `migrations/0001_init.sql` and `0002_cli_audit_log.sql` so a fresh +/// org database is fully provisioned by the CLI alone. autter.dev's +/// `bootstrap-org-tables` may also create these; `IF NOT EXISTS` keeps both safe. +const SCHEMA: &str = "\ +CREATE TABLE IF NOT EXISTS authorship_notes ( + commit_sha TEXT PRIMARY KEY, + content TEXT NOT NULL, + uploaded_by TEXT, + distinct_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS cas_objects ( + hash TEXT PRIMARY KEY, + content JSONB NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + uploaded_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE TABLE IF NOT EXISTS cli_audit_log ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + actor_id TEXT, + actor_email TEXT, + token_id TEXT, + token_name TEXT, + resource_id TEXT, + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS cli_audit_log_eventType_idx ON cli_audit_log (event_type); +CREATE INDEX IF NOT EXISTS cli_audit_log_tokenId_idx ON cli_audit_log (token_id); +CREATE INDEX IF NOT EXISTS cli_audit_log_createdAt_idx ON cli_audit_log (created_at); +CREATE TABLE IF NOT EXISTS cli_metrics ( + id BIGSERIAL PRIMARY KEY, + event_id INTEGER NOT NULL, + event_ts TIMESTAMPTZ NOT NULL, + event_values JSONB NOT NULL DEFAULT '{}'::jsonb, + event_attrs JSONB NOT NULL DEFAULT '{}'::jsonb, + uploaded_by TEXT, + distinct_id TEXT, + dedup_key TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS cli_metrics_eventId_idx ON cli_metrics (event_id); +CREATE INDEX IF NOT EXISTS cli_metrics_eventTs_idx ON cli_metrics (event_ts);"; + +/// Open a new TLS connection to `org_db_url` and ensure the schema exists. +fn connect(org_db_url: &str) -> Result { + let tls = native_tls::TlsConnector::new() + .map_err(|e| AutterError::Generic(format!("failed to build TLS connector: {e}")))?; + let connector = postgres_native_tls::MakeTlsConnector::new(tls); + let mut client = Client::connect(org_db_url, connector) + .map_err(|e| AutterError::Generic(format!("failed to connect to org database: {e}")))?; + client + .batch_execute(SCHEMA) + .map_err(|e| AutterError::Generic(format!("failed to ensure org schema: {e}")))?; + Ok(client) +} + +/// Get the cached client for `org_db_url`, connecting (and provisioning) lazily. +fn get_or_connect(org_db_url: &str) -> Result>, AutterError> { + { + let map = CONNECTIONS.lock().expect("connection cache poisoned"); + if let Some(client) = map.get(org_db_url) { + return Ok(client.clone()); + } + } + let client = Arc::new(Mutex::new(connect(org_db_url)?)); + let mut map = CONNECTIONS.lock().expect("connection cache poisoned"); + // Another thread may have connected while we were dialing — keep theirs. + Ok(map + .entry(org_db_url.to_string()) + .or_insert(client) + .clone()) +} + +/// Run a DB operation against the org's client. +/// +/// Before using a cached connection we validate it with a cheap round-trip and, +/// if it has been dropped (idle timeout, server restart, network blip), discard +/// it and dial a fresh one. We can't rely on the operation itself surfacing the +/// failure: the notes/CAS closures count per-row errors internally rather than +/// propagating them, so a dead socket would otherwise look like an all-rows +/// failure instead of triggering a reconnect. +fn run( + org_db_url: &str, + op: impl FnOnce(&mut Client) -> Result, +) -> Result { + let arc = get_or_connect(org_db_url)?; + { + let mut guard = arc.lock().expect("org client mutex poisoned"); + if guard.is_valid(Duration::from_secs(5)).is_err() { + // Cached connection is stale — drop it so the next get reconnects. + drop(guard); + CONNECTIONS + .lock() + .expect("connection cache poisoned") + .remove(org_db_url); + let fresh = get_or_connect(org_db_url)?; + let mut guard = fresh.lock().expect("org client mutex poisoned"); + return op(&mut guard).map_err(map_db_err); + } + op(&mut guard).map_err(map_db_err) + } +} + +fn map_db_err(e: postgres::Error) -> AutterError { + AutterError::Generic(format!("org database operation failed: {e}")) +} + +/// Best-effort `data.push` audit row. Never returns an error (mirrors the old +/// backend, where audit failures were logged but never failed the request). +fn record_push( + client: &mut Client, + identity: &OrgIdentity, + resource_id: Option<&str>, + detail: &serde_json::Value, +) { + let result = client.execute( + "INSERT INTO cli_audit_log + (id, event_type, actor_id, actor_email, token_id, resource_id, detail) + VALUES (gen_random_uuid()::text, 'data.push', $1, $2, $3, $4, $5)", + &[ + &identity.user_id, + &identity.email, + &identity.token_id, + &resource_id, + detail, + ], + ); + if let Err(e) = result { + tracing::warn!("cli audit write failed: {e}"); + } +} + +/// Upsert a batch of authorship notes, auditing each successful push. +pub fn upsert_notes( + identity: &OrgIdentity, + entries: &[NoteEntry], + distinct_id: &str, +) -> Result { + run(&identity.org_db_url, |client| { + let mut success_count = 0usize; + let mut failure_count = 0usize; + + for entry in entries { + if entry.commit_sha.trim().is_empty() { + failure_count += 1; + continue; + } + + let result = client.execute( + "INSERT INTO authorship_notes (commit_sha, content, uploaded_by, distinct_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (commit_sha) + DO UPDATE SET content = EXCLUDED.content, + uploaded_by = EXCLUDED.uploaded_by, + distinct_id = EXCLUDED.distinct_id, + updated_at = now()", + &[ + &entry.commit_sha, + &entry.content, + &identity.user_id, + &distinct_id, + ], + ); + + match result { + Ok(_) => { + success_count += 1; + record_push( + client, + identity, + Some(&entry.commit_sha), + &serde_json::json!({ "kind": "notes", "distinct_id": distinct_id }), + ); + } + Err(e) => { + tracing::warn!(commit = %entry.commit_sha, "note upsert failed: {e}"); + failure_count += 1; + } + } + } + + Ok(NotesUploadResponse { + success_count, + failure_count, + }) + }) +} + +/// Read authorship notes by commit SHA (`commit_sha` → content). +pub fn read_notes( + identity: &OrgIdentity, + commit_shas: &[&str], +) -> Result { + if commit_shas.is_empty() { + return Ok(NotesReadResponse { + notes: HashMap::new(), + }); + } + let owned: Vec = commit_shas.iter().map(|s| s.to_string()).collect(); + run(&identity.org_db_url, |client| { + let rows = client.query( + "SELECT commit_sha, content FROM authorship_notes WHERE commit_sha = ANY($1)", + &[&owned], + )?; + let notes = rows + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1))) + .collect::>(); + Ok(NotesReadResponse { notes }) + }) +} + +/// Store a batch of content-addressed prompt objects (dedup by hash). +pub fn upsert_cas( + identity: &OrgIdentity, + objects: &[CasObject], + distinct_id: &str, +) -> Result { + run(&identity.org_db_url, |client| { + let mut results = Vec::with_capacity(objects.len()); + let mut stored_hashes: Vec = Vec::new(); + let mut success_count = 0usize; + let mut failure_count = 0usize; + + for obj in objects { + let metadata = + serde_json::to_value(&obj.metadata).unwrap_or_else(|_| serde_json::json!({})); + + let result = client.execute( + "INSERT INTO cas_objects (hash, content, metadata, uploaded_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (hash) DO NOTHING", + &[&obj.hash, &obj.content, &metadata, &identity.user_id], + ); + + match result { + Ok(_) => { + success_count += 1; + stored_hashes.push(obj.hash.clone()); + results.push(CasUploadResult { + hash: obj.hash.clone(), + status: "ok".to_string(), + error: None, + }); + } + Err(e) => { + tracing::warn!(hash = %obj.hash, "cas upsert failed: {e}"); + failure_count += 1; + results.push(CasUploadResult { + hash: obj.hash.clone(), + status: "error".to_string(), + error: Some(e.to_string()), + }); + } + } + } + + // One audit row per batch (per-object would be far too chatty). + if !stored_hashes.is_empty() { + record_push( + client, + identity, + None, + &serde_json::json!({ + "kind": "cas", + "stored_count": stored_hashes.len(), + "failure_count": failure_count, + "hashes": stored_hashes, + "distinct_id": distinct_id, + }), + ); + } + + Ok(CasUploadResponse { + results, + success_count, + failure_count, + }) + }) +} + +/// Read CAS objects by hash, reporting a per-hash found/not-found status. +pub fn read_cas( + identity: &OrgIdentity, + hashes: &[&str], +) -> Result { + if hashes.is_empty() { + return Ok(CAPromptStoreReadResponse { + results: Vec::new(), + success_count: 0, + failure_count: 0, + }); + } + let owned: Vec = hashes.iter().map(|s| s.to_string()).collect(); + run(&identity.org_db_url, |client| { + let rows = client.query( + "SELECT hash, content FROM cas_objects WHERE hash = ANY($1)", + &[&owned], + )?; + let found: HashMap = rows + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, serde_json::Value>(1))) + .collect(); + + let mut results = Vec::with_capacity(owned.len()); + let mut success_count = 0usize; + let mut failure_count = 0usize; + for hash in &owned { + match found.get(hash) { + Some(content) => { + success_count += 1; + results.push(CAPromptStoreReadResult { + hash: hash.clone(), + status: "ok".to_string(), + content: Some(content.clone()), + error: None, + }); + } + None => { + failure_count += 1; + results.push(CAPromptStoreReadResult { + hash: hash.clone(), + status: "error".to_string(), + content: None, + error: Some("not found".to_string()), + }); + } + } + } + + Ok(CAPromptStoreReadResponse { + results, + success_count, + failure_count, + }) + }) +} + +/// Insert a batch of usage-metric events. Each event is stored as one row, with +/// its sparse `values`/`attrs` kept as JSONB. A content-hash `dedup_key` makes +/// the write idempotent, so re-flushing the local queue after a partial failure +/// can't create duplicates. +/// +/// Returns the `(index, error)` pairs for any individual rows that failed; the +/// call itself only errors if the whole batch can't run (e.g. connection lost). +pub fn insert_metrics( + identity: &OrgIdentity, + events: &[MetricEvent], + distinct_id: &str, +) -> Result, AutterError> { + run(&identity.org_db_url, |client| { + let mut errors = Vec::new(); + + for (index, event) in events.iter().enumerate() { + // Canonicalize so the dedup hash is stable regardless of map order. + let canonical = serde_json_canonicalizer::to_string(event) + .unwrap_or_else(|_| serde_json::to_string(event).unwrap_or_default()); + let mut hasher = Sha256::new(); + hasher.update(distinct_id.as_bytes()); + hasher.update([0u8]); + hasher.update(canonical.as_bytes()); + let dedup_key = format!("{:x}", hasher.finalize()); + + let values = serde_json::to_value(&event.values).unwrap_or_else(|_| serde_json::json!({})); + let attrs = serde_json::to_value(&event.attrs).unwrap_or_else(|_| serde_json::json!({})); + + let result = client.execute( + "INSERT INTO cli_metrics + (event_id, event_ts, event_values, event_attrs, uploaded_by, distinct_id, dedup_key) + VALUES ($1, to_timestamp($2), $3, $4, $5, $6, $7) + ON CONFLICT (dedup_key) DO NOTHING", + &[ + &(event.event_id as i32), + &(event.timestamp as f64), + &values, + &attrs, + &identity.user_id, + &distinct_id, + &dedup_key, + ], + ); + + if let Err(e) = result { + tracing::warn!(event_id = event.event_id, "metric insert failed: {e}"); + errors.push((index, e.to_string())); + } + } + + Ok(errors) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a JWT-shaped string (header.payload.signature) with the given JSON + /// payload, base64url-encoded without padding — just like a real token. + fn fake_jwt(payload: serde_json::Value) -> String { + let enc = |v: &serde_json::Value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string().as_bytes()) + }; + format!( + "{}.{}.{}", + enc(&serde_json::json!({"alg": "RS256", "typ": "JWT"})), + enc(&payload), + "sig" + ) + } + + #[test] + fn decodes_org_routing_claims() { + let token = fake_jwt(serde_json::json!({ + "sub": "user_123", + "email": "dev@example.com", + "org_db_url": "postgres://u:p@host/db", + "token_id": "pat_abc", + })); + let id = identity_from_token(&token).unwrap(); + assert_eq!(id.org_db_url, "postgres://u:p@host/db"); + assert_eq!(id.user_id.as_deref(), Some("user_123")); + assert_eq!(id.email.as_deref(), Some("dev@example.com")); + assert_eq!(id.token_id.as_deref(), Some("pat_abc")); + } + + #[test] + fn missing_org_db_url_is_error() { + let token = fake_jwt(serde_json::json!({ "sub": "user_123" })); + assert!(identity_from_token(&token).is_err()); + } + + #[test] + fn malformed_token_is_error() { + assert!(identity_from_token("not-a-jwt").is_err()); + } + + #[test] + fn optional_claims_default_to_none() { + let token = fake_jwt(serde_json::json!({ "org_db_url": "postgres://x/y" })); + let id = identity_from_token(&token).unwrap(); + assert!(id.user_id.is_none()); + assert!(id.email.is_none()); + assert!(id.token_id.is_none()); + } +} diff --git a/src/auth/client.rs b/src/auth/client.rs index 96e79a5..954cecb 100644 --- a/src/auth/client.rs +++ b/src/auth/client.rs @@ -97,9 +97,20 @@ impl OAuthClient { pub fn start_device_flow(&self) -> Result { let url = format!("{}/worker/oauth/device/code", self.base_url); + // Self-report device metadata so the approval screen and the persisted + // device record can show a meaningful "which device" (best-effort). + let device_name = + crate::api::client::resolve_hostname().unwrap_or_else(|| "autter CLI".to_string()); + let body = serde_json::json!({ + "client_id": "autter-cli", + "device_name": device_name, + "os": std::env::consts::OS, + "cli_version": env!("CARGO_PKG_VERSION"), + }); + let (_agent, request) = ApiContext::http_post(&url, Some(30)); let request = request.set("Content-Type", "application/json"); - let response = http::send_with_body(request, "{}") + let response = http::send_with_body(request, &body.to_string()) .map_err(|e| format!("Failed to connect to server: {}", e))?; if response.status_code != 200 { @@ -211,6 +222,71 @@ impl OAuthClient { .map_err(|e| format!("Token refresh failed: {}", e)) } + /// Exchange a Personal Access Token (PAT) for credentials. + /// + /// The PAT rides the refresh grant — the backend routes it by prefix and + /// returns it unchanged (PATs are stable, not rotated). Used by + /// `autter login --token ` to validate the token and seed credentials. + pub fn exchange_pat(&self, pat: &str) -> Result { + self.exchange_pat_for_org(pat, None) + } + + /// Exchange a PAT for credentials scoped to a specific org. + /// + /// `org_id = None` mints for the token's home org. A specific `org_id` mints + /// for that org (the backend verifies the PAT owner is a member) — this is how + /// the CLI routes a push to the org that owns the current repository. + pub fn exchange_pat_for_org( + &self, + pat: &str, + org_id: Option<&str>, + ) -> Result { + let mut body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": pat, + "client_id": "autter-cli" + }); + if let Some(org) = org_id { + body["org_id"] = serde_json::Value::String(org.to_string()); + } + + self.exchange_token(body) + .map_err(|e| format!("Token sign-in failed: {}", e)) + } + + /// Resolve which org owns a repository (by `owner/repo` or remote URL), so a + /// push can be routed to the right org. Returns `None` when no org tracks it + /// (the caller should fall back to the PAT's home org). + pub fn resolve_org_for_repo( + &self, + pat: &str, + repo: &str, + ) -> Result, String> { + let url = format!("{}/worker/oauth/resolve-org", self.base_url); + let body = serde_json::json!({ "pat": pat, "repo": repo }); + + let (_agent, request) = ApiContext::http_post(&url, Some(30)); + let request = request.set("Content-Type", "application/json"); + let response = http::send_with_body(request, &body.to_string()) + .map_err(|e| format!("Failed to connect to server: {}", e))?; + + if response.status_code != 200 { + return Err(format!("resolve-org failed ({})", response.status_code)); + } + + let body = response + .as_str() + .map_err(|e| format!("Invalid response encoding: {}", e))?; + + #[derive(serde::Deserialize)] + struct ResolveOrgResponse { + org_id: Option, + } + let parsed: ResolveOrgResponse = serde_json::from_str(body) + .map_err(|e| format!("Invalid resolve-org response: {}", e))?; + Ok(parsed.org_id) + } + /// Exchange an install nonce for credentials (auto-login from web install page) pub fn exchange_install_nonce(&self, nonce: &str) -> Result { let body = serde_json::json!({ diff --git a/src/auth/identity.rs b/src/auth/identity.rs index 77d3d93..a495560 100644 --- a/src/auth/identity.rs +++ b/src/auth/identity.rs @@ -6,9 +6,27 @@ pub struct TokenIdentity { pub email: Option, pub name: Option, pub personal_org_id: Option, + /// The org this token is scoped to (the `org_id` claim). + pub active_org_id: Option, pub orgs: Vec, } +impl TokenIdentity { + /// The org entry this token is scoped to, matched from `orgs` by the active + /// `org_id`. Falls back to the sole org when there's exactly one. + pub fn active_org(&self) -> Option<&TokenOrg> { + if let Some(id) = self.active_org_id.as_deref() { + if let Some(org) = self.orgs.iter().find(|o| o.org_id.as_deref() == Some(id)) { + return Some(org); + } + } + if self.orgs.len() == 1 { + return self.orgs.first(); + } + None + } +} + #[derive(Default, Debug, Clone, PartialEq, Eq, Deserialize)] pub struct TokenOrg { pub org_id: Option, @@ -22,6 +40,7 @@ struct AccessTokenClaims { pub sub: Option, pub email: Option, pub name: Option, + pub org_id: Option, #[serde(default)] pub orgs: Vec, pub personal_org_id: Option, @@ -51,6 +70,7 @@ pub fn extract_identity_from_access_token(access_token: &str) -> TokenIdentity { email: claims.email, name: claims.name, personal_org_id: claims.personal_org_id, + active_org_id: claims.org_id, orgs: claims.orgs, } } diff --git a/src/commands/autter_handlers.rs b/src/commands/autter_handlers.rs index e68484a..1e97038 100644 --- a/src/commands/autter_handlers.rs +++ b/src/commands/autter_handlers.rs @@ -237,19 +237,13 @@ fn handle_notes_subcommand(args: &[String]) { "migrate" => { commands::notes_migrate::handle_notes_migrate(&args[1..]); } - // Hidden: in-memory reference implementation of the notes backend HTTP - // contract. Intentionally not advertised in `--help`; it is for - // developers, tests, and benchmarks, not end users. - "serve" => { - handle_notes_serve(&args[1..]); - } "--help" | "-h" | "help" => { eprintln!("autter notes - Notes backend management commands"); eprintln!(); eprintln!("Usage: autter notes [options]"); eprintln!(); eprintln!("Subcommands:"); - eprintln!(" migrate Bulk-upload existing git notes to the HTTP backend"); + eprintln!(" migrate Bulk-upload existing git notes to your org's database"); eprintln!(); eprintln!("Run 'autter notes --help' for details."); } @@ -261,50 +255,6 @@ fn handle_notes_subcommand(args: &[String]) { } } -/// `autter notes serve` — run the in-memory reference notes backend. -/// -/// This is a developer/test tool. The server stores everything in process -/// memory and accepts any auth header. See -/// `crate::notes::reference_server` for the wire contract. -fn handle_notes_serve(args: &[String]) { - let mut bind: String = "127.0.0.1:0".to_string(); - let mut i = 0; - while i < args.len() { - match args[i].as_str() { - "--bind" if i + 1 < args.len() => { - bind = args[i + 1].clone(); - i += 2; - } - "--port" if i + 1 < args.len() => { - bind = format!("127.0.0.1:{}", args[i + 1]); - i += 2; - } - "--help" | "-h" => { - eprintln!( - "autter notes serve - Run the in-memory notes backend reference server\n\ - \n\ - Usage: autter notes serve [--bind ] [--port ]\n\ - \n\ - This is a reference implementation. All notes are stored in process\n\ - memory; auth headers are accepted but not validated. It exists to\n\ - document the HTTP wire contract and to enable local testing of the\n\ - `notes_backend.kind = http` code path without a real backend." - ); - return; - } - other => { - eprintln!("Unknown argument to `autter notes serve`: {}", other); - std::process::exit(1); - } - } - } - - if let Err(e) = crate::notes::reference_server::run_blocking(&bind) { - eprintln!("notes reference server failed: {}", e); - std::process::exit(1); - } -} - fn print_help() { eprintln!("autter - git proxy with AI authorship tracking"); eprintln!(); @@ -374,7 +324,8 @@ fn print_help() { eprintln!(" --connect Connect to the Autter platform (runs login)"); eprintln!(" --local Use local-only mode (no uploads)"); eprintln!(" --force Re-run onboarding even if already completed"); - eprintln!(" login Authenticate with Autter"); + eprintln!(" login Open the dashboard to create a sign-in token"); + eprintln!(" --token Complete sign-in with a token from the dashboard"); eprintln!(" logout Clear stored credentials"); eprintln!(" whoami Show auth state and login identity"); eprintln!(" version, -v, --version Print the autter version"); diff --git a/src/commands/flush_metrics_db.rs b/src/commands/flush_metrics_db.rs index 13fad65..03a471c 100644 --- a/src/commands/flush_metrics_db.rs +++ b/src/commands/flush_metrics_db.rs @@ -11,14 +11,13 @@ const MAX_BATCH_SIZE: usize = 1000; /// Handle the flush-metrics-db command pub fn handle_flush_metrics_db(_args: &[String]) { - // Check conditions: (!using_default_api) || is_logged_in() || has_api_key() + // Metrics are written to the org database via the access token's + // `org_db_url` claim, so a write requires being logged in. let context = ApiContext::new(None); - let api_base_url = context.base_url.clone(); let client = ApiClient::new(context); - let using_default_api = api_base_url == crate::config::DEFAULT_API_BASE_URL; - if using_default_api && !client.is_logged_in() && !client.has_api_key() { - eprintln!("flush-metrics-db: skipping (not logged in and using default API)"); + if !client.is_logged_in() { + eprintln!("flush-metrics-db: skipping (not logged in)"); return; } diff --git a/src/commands/login.rs b/src/commands/login.rs index 36f29fe..337ffd9 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -73,20 +73,157 @@ pub fn run_device_login() -> Result { Ok(LoginOutcome::LoggedIn) } -/// Handle the `autter login` command -pub fn handle_login(_args: &[String]) { - match run_device_login() { - Ok(LoginOutcome::AlreadyLoggedIn) => { - eprintln!("Already logged in. Use 'autter logout' to log out first."); - std::process::exit(0); +/// Sign in with a Personal Access Token instead of the interactive device flow. +/// +/// Validates the token by exchanging it for an access token up-front, so a bad +/// token fails immediately rather than being stored and failing later. +pub fn run_pat_login(token: &str) -> Result { + let token = token.trim(); + if token.is_empty() { + return Err("No token provided. Usage: autter login --token ".to_string()); + } + + let creds = OAuthClient::new().exchange_pat(token)?; + + let store = CredentialStore::new(); + store + .store(&creds) + .map_err(|e| format!("Failed to store credentials: {}", e))?; + + print_login_success(&creds.access_token); + Ok(LoginOutcome::LoggedIn) +} + +/// Print "Successfully logged in!" plus the signed-in user and active org, read +/// from the access token's claims (best-effort — falls back gracefully). +fn print_login_success(access_token: &str) { + use crate::auth::identity::extract_identity_from_access_token; + + eprintln!("Successfully logged in!"); + let identity = extract_identity_from_access_token(access_token); + + if let Some(name) = identity.name.as_deref().filter(|s| !s.is_empty()) { + match identity.email.as_deref().filter(|s| !s.is_empty()) { + Some(email) => eprintln!(" Signed in as {} ({})", name, email), + None => eprintln!(" Signed in as {}", name), } - Ok(LoginOutcome::LoggedIn) => { - eprintln!("\nSuccessfully logged in!"); + } else if let Some(email) = identity.email.as_deref().filter(|s| !s.is_empty()) { + eprintln!(" Signed in as {}", email); + } + + if let Some(org) = identity.active_org() { + if let Some(org_name) = org.org_name.as_deref().filter(|s| !s.is_empty()) { + match org.org_slug.as_deref().filter(|s| !s.is_empty()) { + Some(slug) => eprintln!(" Organization: {} ({})", org_name, slug), + None => eprintln!(" Organization: {}", org_name), + } + } + } +} + +/// Extract a `--token ` or `--token=` argument, if present. +fn parse_token_arg(args: &[String]) -> Option { + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + if let Some(rest) = arg.strip_prefix("--token=") { + return Some(rest.to_string()); + } + if arg == "--token" { + return args.get(i + 1).cloned(); } - Err(e) => { - eprintln!("\n{}", e); + i += 1; + } + None +} + +/// The Autter web dashboard, where the user creates a Personal Access Token. +const DEFAULT_WEB_APP_URL: &str = "https://app.autter.dev"; + +/// Resolve the web dashboard URL. Precedence: +/// 1. `AUTTER_WEB_URL` env (explicit override, e.g. a local Vite dev server) +/// 2. derived from the configured `api_base_url` (swap the `api` host label) +/// 3. the default `https://app.autter.dev` +fn web_app_url() -> String { + if let Ok(url) = std::env::var("AUTTER_WEB_URL") + && !url.trim().is_empty() + { + return url; + } + if let Some(web) = derive_web_url_from_api(crate::config::Config::get().api_base_url()) { + return web; + } + DEFAULT_WEB_APP_URL.to_string() +} + +/// Derive the web app URL from the API base URL by swapping the leading `api` +/// host label for `app`, e.g. `https://test-api.autter.dev` -> +/// `https://test-app.autter.dev`, `https://api.autter.dev` -> `https://app.autter.dev`. +/// Returns `None` when there is no `api` label to swap. +fn derive_web_url_from_api(api_base_url: &str) -> Option { + let (scheme, rest) = api_base_url.split_once("://")?; + let (host, tail) = match rest.split_once('/') { + Some((h, t)) => (h, Some(t)), + None => (rest, None), + }; + let (first, remainder) = host.split_once('.')?; + let new_first = if first == "api" { + "app".to_string() + } else if let Some(prefix) = first.strip_suffix("-api") { + format!("{prefix}-app") + } else { + return None; + }; + let new_host = format!("{new_first}.{remainder}"); + Some(match tail { + Some(t) => format!("{scheme}://{new_host}/{t}"), + None => format!("{scheme}://{new_host}"), + }) +} + +/// Print the step-by-step browser sign-in instructions. +fn print_login_instructions(url: &str) { + eprintln!("To sign in to Autter:\n"); + eprintln!(" 1. We've opened the Autter dashboard in your browser:"); + eprintln!(" {}", url); + eprintln!(" (If it didn't open, copy that link into your browser.)\n"); + eprintln!(" 2. Log in, then open any organization's"); + eprintln!(" Settings -> Access Tokens\n"); + eprintln!(" 3. Click \"Create token\", give it a name, and copy the token.\n"); + eprintln!(" 4. Come back here and run:"); + eprintln!(" autter login --token \n"); +} + +/// Handle the `autter login` command. +/// +/// Two-step, browser-assisted Personal Access Token flow: +/// 1. `autter login` opens the dashboard so the user can create + copy a token. +/// 2. `autter login --token ` completes sign-in with that token. +pub fn handle_login(args: &[String]) { + // Step 2: complete sign-in with a token created in the browser. + if let Some(token) = parse_token_arg(args) { + // run_pat_login prints the success message + identity on success. + if let Err(e) = run_pat_login(&token) { + eprintln!("{}", e); std::process::exit(1); } + return; + } + + // Already signed in? Nothing to do. + let store = CredentialStore::new(); + if let Ok(Some(creds)) = store.load() + && !creds.is_refresh_token_expired() + { + eprintln!("Already logged in. Use 'autter logout' to log out first."); + return; + } + + // Step 1: open the dashboard and tell the user what to do next. + let url = web_app_url(); + print_login_instructions(&url); + if open_browser(&url).is_err() { + eprintln!(" (Could not open the browser automatically — open the link above.)"); } } diff --git a/src/commands/notes_migrate.rs b/src/commands/notes_migrate.rs index 796e751..5ffae67 100644 --- a/src/commands/notes_migrate.rs +++ b/src/commands/notes_migrate.rs @@ -1,4 +1,4 @@ -//! `autter notes migrate` — bulk-upload existing git notes to the HTTP backend. +//! `autter notes migrate` — bulk-upload existing git notes to the org's database. //! //! This command reads all notes stored in `refs/notes/ai` via `git notes --ref=ai list`, //! fetches their content using `git cat-file --batch`, uploads them to the remote HTTP @@ -45,7 +45,7 @@ pub fn handle_notes_migrate(args: &[String]) { "error: `autter notes migrate` requires notes_backend.kind = http.\n\ Current backend: {}\n\ \n\ - To enable the HTTP backend, run:\n\ + To enable cloud sync, run:\n\ \n\ \x20 autter config set notes_backend.kind http", cfg.notes_backend_kind() @@ -396,7 +396,7 @@ fn cat_file_batch( } fn print_help() { - eprintln!("autter notes migrate - Bulk-upload existing git notes to the HTTP backend"); + eprintln!("autter notes migrate - Bulk-upload existing git notes to your org's database"); eprintln!(); eprintln!("Usage: autter notes migrate [options]"); eprintln!(); diff --git a/src/commands/onboard.rs b/src/commands/onboard.rs index 63df4d4..4c8d9cb 100644 --- a/src/commands/onboard.rs +++ b/src/commands/onboard.rs @@ -114,10 +114,12 @@ fn setup_connected(cfg: &mut config::FileConfig, already_logged_in: bool) { } } - // Upload prompt/usage data to the platform while keeping local git notes. + // Connected mode: upload prompts (CAS) and authorship notes to the hosted + // data plane. `backend_url: None` resolves to DEFAULT_NOTES_BACKEND_URL + // (cli.autter.dev) via Config::notes_backend_url(). cfg.prompt_storage = Some("default".to_string()); cfg.notes_backend = Some(NotesBackendConfig { - kind: NotesBackendKind::GitNotes, + kind: NotesBackendKind::Http, backend_url: None, }); diff --git a/src/config.rs b/src/config.rs index 7058d2e..55cf74c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,7 +17,11 @@ use crate::mdm::utils::home_dir; use std::sync::RwLock; /// Default API base URL for comparison -pub const DEFAULT_API_BASE_URL: &str = "https://autter.dev"; +pub const DEFAULT_API_BASE_URL: &str = "https://api.autter.dev"; + +/// Default data-plane (notes/CAS) endpoint used when the HTTP notes backend is +/// enabled but no explicit `backend_url` is configured. +pub const DEFAULT_NOTES_BACKEND_URL: &str = "https://cli.autter.dev"; /// Which backend to use for storing authorship notes. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -615,12 +619,19 @@ impl Config { self.notes_backend.kind } - /// Returns the configured notes backend URL, or `None` if unset. + /// Returns the notes backend URL. /// - /// Callers must handle `None` explicitly — typically by skipping the operation when the HTTP backend - /// is enabled but no URL has been configured. + /// Precedence: an explicit `backend_url` wins; otherwise, when the HTTP backend + /// is active, falls back to [`DEFAULT_NOTES_BACKEND_URL`] (the hosted data plane). + /// Returns `None` only when the HTTP backend is not enabled and no URL is set. pub fn notes_backend_url(&self) -> Option<&str> { - self.notes_backend.backend_url.as_deref() + if let Some(url) = self.notes_backend.backend_url.as_deref() { + return Some(url); + } + if self.notes_backend.kind == NotesBackendKind::Http { + return Some(DEFAULT_NOTES_BACKEND_URL); + } + None } /// Returns true when the HTTP notes backend is active. diff --git a/src/daemon/telemetry_worker.rs b/src/daemon/telemetry_worker.rs index 68ab04c..4556b15 100644 --- a/src/daemon/telemetry_worker.rs +++ b/src/daemon/telemetry_worker.rs @@ -317,11 +317,12 @@ fn flush_telemetry_batch(batch: TelemetryBuffer) { fn flush_metrics(events: &[MetricEvent]) { let context = ApiContext::new(None); - let api_base_url = context.base_url.clone(); let client = ApiClient::new(context); - let using_default_api = api_base_url == crate::config::DEFAULT_API_BASE_URL; - let should_upload = !using_default_api || client.is_logged_in() || client.has_api_key(); + // Metrics are written straight to the org database, which we reach via the + // `org_db_url` claim in the access token — so a write is only possible when + // logged in. Otherwise the events fall back to the local SQLite queue. + let should_upload = client.is_logged_in(); let mut upload_failed = false; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); @@ -567,7 +568,7 @@ pub fn flush_notes() { return; } }; - let context = ApiContext::new(Some(backend_url)); + let context = ApiContext::new(Some(backend_url.clone())); let client = ApiClient::new(context); if !client.is_logged_in() && !client.has_api_key() { @@ -600,51 +601,87 @@ pub fn flush_notes() { return; } - let commit_shas: Vec = pending.iter().map(|p| p.commit_sha.clone()).collect(); - - let entries: Vec = pending - .iter() - .map(|p| NoteEntry { - commit_sha: p.commit_sha.clone(), - content: p.content.clone(), - }) - .collect(); - - let request = NotesUploadRequest { entries }; + // Route each note to the org that owns its repo. Notes whose repo isn't known + // or isn't tracked by any org go to the home org (org = None → default token). + let mut groups: std::collections::HashMap, Vec<(String, String)>> = + std::collections::HashMap::new(); + for note in &pending { + let org = note + .repo_url + .as_deref() + .and_then(crate::api::client::resolve_org_for_repo_cached); + groups + .entry(org) + .or_default() + .push((note.commit_sha.clone(), note.content.clone())); + } + + for (org_opt, batch) in groups { + let commit_shas: Vec = batch.iter().map(|(sha, _)| sha.clone()).collect(); + let entries: Vec = batch + .iter() + .map(|(sha, content)| NoteEntry { + commit_sha: sha.clone(), + content: content.clone(), + }) + .collect(); + + // Pick the client for this org. A resolved org mints an org-scoped token; + // if minting fails we defer (mark failed → retry) rather than misroute. + let group_client = match &org_opt { + Some(org) => match crate::api::client::access_token_for_org(org) { + Some(token) => { + ApiClient::new(ApiContext::with_auth(Some(backend_url.clone()), token)) + } + None => { + if let Ok(db) = crate::notes::db::NotesDatabase::global() + && let Ok(mut lock) = db.lock() + { + let _ = lock + .mark_failed(&commit_shas, "could not mint org-scoped token"); + } + continue; + } + }, + None => ApiClient::new(ApiContext::new(Some(backend_url.clone()))), + }; - match client.upload_notes(request) { - Ok(resp) => { - tracing::debug!( - success = resp.success_count, - failure = resp.failure_count, - "notes: uploaded batch" - ); - if let Ok(db) = crate::notes::db::NotesDatabase::global() - && let Ok(mut lock) = db.lock() - { - if resp.failure_count == 0 { - let _ = lock.mark_synced(&commit_shas); - } else { - // Server reported partial failures but doesn't identify which - // entries failed. Mark the entire batch as failed so all entries - // are retried on the next flush cycle. - let _ = lock.mark_failed( - &commit_shas, - &format!( - "partial failure: {}/{} entries failed", - resp.failure_count, - commit_shas.len() - ), - ); + let request = NotesUploadRequest { entries }; + match group_client.upload_notes(request) { + Ok(resp) => { + tracing::debug!( + success = resp.success_count, + failure = resp.failure_count, + org = org_opt.as_deref().unwrap_or("home"), + "notes: uploaded batch" + ); + if let Ok(db) = crate::notes::db::NotesDatabase::global() + && let Ok(mut lock) = db.lock() + { + if resp.failure_count == 0 { + let _ = lock.mark_synced(&commit_shas); + } else { + // Server reported partial failures but doesn't identify which + // entries failed. Mark the whole group failed so all entries + // retry on the next flush cycle. + let _ = lock.mark_failed( + &commit_shas, + &format!( + "partial failure: {}/{} entries failed", + resp.failure_count, + commit_shas.len() + ), + ); + } } } - } - Err(e) => { - tracing::warn!(%e, "notes: upload error"); - if let Ok(db) = crate::notes::db::NotesDatabase::global() - && let Ok(mut lock) = db.lock() - { - let _ = lock.mark_failed(&commit_shas, &e.to_string()); + Err(e) => { + tracing::warn!(%e, "notes: upload error"); + if let Ok(db) = crate::notes::db::NotesDatabase::global() + && let Ok(mut lock) = db.lock() + { + let _ = lock.mark_failed(&commit_shas, &e.to_string()); + } } } } @@ -663,12 +700,23 @@ pub fn flush_notes() { } fn flush_cas(records: Vec) { - let context = ApiContext::new(None); - let api_base_url = context.base_url.clone(); + // CAS (prompt transcripts) is data-plane traffic. When the HTTP notes backend + // is active, send it to the same hosted data plane as notes (cli.autter.dev); + // otherwise fall back to the API base URL (legacy behavior). + let cfg = Config::fresh(); + let dataplane_url = + if cfg.notes_backend_kind() == crate::config::NotesBackendKind::Http { + cfg.notes_backend_url().map(|s| s.to_string()) + } else { + None + }; + let context = ApiContext::new(dataplane_url); + let target_url = context.base_url.clone(); let client = ApiClient::new(context); - let using_default_api = api_base_url == crate::config::DEFAULT_API_BASE_URL; - if using_default_api && !client.is_logged_in() && !client.has_api_key() { + let using_hosted = target_url == crate::config::DEFAULT_API_BASE_URL + || target_url == crate::config::DEFAULT_NOTES_BACKEND_URL; + if using_hosted && !client.is_logged_in() && !client.has_api_key() { tracing::debug!("telemetry: skipping CAS flush, not logged in"); return; } diff --git a/src/git/notes_api.rs b/src/git/notes_api.rs index 5c5d6dd..c32d122 100644 --- a/src/git/notes_api.rs +++ b/src/git/notes_api.rs @@ -20,7 +20,10 @@ pub use crate::git::refs::CommitAuthorship; pub fn write_note(repo: &Repository, commit_sha: &str, content: &str) -> Result<(), AutterError> { match Config::get().notes_backend_kind() { - NotesBackendKind::Http => http_write_note(commit_sha, content), + NotesBackendKind::Http => { + let repo_url = crate::repo_url::resolve_repo_url_from_repo(repo); + http_write_note(commit_sha, content, repo_url.as_deref()) + } NotesBackendKind::GitNotes => crate::git::refs::notes_add(repo, commit_sha, content), } } @@ -33,7 +36,10 @@ pub fn write_notes_batch( return Ok(()); } match Config::get().notes_backend_kind() { - NotesBackendKind::Http => http_write_batch(entries), + NotesBackendKind::Http => { + let repo_url = crate::repo_url::resolve_repo_url_from_repo(repo); + http_write_batch(entries, repo_url.as_deref()) + } NotesBackendKind::GitNotes => crate::git::refs::notes_add_batch(repo, entries), } } @@ -506,23 +512,30 @@ pub fn warm_cache_for_remote(repo: &Repository, remote: &str) -> Result<(), Autt // --- HTTP backend helpers (private) --- -fn http_write_note(commit_sha: &str, content: &str) -> Result<(), AutterError> { +fn http_write_note( + commit_sha: &str, + content: &str, + repo_url: Option<&str>, +) -> Result<(), AutterError> { let db = crate::notes::db::NotesDatabase::global()?; let mut db_lock = db .lock() .map_err(|e| AutterError::Generic(format!("notes-db lock: {}", e)))?; - db_lock.upsert_note(commit_sha, content)?; + db_lock.upsert_note_with_repo(commit_sha, content, repo_url)?; drop(db_lock); crate::daemon::telemetry_handle::submit_notes(); Ok(()) } -fn http_write_batch(entries: &[(String, String)]) -> Result<(), AutterError> { +fn http_write_batch( + entries: &[(String, String)], + repo_url: Option<&str>, +) -> Result<(), AutterError> { let db = crate::notes::db::NotesDatabase::global()?; let mut db_lock = db .lock() .map_err(|e| AutterError::Generic(format!("notes-db lock: {}", e)))?; - db_lock.upsert_notes_batch(entries)?; + db_lock.upsert_notes_batch_with_repo(entries, repo_url)?; drop(db_lock); crate::daemon::telemetry_handle::submit_notes(); Ok(()) @@ -613,7 +626,7 @@ mod tests { } // Write directly via http helper (no repo needed). - http_write_note("abc123def456abc123def456abc123def456abc1", "test content").expect("write"); + http_write_note("abc123def456abc123def456abc123def456abc1", "test content", None).expect("write"); // Read back from cache. let content = http_read_note("abc123def456abc123def456abc123def456abc1"); @@ -653,8 +666,8 @@ mod tests { let sha2 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); let sha3 = "cccccccccccccccccccccccccccccccccccccccc".to_string(); - http_write_note(&sha1, "content-a").expect("write sha1"); - http_write_note(&sha2, "content-b").expect("write sha2"); + http_write_note(&sha1, "content-a", None).expect("write sha1"); + http_write_note(&sha2, "content-b", None).expect("write sha2"); // sha3 is not written — should not appear in result. let result = http_read_notes(&[sha1.clone(), sha2.clone(), sha3.clone()]); @@ -739,7 +752,7 @@ mod tests { let sha = repo.commit_all("msg").expect("commit"); // Write a note for this SHA using the Http helper. - http_write_note(&sha, "some-note-content").expect("http write"); + http_write_note(&sha, "some-note-content", None).expect("http write"); // Confirm it is in notes-db with synced=0. let db = crate::notes::db::NotesDatabase::global().expect("global db"); @@ -797,7 +810,7 @@ mod tests { let sha = repo.commit_all("test commit").expect("commit"); // Put a note in the cache for this commit. - http_write_note(&sha, "display-note-content").expect("write note"); + http_write_note(&sha, "display-note-content", None).expect("write note"); // Materialize the cache into refs/notes/ai-display. let count = materialize_notes_for_display(repo.autter_repo(), 50).expect("materialize"); diff --git a/src/mdm/agents/cursor.rs b/src/mdm/agents/cursor.rs index 8436b36..1fd28cd 100644 --- a/src/mdm/agents/cursor.rs +++ b/src/mdm/agents/cursor.rs @@ -3,8 +3,9 @@ use crate::mdm::hook_installer::{ HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, }; use crate::mdm::utils::{ - MIN_CURSOR_VERSION, generate_diff, get_editor_version, home_dir, install_vsc_editor_extension, - is_vsc_editor_extension_installed, parse_version, resolve_editor_cli, + MIN_CURSOR_VERSION, generate_diff, get_editor_version, home_dir, + install_vsc_editor_extension_with_vsix_fallback, is_vsc_editor_extension_installed, + parse_version, resolve_editor_cli, settings_paths_for_products, should_process_settings_target, version_meets_requirement, write_atomic, }; @@ -346,7 +347,10 @@ impl HookInstaller for CursorInstaller { } else { println!("Installing extensions..."); println!("\tInstalling extension 'autter.autter-vscode'..."); - match install_vsc_editor_extension(&cli, "autter.autter-vscode") { + match install_vsc_editor_extension_with_vsix_fallback( + &cli, + "autter.autter-vscode", + ) { Ok(()) => { results.push(InstallResult { changed: true, diff --git a/src/mdm/agents/vscode.rs b/src/mdm/agents/vscode.rs index 5f496f9..ad4d2b5 100644 --- a/src/mdm/agents/vscode.rs +++ b/src/mdm/agents/vscode.rs @@ -3,7 +3,7 @@ use crate::mdm::hook_installer::{ HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, UninstallResult, }; use crate::mdm::utils::{ - MIN_CODE_VERSION, get_editor_version, home_dir, install_vsc_editor_extension, + MIN_CODE_VERSION, get_editor_version, home_dir, install_vsc_editor_extension_with_vsix_fallback, is_github_codespaces, is_vsc_editor_extension_installed, parse_version, resolve_editor_cli, settings_paths_for_products, should_process_settings_target, update_vscode_chat_hook_settings, version_meets_requirement, @@ -143,7 +143,10 @@ impl HookInstaller for VSCodeInstaller { message: "VS Code: Pending extension install".to_string(), }); } else { - match install_vsc_editor_extension(&cli, "autter.autter-vscode") { + match install_vsc_editor_extension_with_vsix_fallback( + &cli, + "autter.autter-vscode", + ) { Ok(()) => { results.push(InstallResult { changed: true, diff --git a/src/mdm/agents/windsurf.rs b/src/mdm/agents/windsurf.rs index 5bd59cf..be627ed 100644 --- a/src/mdm/agents/windsurf.rs +++ b/src/mdm/agents/windsurf.rs @@ -3,8 +3,9 @@ use crate::mdm::hook_installer::{ HookCheckResult, HookInstaller, HookInstallerParams, InstallResult, UninstallResult, }; use crate::mdm::utils::{ - generate_diff, home_dir, install_vsc_editor_extension, is_autter_checkpoint_command, - is_github_codespaces, is_vsc_editor_extension_installed, resolve_editor_cli, write_atomic, + generate_diff, home_dir, install_vsc_editor_extension_with_vsix_fallback, + is_autter_checkpoint_command, is_github_codespaces, is_vsc_editor_extension_installed, + resolve_editor_cli, write_atomic, }; use serde_json::{Value, json}; @@ -348,7 +349,10 @@ impl HookInstaller for WindsurfInstaller { } else { println!("Installing extensions..."); println!("\tInstalling extension 'autter.autter-vscode'..."); - match install_vsc_editor_extension(&cli, "autter.autter-vscode") { + match install_vsc_editor_extension_with_vsix_fallback( + &cli, + "autter.autter-vscode", + ) { Ok(()) => { results.push(InstallResult { changed: true, diff --git a/src/mdm/utils.rs b/src/mdm/utils.rs index 857dd65..b2d7a76 100644 --- a/src/mdm/utils.rs +++ b/src/mdm/utils.rs @@ -651,6 +651,100 @@ pub fn install_vsc_editor_extension( ))) } +/// Open VSX is the editor-independent fallback source for the extension. Every +/// VS Code-family editor (VS Code, Cursor, Windsurf, VSCodium) can install from +/// a local `.vsix` file, but they don't all share one marketplace: Cursor and +/// VS Code resolve extension IDs against the Microsoft Marketplace, while +/// VSCodium uses Open VSX. So when an ID-based install fails (the editor's +/// gallery doesn't carry the extension), we download the `.vsix` from Open VSX +/// and install from the file instead — which works everywhere. +const OPEN_VSX_API: &str = "https://open-vsx.org/api"; + +/// Fetch a URL and return its body bytes (follows redirects; Open VSX serves the +/// `.vsix` via a 302 to blob storage). +fn http_get_bytes(url: &str) -> Result, String> { + let agent = crate::http::build_agent(Some(60)); + let request = agent.get(url).set( + "User-Agent", + &format!("autter/{}", env!("CARGO_PKG_VERSION")), + ); + let response = crate::http::send(request)?; + if response.status_code != 200 { + return Err(format!("HTTP {} from {url}", response.status_code)); + } + Ok(response.into_bytes()) +} + +/// Download the latest `.vsix` for `publisher.name` from Open VSX into a temp +/// file and return its path. The caller is responsible for deleting it. +fn download_extension_vsix(extension_id: &str) -> Result { + let (publisher, name) = extension_id.split_once('.').ok_or_else(|| { + AutterError::Generic(format!( + "invalid extension id '{extension_id}' (expected publisher.name)" + )) + })?; + + // Look up the latest version to find its download URL. + let meta_url = format!("{OPEN_VSX_API}/{publisher}/{name}/latest"); + let meta_bytes = http_get_bytes(&meta_url) + .map_err(|e| AutterError::Generic(format!("Open VSX metadata fetch failed: {e}")))?; + let meta: serde_json::Value = + serde_json::from_slice(&meta_bytes).map_err(AutterError::JsonError)?; + + let download_url = meta + .get("files") + .and_then(|files| files.get("download")) + .and_then(|url| url.as_str()) + .ok_or_else(|| { + AutterError::Generic(format!("Open VSX has no .vsix download for '{extension_id}'")) + })?; + let version = meta + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("latest"); + + let vsix_bytes = http_get_bytes(download_url) + .map_err(|e| AutterError::Generic(format!("Open VSX .vsix download failed: {e}")))?; + + let path = std::env::temp_dir().join(format!("{publisher}.{name}-{version}.vsix")); + fs::write(&path, vsix_bytes).map_err(|e| { + AutterError::Generic(format!("failed to write .vsix to {}: {e}", path.display())) + })?; + Ok(path) +} + +/// Install an extension, falling back to a direct Open VSX `.vsix` download when +/// the editor's marketplace can't resolve it by ID. This makes onboarding install +/// the extension regardless of which gallery a given editor is wired to. +pub fn install_vsc_editor_extension_with_vsix_fallback( + cli: &EditorCliCommand, + extension_id: &str, +) -> Result<(), AutterError> { + let id_err = match install_vsc_editor_extension(cli, extension_id) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + + tracing::debug!( + "{}: marketplace install of '{extension_id}' failed ({id_err}); trying Open VSX .vsix fallback", + cli.program + ); + + let vsix_path = download_extension_vsix(extension_id).map_err(|dl_err| { + AutterError::Generic(format!( + "marketplace install failed ({id_err}); could not download .vsix from Open VSX: {dl_err}" + )) + })?; + + let result = install_vsc_editor_extension(cli, &vsix_path.to_string_lossy()); + let _ = fs::remove_file(&vsix_path); + result.map_err(|vsix_err| { + AutterError::Generic(format!( + "marketplace install failed ({id_err}); .vsix fallback install also failed: {vsix_err}" + )) + }) +} + /// Strip the Windows extended-length path prefix (`\\?\`) if present. /// On Windows, `std::fs::canonicalize` returns paths prefixed with `\\?\` /// (e.g. `\\?\C:\Users\...`). This prefix causes problems when the path is diff --git a/src/notes/db.rs b/src/notes/db.rs index cd27646..0a4c943 100644 --- a/src/notes/db.rs +++ b/src/notes/db.rs @@ -17,7 +17,7 @@ use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// Current schema version (must equal MIGRATIONS.len()). -const SCHEMA_VERSION: usize = 1; +const SCHEMA_VERSION: usize = 2; /// Database migrations — each entry upgrades the schema by one version. const MIGRATIONS: &[&str] = &[ @@ -39,6 +39,12 @@ const MIGRATIONS: &[&str] = &[ CREATE INDEX IF NOT EXISTS idx_notes_pending ON notes(synced, next_retry_at) WHERE synced = 0; "#, + // Migration 1 → 2: record the canonical repo remote URL per note so the + // daemon flush can route each note to the org that owns that repository. + // NULL means "no known repo" → upload to the user's home org. + r#" + ALTER TABLE notes ADD COLUMN repo_url TEXT; + "#, ]; /// Global singleton for the notes database. @@ -50,6 +56,9 @@ pub struct PendingNote { pub commit_sha: String, pub content: String, pub attempts: i64, + /// Canonical remote URL of the repo this note came from (None = unknown → + /// home org). Used to route the upload to the org that owns the repository. + pub repo_url: Option, } /// SQLite wrapper for notes storage and queue. @@ -234,25 +243,45 @@ impl NotesDatabase { /// - If the content changed, `synced` and `attempts` are reset to 0 so the /// updated note is queued for re-upload. pub fn upsert_note(&mut self, commit_sha: &str, content: &str) -> Result<(), AutterError> { + self.upsert_note_with_repo(commit_sha, content, None) + } + + /// Upsert a note, associating it with the repo it came from (for org routing). + pub fn upsert_note_with_repo( + &mut self, + commit_sha: &str, + content: &str, + repo_url: Option<&str>, + ) -> Result<(), AutterError> { let now = unix_now(); self.conn.execute( r#" - INSERT INTO notes (commit_sha, content, synced, created_at, updated_at, next_retry_at) - VALUES (?1, ?2, 0, ?3, ?3, ?3) + INSERT INTO notes (commit_sha, content, repo_url, synced, created_at, updated_at, next_retry_at) + VALUES (?1, ?2, ?3, 0, ?4, ?4, ?4) ON CONFLICT(commit_sha) DO UPDATE SET content = excluded.content, + repo_url = excluded.repo_url, synced = CASE WHEN notes.content = excluded.content THEN notes.synced ELSE 0 END, attempts = CASE WHEN notes.content = excluded.content THEN notes.attempts ELSE 0 END, next_retry_at = CASE WHEN notes.content = excluded.content THEN notes.next_retry_at ELSE excluded.next_retry_at END, updated_at = excluded.updated_at "#, - params![commit_sha, content, now], + params![commit_sha, content, repo_url, now], )?; Ok(()) } /// Upsert a batch of notes inside a single transaction. pub fn upsert_notes_batch(&mut self, entries: &[(String, String)]) -> Result<(), AutterError> { + self.upsert_notes_batch_with_repo(entries, None) + } + + /// Upsert a batch of notes from a single repo (for org routing). + pub fn upsert_notes_batch_with_repo( + &mut self, + entries: &[(String, String)], + repo_url: Option<&str>, + ) -> Result<(), AutterError> { if entries.is_empty() { return Ok(()); } @@ -261,10 +290,11 @@ impl NotesDatabase { { let mut stmt = tx.prepare_cached( r#" - INSERT INTO notes (commit_sha, content, synced, created_at, updated_at, next_retry_at) - VALUES (?1, ?2, 0, ?3, ?3, ?3) + INSERT INTO notes (commit_sha, content, repo_url, synced, created_at, updated_at, next_retry_at) + VALUES (?1, ?2, ?3, 0, ?4, ?4, ?4) ON CONFLICT(commit_sha) DO UPDATE SET content = excluded.content, + repo_url = excluded.repo_url, synced = CASE WHEN notes.content = excluded.content THEN notes.synced ELSE 0 END, attempts = CASE WHEN notes.content = excluded.content THEN notes.attempts ELSE 0 END, next_retry_at = CASE WHEN notes.content = excluded.content THEN notes.next_retry_at ELSE excluded.next_retry_at END, @@ -272,7 +302,7 @@ impl NotesDatabase { "#, )?; for (sha, content) in entries { - stmt.execute(params![sha, content, now])?; + stmt.execute(params![sha, content, repo_url, now])?; } } tx.commit()?; @@ -374,7 +404,7 @@ impl NotesDatabase { // Read back the locked rows. let select_sql = format!( - "SELECT commit_sha, content, attempts FROM notes WHERE commit_sha IN ({})", + "SELECT commit_sha, content, attempts, repo_url FROM notes WHERE commit_sha IN ({})", shas.iter() .enumerate() .map(|(i, _)| format!("?{}", i + 1)) @@ -393,6 +423,7 @@ impl NotesDatabase { commit_sha: row.get(0)?, content: row.get(1)?, attempts: row.get(2)?, + repo_url: row.get(3)?, }) })?; diff --git a/src/notes/mod.rs b/src/notes/mod.rs index 254f44c..c697a39 100644 --- a/src/notes/mod.rs +++ b/src/notes/mod.rs @@ -1,11 +1,7 @@ //! Notes backend module. //! //! `notes::db` provides the dedicated `~/.autter/internal/notes-db` SQLite store -//! used by the HTTP notes backend as both a write queue and a local read cache. -//! -//! `notes::reference_server` is an in-memory reference implementation of the -//! HTTP wire contract — used for local testing, benchmarking, and as -//! documentation of what a real backend must implement. +//! used as both the write queue and the local read cache for authorship notes +//! that sync to the org's own database (see `api::org_db`). pub mod db; -pub mod reference_server; diff --git a/src/notes/reference_server.rs b/src/notes/reference_server.rs deleted file mode 100644 index f384560..0000000 --- a/src/notes/reference_server.rs +++ /dev/null @@ -1,521 +0,0 @@ -//! In-memory reference implementation of the notes backend HTTP server. -//! -//! This module exists to make the wire contract between `autter` and a -//! third-party notes backend self-documenting and locally runnable. It is NOT -//! intended for production use: -//! -//! - All notes live in an `Arc>` for the lifetime of the -//! process. -//! - Authentication is accepted but not validated. -//! - Concurrency is "thread-per-connection" with no rate limiting. -//! -//! What it IS good for: -//! -//! - Demonstrating exactly which endpoints a real backend must implement, -//! what the request and response bodies look like, and which status codes -//! `autter` distinguishes between. -//! - Driving local end-to-end tests and benchmarks without a real server. -//! - Serving as a starting point for a real implementation. -//! -//! # Wire contract -//! -//! The client side lives in `src/api/notes.rs`. The two endpoints are: -//! -//! ## `POST /worker/notes/upload` -//! -//! Request body (JSON, [`NotesUploadRequest`]): -//! -//! ```json -//! { -//! "entries": [ -//! { "commit_sha": "", "content": "" }, -//! ... -//! ] -//! } -//! ``` -//! -//! Response body (JSON, [`NotesUploadResponse`]): -//! -//! ```json -//! { "success_count": , "failure_count": } -//! ``` -//! -//! Status: `200` on success; `400` on a malformed body. -//! -//! ## `GET /worker/notes/?commits=,,...` -//! -//! Response body (JSON, [`NotesReadResponse`]): -//! -//! ```json -//! { "notes": { "": "", ... } } -//! ``` -//! -//! Status: `200` if at least one of the requested SHAs is known; `404` -//! otherwise (the client treats `404` as "no notes found" — equivalent to an -//! empty map). -//! -//! [`NotesUploadRequest`]: crate::api::types::NotesUploadRequest -//! [`NotesUploadResponse`]: crate::api::types::NotesUploadResponse -//! [`NotesReadResponse`]: crate::api::types::NotesReadResponse - -use crate::api::types::{NotesReadResponse, NotesUploadRequest, NotesUploadResponse}; -use crate::error::AutterError; -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::JoinHandle; - -/// In-memory, thread-safe note store. -#[derive(Default, Clone)] -pub struct NotesStore { - inner: Arc>>, -} - -impl NotesStore { - pub fn new() -> Self { - Self::default() - } - - /// Insert / overwrite a single note. Returns `true` if a previous value - /// was overwritten. - pub fn put(&self, commit_sha: String, content: String) -> bool { - let mut guard = self.inner.lock().expect("notes store poisoned"); - guard.insert(commit_sha, content).is_some() - } - - /// Look up a single commit SHA. - pub fn get(&self, commit_sha: &str) -> Option { - self.inner - .lock() - .expect("notes store poisoned") - .get(commit_sha) - .cloned() - } - - /// Look up many commit SHAs at once. Missing SHAs are absent from the map. - pub fn get_many(&self, commit_shas: &[&str]) -> HashMap { - let guard = self.inner.lock().expect("notes store poisoned"); - commit_shas - .iter() - .filter_map(|sha| guard.get(*sha).map(|c| (sha.to_string(), c.clone()))) - .collect() - } - - pub fn len(&self) -> usize { - self.inner.lock().expect("notes store poisoned").len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -/// Handle to a running reference server. The server runs on a background -/// thread; dropping the handle (or calling [`ReferenceServer::shutdown`]) -/// stops the accept loop. -pub struct ReferenceServer { - addr: SocketAddr, - store: NotesStore, - shutdown: Arc, - join: Option>, -} - -impl ReferenceServer { - /// Bind to `bind_addr` (e.g. `127.0.0.1:0`) and spawn the accept loop on a - /// background thread. The returned handle exposes the bound address (so - /// callers binding to port `0` can discover the chosen port) and the - /// shared [`NotesStore`]. - pub fn start(bind_addr: &str) -> Result { - let listener = TcpListener::bind(bind_addr) - .map_err(|e| AutterError::Generic(format!("bind {}: {}", bind_addr, e)))?; - // Short read timeout so the accept loop can periodically observe the - // shutdown flag without needing a separate wakeup mechanism. - listener - .set_nonblocking(false) - .map_err(AutterError::IoError)?; - - let addr = listener.local_addr().map_err(AutterError::IoError)?; - let store = NotesStore::new(); - let shutdown = Arc::new(AtomicBool::new(false)); - - let store_clone = store.clone(); - let shutdown_clone = shutdown.clone(); - let join = std::thread::Builder::new() - .name("notes-reference-server".into()) - .spawn(move || accept_loop(listener, store_clone, shutdown_clone)) - .map_err(AutterError::IoError)?; - - Ok(Self { - addr, - store, - shutdown, - join: Some(join), - }) - } - - pub fn addr(&self) -> SocketAddr { - self.addr - } - - pub fn base_url(&self) -> String { - format!("http://{}", self.addr) - } - - pub fn store(&self) -> &NotesStore { - &self.store - } - - /// Signal the accept loop to stop and wait for the thread to exit. - pub fn shutdown(mut self) { - self.shutdown_inner(); - } - - fn shutdown_inner(&mut self) { - self.shutdown.store(true, Ordering::SeqCst); - // Wake the accept loop by connecting to ourselves. - let _ = TcpStream::connect(self.addr); - if let Some(handle) = self.join.take() { - let _ = handle.join(); - } - } -} - -impl Drop for ReferenceServer { - fn drop(&mut self) { - self.shutdown_inner(); - } -} - -/// Run the server on the current thread until `Ctrl-C`. Used by the -/// `autter notes serve` CLI entry point. -pub fn run_blocking(bind_addr: &str) -> Result<(), AutterError> { - let server = ReferenceServer::start(bind_addr)?; - eprintln!( - "notes reference server listening on http://{}\n\ - (in-memory; not for production)\n\ - press Ctrl-C to stop.", - server.addr() - ); - // Block until the spawned thread exits — which only happens via shutdown, - // which the CLI never triggers, so this is effectively `park forever`. - if let Some(handle) = server.join.as_ref() { - // Park the main thread; the OS will deliver SIGINT to the process and - // tear everything down. We park in a loop because spurious wakeups - // would otherwise cause this to return early. - while !handle.is_finished() { - std::thread::park(); - } - } - Ok(()) -} - -fn accept_loop(listener: TcpListener, store: NotesStore, shutdown: Arc) { - for stream in listener.incoming() { - if shutdown.load(Ordering::SeqCst) { - break; - } - match stream { - Ok(stream) => { - let store = store.clone(); - std::thread::spawn(move || { - if let Err(e) = handle_connection(stream, &store) { - eprintln!("notes-reference-server: connection error: {}", e); - } - }); - } - Err(e) => { - eprintln!("notes-reference-server: accept error: {}", e); - } - } - } -} - -// ---------------------------------------------------------------------------- -// Minimal HTTP/1.1 request handling -// ---------------------------------------------------------------------------- - -struct Request { - method: String, - path: String, - query: String, - body: Vec, -} - -fn handle_connection(mut stream: TcpStream, store: &NotesStore) -> Result<(), AutterError> { - let request = read_request(&mut stream)?; - let response = dispatch(&request, store); - write_response(&mut stream, &response) -} - -fn read_request(stream: &mut TcpStream) -> Result { - let mut reader = BufReader::new(stream.try_clone().map_err(AutterError::IoError)?); - - // Request line. - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .map_err(AutterError::IoError)?; - let mut parts = request_line.split_whitespace(); - let method = parts.next().unwrap_or("").to_string(); - let target = parts.next().unwrap_or("").to_string(); - - // Split target into path + query. - let (path, query) = match target.split_once('?') { - Some((p, q)) => (p.to_string(), q.to_string()), - None => (target, String::new()), - }; - - // Headers (read until empty line, only `Content-Length` matters here). - let mut content_length: usize = 0; - loop { - let mut line = String::new(); - let n = reader.read_line(&mut line).map_err(AutterError::IoError)?; - if n == 0 || line == "\r\n" || line == "\n" { - break; - } - if let Some((name, value)) = line.split_once(':') - && name.trim().eq_ignore_ascii_case("content-length") - { - content_length = value.trim().parse().unwrap_or(0); - } - } - - // Body — cap at 50 MB to prevent OOM from malformed Content-Length. - const MAX_BODY: usize = 50 * 1024 * 1024; - if content_length > MAX_BODY { - return Err(AutterError::Generic(format!( - "Content-Length {} exceeds maximum {}", - content_length, MAX_BODY - ))); - } - let mut body = vec![0u8; content_length]; - if content_length > 0 { - reader.read_exact(&mut body).map_err(AutterError::IoError)?; - } - - Ok(Request { - method, - path, - query, - body, - }) -} - -struct Response { - status: u16, - body: Vec, -} - -impl Response { - fn json(status: u16, value: &serde_json::Value) -> Self { - Self { - status, - body: serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec()), - } - } - - fn error(status: u16, message: &str) -> Self { - Self::json(status, &serde_json::json!({ "error": message })) - } -} - -fn dispatch(req: &Request, store: &NotesStore) -> Response { - // Tolerate the trailing slash variant the client sends (`/worker/notes/`) - // as well as the bare path. - let path = req.path.trim_end_matches('/'); - match (req.method.as_str(), path) { - ("POST", "/worker/notes/upload") => handle_upload(&req.body, store), - ("GET", "/worker/notes") => handle_read(&req.query, store), - _ => Response::error(404, "not found"), - } -} - -fn handle_upload(body: &[u8], store: &NotesStore) -> Response { - let request: NotesUploadRequest = match serde_json::from_slice(body) { - Ok(r) => r, - Err(e) => return Response::error(400, &format!("invalid request body: {}", e)), - }; - - let mut success_count = 0usize; - let failure_count = 0usize; - for entry in request.entries { - store.put(entry.commit_sha, entry.content); - success_count += 1; - } - - let response = NotesUploadResponse { - success_count, - failure_count, - }; - Response::json( - 200, - &serde_json::to_value(response).expect("serialise upload response"), - ) -} - -fn handle_read(query: &str, store: &NotesStore) -> Response { - // The client sends `commits=sha1,sha2,...`. We accept either form. - let commits: Vec<&str> = query - .split('&') - .filter_map(|kv| kv.split_once('=')) - .filter(|(k, _)| *k == "commits") - .flat_map(|(_, v)| v.split(',')) - .filter(|s| !s.is_empty()) - .collect(); - - let notes = store.get_many(&commits); - - if notes.is_empty() { - // The client treats `404` as success-with-empty; we mirror that here - // (rather than returning `200` + empty map) to exercise the cold-miss - // path of the wire contract. - return Response::json(404, &serde_json::json!({ "notes": {} })); - } - - let response = NotesReadResponse { notes }; - Response::json( - 200, - &serde_json::to_value(response).expect("serialise read response"), - ) -} - -fn write_response(stream: &mut TcpStream, response: &Response) -> Result<(), AutterError> { - let reason = match response.status { - 200 => "OK", - 400 => "Bad Request", - 404 => "Not Found", - _ => "Error", - }; - let header = format!( - "HTTP/1.1 {} {}\r\n\ - Content-Type: application/json\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n", - response.status, - reason, - response.body.len() - ); - stream - .write_all(header.as_bytes()) - .map_err(AutterError::IoError)?; - stream - .write_all(&response.body) - .map_err(AutterError::IoError)?; - stream.flush().map_err(AutterError::IoError)?; - Ok(()) -} - -// ---------------------------------------------------------------------------- -// Tests -// ---------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::api::client::{ApiClient, ApiContext}; - use crate::api::types::{NoteEntry, NotesUploadRequest}; - - fn client_for(server: &ReferenceServer) -> ApiClient { - // `ApiContext::without_auth` still picks up an API key from the - // environment if one is set. The reference server ignores headers, so - // either way is fine. - ApiClient::new(ApiContext::without_auth(Some(server.base_url()))) - } - - #[test] - fn upload_then_read_round_trip() { - let server = ReferenceServer::start("127.0.0.1:0").expect("start server"); - let client = client_for(&server); - - let entries = vec![ - NoteEntry { - commit_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - content: "note-a".to_string(), - }, - NoteEntry { - commit_sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), - content: "note-b".to_string(), - }, - ]; - let upload = client - .upload_notes(NotesUploadRequest { - entries: entries.clone(), - }) - .expect("upload"); - assert_eq!(upload.success_count, 2); - assert_eq!(upload.failure_count, 0); - - let shas: Vec<&str> = entries.iter().map(|e| e.commit_sha.as_str()).collect(); - let read = client.read_notes(&shas).expect("read"); - assert_eq!(read.notes.len(), 2); - assert_eq!( - read.notes.get(&entries[0].commit_sha).map(|s| s.as_str()), - Some("note-a") - ); - assert_eq!( - read.notes.get(&entries[1].commit_sha).map(|s| s.as_str()), - Some("note-b") - ); - } - - #[test] - fn read_unknown_sha_returns_empty() { - let server = ReferenceServer::start("127.0.0.1:0").expect("start server"); - let client = client_for(&server); - - let read = client - .read_notes(&["0000000000000000000000000000000000000000"]) - .expect("read"); - assert!(read.notes.is_empty()); - } - - #[test] - fn upload_rejects_malformed_body() { - let server = ReferenceServer::start("127.0.0.1:0").expect("start server"); - - // Bypass `ApiClient` so we can send invalid JSON directly. - let mut stream = TcpStream::connect(server.addr()).expect("connect"); - let body = b"not json"; - let request = format!( - "POST /worker/notes/upload HTTP/1.1\r\n\ - Host: localhost\r\n\ - Content-Type: application/json\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n", - body.len() - ); - stream.write_all(request.as_bytes()).expect("write head"); - stream.write_all(body).expect("write body"); - - let mut response = String::new(); - stream.read_to_string(&mut response).expect("read response"); - assert!(response.starts_with("HTTP/1.1 400"), "got: {}", response); - } - - #[test] - fn store_is_shared_across_connections() { - let server = ReferenceServer::start("127.0.0.1:0").expect("start server"); - let store = server.store().clone(); - - let client = client_for(&server); - client - .upload_notes(NotesUploadRequest { - entries: vec![NoteEntry { - commit_sha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(), - content: "x".to_string(), - }], - }) - .expect("upload"); - - // The store handed back by `server.store()` reflects writes that came - // in over the socket — proving the in-memory store really is shared. - assert_eq!( - store.get("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), - Some("x".to_string()) - ); - } -}