jj workspaces over the network
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

empty everything

-5035
-1
.fp/.gitignore
··· 1 - snapshots/
-4
.fp/config.toml
··· 1 - # FP CLI Configuration 2 - 3 - project_id = "proj_01KEMKEQ5R5EA67RM0H4YKKVF5" 4 - prefix = "TAN"
-54
Cargo.toml
··· 1 - [workspace] 2 - resolver = "2" 3 - members = [ 4 - "crates/tandem-core", 5 - "crates/tandem-server", 6 - "crates/tandem-cli", 7 - ] 8 - 9 - [workspace.package] 10 - version = "0.1.0" 11 - edition = "2024" 12 - authors = ["Tandem Contributors"] 13 - license = "MIT OR Apache-2.0" 14 - repository = "https://github.com/laulauland/tandem" 15 - 16 - [workspace.dependencies] 17 - # Shared core library 18 - tandem-core = { path = "crates/tandem-core" } 19 - 20 - # Serialization 21 - serde = { version = "1.0", features = ["derive"] } 22 - serde_json = "1.0" 23 - 24 - # Utilities 25 - uuid = { version = "1.11", features = ["v4", "serde"] } 26 - chrono = { version = "0.4", features = ["serde"] } 27 - 28 - # CRDT 29 - yrs = "0.21" 30 - 31 - # Async runtime 32 - tokio = { version = "1.0", features = ["full"] } 33 - async-trait = "0.1" 34 - futures-util = "0.3" 35 - 36 - # Web framework 37 - axum = { version = "0.7", features = ["ws"] } 38 - tower = "0.5" 39 - tower-http = { version = "0.6", features = ["cors", "trace"] } 40 - 41 - # Logging 42 - tracing = "0.1" 43 - tracing-subscriber = { version = "0.3", features = ["env-filter"] } 44 - 45 - # Database 46 - sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] } 47 - 48 - # CLI 49 - clap = { version = "4.5", features = ["derive"] } 50 - 51 - # Encoding 52 - hex = "0.4" 53 - rand = "0.8" 54 - base64 = "0.22"
-420
QA.md
··· 1 - # Tandem QA Test Plan 2 - 3 - ## Prerequisites 4 - 5 - 1. Rust toolchain installed 6 - 2. `jj` (Jujutsu) installed 7 - 3. `sqlite3` CLI available 8 - 4. `curl` and `jq` for API testing 9 - 5. Two terminal windows minimum 10 - 11 - ## Build 12 - 13 - ```bash 14 - cd /home/lau/code/laulauland/tandem 15 - cargo build --release 16 - ``` 17 - 18 - **Expected**: Build completes with warnings only, no errors. Binaries at: 19 - - `target/release/tandem-server` 20 - - `target/release/jjf` 21 - 22 - --- 23 - 24 - ## Test 1: Server Startup 25 - 26 - ### Steps 27 - ```bash 28 - # Terminal 1 29 - DATABASE_URL=sqlite:tandem.db DATA_DIR=./data ./target/release/tandem-server 30 - ``` 31 - 32 - ### Expected Output 33 - ``` 34 - Server running on http://localhost:3000 35 - ``` 36 - 37 - ### Verify 38 - ```bash 39 - curl http://localhost:3000/health 40 - ``` 41 - 42 - ### Expected Response 43 - ```json 44 - {"status":"ok"} 45 - ``` 46 - 47 - --- 48 - 49 - ## Test 2: User Creation and Authentication 50 - 51 - ### Steps 52 - ```bash 53 - # Create database tables (auto-created on first run, but verify) 54 - sqlite3 tandem.db ".tables" 55 - ``` 56 - 57 - ### Expected Output 58 - ``` 59 - auth_tokens repo_access repos users 60 - ``` 61 - 62 - ### Create Test User 63 - ```bash 64 - sqlite3 tandem.db "INSERT INTO users (id, email, name, password_hash) 65 - VALUES ('user-alice', 'alice@example.com', 'Alice', 'password123');" 66 - ``` 67 - 68 - ### Login 69 - ```bash 70 - curl -s -X POST http://localhost:3000/api/auth/login \ 71 - -H "Content-Type: application/json" \ 72 - -d '{"email":"alice@example.com","password":"password123"}' 73 - ``` 74 - 75 - ### Expected Response 76 - ```json 77 - { 78 - "token": "<64-char-hex-string>", 79 - "expires_at": "<RFC3339-timestamp>" 80 - } 81 - ``` 82 - 83 - ### Save Token 84 - ```bash 85 - export TOKEN="<paste-token-here>" 86 - ``` 87 - 88 - ### Verify Token 89 - ```bash 90 - curl -s http://localhost:3000/api/auth/me \ 91 - -H "Authorization: Bearer $TOKEN" 92 - ``` 93 - 94 - ### Expected Response 95 - ```json 96 - { 97 - "id": "user-alice", 98 - "email": "alice@example.com", 99 - "name": "Alice" 100 - } 101 - ``` 102 - 103 - --- 104 - 105 - ## Test 3: Repository Creation 106 - 107 - ### Steps 108 - ```bash 109 - curl -s -X POST http://localhost:3000/api/repos \ 110 - -H "Authorization: Bearer $TOKEN" \ 111 - -H "Content-Type: application/json" \ 112 - -d '{"name":"my-project","org":"acme"}' 113 - ``` 114 - 115 - ### Expected Response 116 - ```json 117 - { 118 - "id": "<uuid>", 119 - "name": "my-project", 120 - "org": "acme", 121 - "created_at": "<RFC3339-timestamp>" 122 - } 123 - ``` 124 - 125 - ### Save Repo ID 126 - ```bash 127 - export REPO_ID="<paste-repo-id-here>" 128 - ``` 129 - 130 - ### Verify Repo Created 131 - ```bash 132 - curl -s http://localhost:3000/api/repos \ 133 - -H "Authorization: Bearer $TOKEN" 134 - ``` 135 - 136 - ### Expected Response 137 - Array containing the created repo (user has admin access as creator). 138 - 139 - --- 140 - 141 - ## Test 4: Access Control 142 - 143 - ### Create Second User Without Access 144 - ```bash 145 - sqlite3 tandem.db "INSERT INTO users (id, email, name, password_hash) 146 - VALUES ('user-bob', 'bob@example.com', 'Bob', 'password456');" 147 - 148 - # Login as Bob 149 - BOB_TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \ 150 - -H "Content-Type: application/json" \ 151 - -d '{"email":"bob@example.com","password":"password456"}' | jq -r .token) 152 - ``` 153 - 154 - ### Try to Access Repo as Bob 155 - ```bash 156 - curl -s http://localhost:3000/api/repos/$REPO_ID \ 157 - -H "Authorization: Bearer $BOB_TOKEN" 158 - ``` 159 - 160 - ### Expected Response 161 - HTTP 403 Forbidden (Bob has no access to Alice's repo) 162 - 163 - ### Grant Bob Read Access 164 - ```bash 165 - sqlite3 tandem.db "INSERT INTO repo_access (repo_id, user_id, role) 166 - VALUES ('$REPO_ID', 'user-bob', 'read');" 167 - ``` 168 - 169 - ### Retry Access 170 - ```bash 171 - curl -s http://localhost:3000/api/repos/$REPO_ID \ 172 - -H "Authorization: Bearer $BOB_TOKEN" 173 - ``` 174 - 175 - ### Expected Response 176 - ```json 177 - { 178 - "id": "<repo-id>", 179 - "name": "my-project", 180 - "org": "acme", 181 - "created_at": "<timestamp>" 182 - } 183 - ``` 184 - 185 - --- 186 - 187 - ## Test 5: jjf CLI - Link Repository 188 - 189 - ### Setup Local jj Repo 190 - ```bash 191 - # Terminal 2 192 - mkdir /tmp/test-project && cd /tmp/test-project 193 - jj init 194 - echo "Hello World" > README.md 195 - jj new -m "Initial commit" 196 - ``` 197 - 198 - ### Link to Forge 199 - ```bash 200 - /home/lau/code/laulauland/tandem/target/release/jjf link \ 201 - http://localhost:3000/acme/my-project \ 202 - --token $TOKEN 203 - ``` 204 - 205 - ### Expected Output 206 - ``` 207 - ✓ Linked to forge: http://localhost:3000/acme/my-project 208 - Run 'jjf daemon start' to begin syncing 209 - ``` 210 - 211 - ### Verify Config Created 212 - ```bash 213 - cat .jj/forge.toml 214 - ``` 215 - 216 - ### Expected Content 217 - ```toml 218 - [forge] 219 - url = "http://localhost:3000/acme/my-project" 220 - ``` 221 - 222 - --- 223 - 224 - ## Test 6: jjf CLI - Status 225 - 226 - ### Steps 227 - ```bash 228 - /home/lau/code/laulauland/tandem/target/release/jjf status 229 - ``` 230 - 231 - ### Expected Output 232 - ``` 233 - Repository: /tmp/test-project 234 - Forge: http://localhost:3000/acme/my-project 235 - Status: Not syncing (daemon not running) 236 - ``` 237 - 238 - --- 239 - 240 - ## Test 7: jjf Daemon - WebSocket Sync 241 - 242 - ### Start Daemon 243 - ```bash 244 - # Terminal 2 (in /tmp/test-project) 245 - /home/lau/code/laulauland/tandem/target/release/jjf daemon start 246 - ``` 247 - 248 - ### Expected Output 249 - ``` 250 - Starting daemon for /tmp/test-project 251 - Connecting to forge: ws://localhost:3000/sync/my-project 252 - ``` 253 - 254 - ### Verify in Server Logs (Terminal 1) 255 - ``` 256 - Client connected to sync for repo my-project 257 - ``` 258 - 259 - ### Stop Daemon 260 - Press `Ctrl+C` 261 - 262 - ### Expected Output 263 - ``` 264 - Daemon shutting down 265 - ``` 266 - 267 - --- 268 - 269 - ## Test 8: jjf Clone 270 - 271 - ### Steps 272 - ```bash 273 - # Terminal 2 274 - cd /tmp 275 - /home/lau/code/laulauland/tandem/target/release/jjf clone \ 276 - http://localhost:3000/acme/my-project \ 277 - --token $TOKEN 278 - ``` 279 - 280 - ### Expected Output 281 - ``` 282 - Cloning into '/tmp/my-project'... 283 - Syncing initial state... 284 - ✓ Received X changes, Y bookmarks 285 - ✓ Linked to forge: http://localhost:3000/acme/my-project 286 - Run 'jjf daemon start' to begin syncing 287 - ✓ Cloned repository to /tmp/my-project 288 - ``` 289 - 290 - ### Verify Clone 291 - ```bash 292 - cd /tmp/my-project 293 - ls -la .jj/ 294 - cat .jj/forge.toml 295 - ``` 296 - 297 - --- 298 - 299 - ## Test 9: WebSocket Broadcast (Multi-Client Sync) 300 - 301 - ### Setup 302 - Start two daemon instances connected to the same repo. 303 - 304 - ### Terminal 2 305 - ```bash 306 - cd /tmp/test-project 307 - /home/lau/code/laulauland/tandem/target/release/jjf daemon start 308 - ``` 309 - 310 - ### Terminal 3 311 - ```bash 312 - cd /tmp/my-project # The cloned repo 313 - /home/lau/code/laulauland/tandem/target/release/jjf daemon start 314 - ``` 315 - 316 - ### Expected Server Logs 317 - ``` 318 - Client connected to sync for repo my-project 319 - Client connected to sync for repo my-project 320 - ``` 321 - 322 - ### Test Sync 323 - Make a change in one repo and verify it appears in the other. 324 - 325 - **Note**: Full bidirectional sync requires the daemon to push local jj changes, which needs additional integration with jj-lib's operation log watching. 326 - 327 - --- 328 - 329 - ## Test 10: REST API - Changes and Bookmarks 330 - 331 - ### List Changes 332 - ```bash 333 - curl -s http://localhost:3000/api/repos/$REPO_ID/changes \ 334 - -H "Authorization: Bearer $TOKEN" 335 - ``` 336 - 337 - ### Expected Response 338 - ```json 339 - [] 340 - ``` 341 - (Empty until changes are synced from a jj repo) 342 - 343 - ### List Bookmarks 344 - ```bash 345 - curl -s http://localhost:3000/api/repos/$REPO_ID/bookmarks \ 346 - -H "Authorization: Bearer $TOKEN" 347 - ``` 348 - 349 - ### Expected Response 350 - ```json 351 - [] 352 - ``` 353 - 354 - --- 355 - 356 - ## Test 11: Content Endpoint 357 - 358 - ### Store Test Content (via server) 359 - This requires content to be synced first. Manual test: 360 - 361 - ```bash 362 - curl -s http://localhost:3000/api/repos/$REPO_ID/content/abc123 \ 363 - -H "Authorization: Bearer $TOKEN" 364 - ``` 365 - 366 - ### Expected Response 367 - HTTP 404 (content not found - expected for non-existent hash) 368 - 369 - --- 370 - 371 - ## Test 12: Events WebSocket 372 - 373 - ### Connect to Events 374 - ```bash 375 - # Requires websocat or similar 376 - websocat ws://localhost:3000/events/$REPO_ID 377 - ``` 378 - 379 - ### Expected Initial Message 380 - ```json 381 - {"type":"connected","repo_id":"<repo-id>"} 382 - ``` 383 - 384 - --- 385 - 386 - ## Test 13: jj-lib Integration 387 - 388 - ### Verify jj Repository Reading 389 - ```bash 390 - cd /tmp/test-project 391 - /home/lau/code/laulauland/tandem/target/release/jjf list 392 - ``` 393 - 394 - ### Expected Output 395 - List of changes from the jj repository (may be empty for new repos). 396 - 397 - --- 398 - 399 - ## Known Limitations 400 - 401 - 1. **Tree Hash Placeholder**: Tree hashes use first 20 bytes of change_id (jj-lib async limitation) 402 - 2. **Password Storage**: Plaintext comparison (use bcrypt in production) 403 - 3. **Token in CLI**: Currently passed as flag (should use keychain) 404 - 4. **Daemon Status**: `jjf daemon status` not fully implemented 405 - 5. **Presence Warnings**: Require daemon IPC (currently stub) 406 - 6. **Log Interception**: `jjf alias` log presence injection is stub 407 - 408 - --- 409 - 410 - ## Cleanup 411 - 412 - ```bash 413 - # Stop server (Ctrl+C in Terminal 1) 414 - 415 - # Remove test data 416 - rm -rf /tmp/test-project /tmp/my-project 417 - rm tandem.db data/ 418 - 419 - # Or keep for further testing 420 - ```
-296
README.md
··· 1 - # Tandem 2 - 3 - A native forge for [Jujutsu](https://github.com/martinvonz/jj) (jj) with real-time multiplayer collaboration. Tandem syncs jj changes, bookmarks, and user presence across multiple clients using CRDTs. 4 - 5 - ## Overview 6 - 7 - Tandem provides a server-client architecture where: 8 - 9 - - The **server** maintains the authoritative Y.Doc (Yrs CRDT document) for each repository 10 - - **Clients** run a daemon that syncs local changes to the server over WebSocket 11 - - All data merges automatically without conflicts using Yrs (the Rust port of Yjs) 12 - 13 - This enables multiple developers to work on the same repository simultaneously with real-time visibility into who is editing what. 14 - 15 - ## Architecture 16 - 17 - Tandem is organized into three crates: 18 - 19 - ### tandem-core 20 - 21 - The shared library containing: 22 - 23 - - **types.rs** - Core data types: `ChangeId`, `TreeHash`, `Change`, `ChangeRecord`, `Bookmark`, `PresenceInfo` 24 - - **sync.rs** - `ForgeDoc` wrapping a Y.Doc with maps for changes, bookmarks, presence, and subdocuments for lazy content loading 25 - 26 - ### tandem-server 27 - 28 - An Axum-based HTTP/WebSocket server: 29 - 30 - - **sync.rs** - WebSocket endpoint at `/sync/:repo_id` handling bidirectional CRDT sync 31 - - **docs.rs** - `DocManager` loads and persists Y.Doc state to `.yrs` files 32 - - REST API for repositories, changes, bookmarks, presence, and content 33 - 34 - ### tandem-cli 35 - 36 - The `jjf` command-line tool and background daemon: 37 - 38 - - **main.rs** - CLI commands: `init`, `link`, `clone`, `daemon start` 39 - - **daemon.rs** - Background sync daemon connecting to the forge 40 - - **presence.rs** - Tracks who is editing which change 41 - - **offline.rs** - Queues operations when disconnected for later replay 42 - - **content.rs** - Lazy fetching of tree/blob content from the server 43 - 44 - ## Data Model 45 - 46 - ### Core Types 47 - 48 - **ChangeId** - A stable 32-byte identifier for a change that persists across rebases. This is jj's native change ID. 49 - 50 - **Change** - The unit of work containing: 51 - - `id: ChangeId` - Stable identifier 52 - - `tree: TreeHash` - Content-addressed 20-byte hash of the tree 53 - - `parents: Vec<ChangeId>` - Parent changes 54 - - `description: String` - Commit message 55 - - `author: Identity` - Name and email 56 - - `timestamp: DateTime<Utc>` 57 - 58 - **ChangeRecord** - The CRDT-friendly wrapper stored in Y.Doc: 59 - - `record_id: Uuid` - Unique key for the Y.Map entry 60 - - `change_id: ChangeId` - The actual change ID 61 - - `visible: bool` - False when abandoned/hidden 62 - - All fields from `Change` 63 - 64 - The distinction matters: a single `ChangeId` can have multiple `ChangeRecord` entries when a change diverges across clients before syncing. The CRDT merges them all, and the application layer decides how to present divergence. 65 - 66 - **Bookmark** - A named pointer to a change (like a git branch): 67 - - `name: String` - Bookmark name 68 - - `target: ChangeId` - The change it points to 69 - - `protected: bool` - Whether rules apply 70 - - `rules: BookmarkRules` - CI/review requirements 71 - 72 - **PresenceInfo** - Real-time editing status: 73 - - `user_id: String` - Username 74 - - `change_id: ChangeId` - Currently edited change 75 - - `device: String` - Device name 76 - - `timestamp: DateTime<Utc>` - Last update time (stale after 5 minutes) 77 - 78 - ### Y.Doc Structure 79 - 80 - Each repository has **one main Y.Doc** for metadata, plus **separate subdocuments** (each its own Y.Doc) for content: 81 - 82 - ``` 83 - ForgeDoc 84 - ├── Main Doc (one per repo) 85 - │ ├── Y.Map("changes") → {record_id: JSON(ChangeRecord)} 86 - │ ├── Y.Map("bookmarks") → {name: ChangeId_hex} 87 - │ └── Y.Map("presence") → {user_id: JSON(PresenceInfo)} 88 - 89 - └── Subdocuments (HashMap<hash, Doc>) 90 - ├── "abc123" → Y.Doc { Y.Map("data") → {content: base64} } 91 - ├── "def456" → Y.Doc { Y.Map("data") → {content: base64} } 92 - └── ... 93 - ``` 94 - 95 - This design enables: 96 - 1. **Fast metadata sync** - The main doc is small and syncs quickly 97 - 2. **Lazy content loading** - Each content blob is a separate Y.Doc fetched on-demand 98 - 3. **Independent sync** - Subdocuments can sync independently, so you only fetch content you need 99 - 100 - ## Sync Protocol 101 - 102 - ### State Vector Exchange 103 - 104 - Yrs uses state vectors to track what each peer has seen. The sync flow: 105 - 106 - 1. **Client connects** via WebSocket to `/sync/:repo_id` 107 - 2. **Client sends state vector** - A compact encoding of which updates it has 108 - 3. **Server computes diff** - Encodes only the updates the client lacks 109 - 4. **Server sends update** - Binary Yrs update packet 110 - 5. **Client applies update** - Merges into its local Y.Doc 111 - 6. **Bidirectional sync continues** - Either side can send updates 112 - 113 - ``` 114 - Client Server 115 - | | 116 - |-- Binary(state_vector) ------>| 117 - | | (compute diff) 118 - |<----- Binary(update) ---------| 119 - | | 120 - |-- Binary(local_update) ------>| (from local edit) 121 - | | (apply, save, broadcast) 122 - | | 123 - ``` 124 - 125 - ### Server-Side Sync (tandem-server/src/sync.rs) 126 - 127 - The server handles each WebSocket message: 128 - 129 - 1. **Try to apply as update** - If valid Yrs update, apply it 130 - 2. **On success** - Save to disk, broadcast to other clients (excluding sender) 131 - 3. **On failure** - Assume it's a state vector, compute and send diff 132 - 133 - The `SyncManager` maintains broadcast channels per repository so updates propagate to all connected clients. 134 - 135 - ### Client-Side Sync (tandem-cli/src/daemon.rs) 136 - 137 - The daemon: 138 - 139 - 1. Connects to the forge WebSocket 140 - 2. Sends its state vector to request initial sync 141 - 3. Receives and applies updates from the server 142 - 4. When local changes happen, sends updates to the server 143 - 5. On disconnect, enters offline mode and queues operations 144 - 145 - ## Conflict Resolution 146 - 147 - ### Why CRDTs Avoid Traditional Conflicts 148 - 149 - Yrs implements a CRDT (Conflict-free Replicated Data Type). Every operation is designed to commute - the same set of operations applied in any order produces the same result. 150 - 151 - For Tandem: 152 - 153 - - **Changes** are keyed by unique `record_id` UUIDs. Two clients creating a record for the same change get two records, not a conflict. 154 - - **Bookmarks** use last-writer-wins semantics on the Y.Map. Concurrent moves to different targets result in one winning. 155 - - **Presence** uses last-writer-wins per user. Stale entries are filtered out by timestamp. 156 - 157 - ### Divergence Handling 158 - 159 - When the same `ChangeId` gets edited on two disconnected clients: 160 - 161 - 1. Each client creates a new `ChangeRecord` with a unique `record_id` 162 - 2. On reconnect, both records sync to all clients 163 - 3. `get_change_records(change_id)` returns multiple records 164 - 4. The application must decide which is canonical (by timestamp, by user, or by user choice) 165 - 166 - Hidden/abandoned changes are marked with `visible: false` rather than deleted, preserving history. 167 - 168 - ### Edge Cases 169 - 170 - - **Bookmark races** - If Alice moves `main` to change A while Bob moves it to B, one wins. The CRDT resolves this deterministically but the "loser" may need to re-move the bookmark. 171 - - **Offline queuing** - Operations made while disconnected are stored in `.jj/forge-queue.json` and replayed on reconnect. 172 - - **Stale presence** - Presence entries older than 5 minutes are filtered out. 173 - 174 - ## Getting Started 175 - 176 - ### Starting the Server 177 - 178 - ```bash 179 - cd crates/tandem-server 180 - DATABASE_URL=sqlite:tandem.db DATA_DIR=./data cargo run 181 - ``` 182 - 183 - The server starts on `http://localhost:3000` with: 184 - - REST API at `/api/*` 185 - - WebSocket sync at `/sync/:repo_id` 186 - - Health check at `/health` 187 - 188 - ### Linking a Repository 189 - 190 - In an existing jj repository: 191 - 192 - ```bash 193 - jjf link https://forge.example.com/org/myrepo --token <auth_token> 194 - ``` 195 - 196 - This: 197 - 1. Tests the connection to the forge 198 - 2. Creates `.jj/forge.toml` with the forge URL 199 - 3. Prints instructions to start the daemon 200 - 201 - ### Cloning from Forge 202 - 203 - ```bash 204 - jjf clone https://forge.example.com/org/myrepo 205 - ``` 206 - 207 - This: 208 - 1. Creates the target directory 209 - 2. Runs `jj init` 210 - 3. Links to the forge 211 - 4. Pulls initial state via WebSocket sync 212 - 5. Saves the Y.Doc state to `.jj/forge-doc.bin` 213 - 214 - ### Running the Daemon 215 - 216 - ```bash 217 - jjf daemon start 218 - ``` 219 - 220 - The daemon: 221 - 1. Loads forge config from `.jj/forge.toml` 222 - 2. Connects to the forge WebSocket 223 - 3. Syncs bidirectionally in real-time 224 - 4. Tracks presence (which change you're editing) 225 - 5. Warns when someone else is editing the same change 226 - 6. Queues operations when offline, replays on reconnect 227 - 228 - ### Presence Tracking 229 - 230 - The daemon uses `whoami` to identify the current user and device. When you edit a change, it broadcasts your presence. Other users see warnings like: 231 - 232 - ``` 233 - Warning: This change is currently being edited by alice@laptop 234 - ``` 235 - 236 - Presence entries expire after 5 minutes of inactivity. 237 - 238 - ## Configuration 239 - 240 - ### .jj/forge.toml 241 - 242 - Created by `jjf link`, stores the forge URL: 243 - 244 - ```toml 245 - [forge] 246 - url = "https://forge.example.com/org/myrepo" 247 - ``` 248 - 249 - ### .jj/forge-queue.json 250 - 251 - Stores queued operations when offline: 252 - 253 - ```json 254 - { 255 - "operations": [ 256 - { 257 - "type": "change_updated", 258 - "record": { ... }, 259 - "timestamp": "2024-01-01T00:00:00Z" 260 - } 261 - ] 262 - } 263 - ``` 264 - 265 - ### .jj/forge-offline 266 - 267 - Marker file indicating offline mode. Removed when connection is restored. 268 - 269 - ## API Reference 270 - 271 - ### REST Endpoints 272 - 273 - | Method | Path | Description | 274 - |--------|------|-------------| 275 - | POST | `/api/auth/login` | Authenticate and get token | 276 - | GET | `/api/auth/me` | Get current user | 277 - | GET | `/api/repos` | List repositories | 278 - | POST | `/api/repos` | Create repository | 279 - | GET | `/api/repos/:id` | Get repository details | 280 - | GET | `/api/repos/:id/changes` | List changes | 281 - | GET | `/api/repos/:id/changes/:cid` | Get specific change | 282 - | GET | `/api/repos/:id/bookmarks` | List bookmarks | 283 - | POST | `/api/repos/:id/bookmarks` | Move bookmark | 284 - | GET | `/api/repos/:id/presence` | Get active presence | 285 - | GET | `/api/repos/:id/content/:hash` | Fetch content by hash | 286 - 287 - ### WebSocket Endpoints 288 - 289 - | Path | Description | 290 - |------|-------------| 291 - | `/sync/:repo_id` | Bidirectional Yrs sync | 292 - | `/events/:repo_id` | Server-sent events for notifications | 293 - 294 - ## License 295 - 296 - MIT OR Apache-2.0
-31
crates/tandem-cli/Cargo.toml
··· 1 - [package] 2 - name = "tandem-cli" 3 - version.workspace = true 4 - edition.workspace = true 5 - authors.workspace = true 6 - license.workspace = true 7 - repository.workspace = true 8 - 9 - [[bin]] 10 - name = "jjf" 11 - path = "src/main.rs" 12 - 13 - [dependencies] 14 - tandem-core.workspace = true 15 - 16 - clap.workspace = true 17 - tokio.workspace = true 18 - 19 - serde.workspace = true 20 - serde_json.workspace = true 21 - thiserror = "2.0.17" 22 - toml = "0.9.11" 23 - chrono.workspace = true 24 - tokio-tungstenite = "0.28.0" 25 - futures-util = "0.3.31" 26 - url = "2.5.4" 27 - tracing.workspace = true 28 - reqwest = { version = "0.13.1", features = ["rustls-native-certs"] } 29 - whoami = "2.0.2" 30 - jj-lib = "0.37.0" 31 - yrs.workspace = true
-114
crates/tandem-cli/src/alias.rs
··· 1 - use std::process::{Command, ExitStatus, Stdio}; 2 - use std::path::Path; 3 - use crate::repo::JjRepo; 4 - use tandem_core::types::ChangeId; 5 - 6 - /// Run jj command with presence warnings 7 - pub async fn run_with_presence(args: &[String]) -> Result<ExitStatus, std::io::Error> { 8 - let cwd = std::env::current_dir()?; 9 - 10 - // Check if this is a command that should trigger presence checks 11 - let check_presence = should_check_presence(args); 12 - 13 - if check_presence { 14 - if let Some(change_id) = get_target_change(args) { 15 - // Check for conflicts 16 - if let Err(e) = check_and_warn(&cwd, &change_id).await { 17 - tracing::warn!("Presence check failed: {}", e); 18 - // Continue anyway - don't block on presence check failures 19 - } 20 - } 21 - } 22 - 23 - // Run the actual jj command 24 - run_jj(args) 25 - } 26 - 27 - /// Check if this command should trigger a presence check 28 - fn should_check_presence(args: &[String]) -> bool { 29 - if args.is_empty() { 30 - return false; 31 - } 32 - 33 - // Commands that edit a specific change 34 - matches!(args[0].as_str(), "edit" | "checkout" | "co" | "new" | "squash" | "amend") 35 - } 36 - 37 - /// Extract target change ID from command args 38 - fn get_target_change(args: &[String]) -> Option<String> { 39 - if args.len() < 2 { 40 - return None; 41 - } 42 - 43 - match args[0].as_str() { 44 - "edit" | "checkout" | "co" => { 45 - // jj edit <change_id> 46 - Some(args[1].clone()) 47 - } 48 - "new" => { 49 - // jj new <parent> - the parent might have conflicts 50 - Some(args[1].clone()) 51 - } 52 - _ => None, 53 - } 54 - } 55 - 56 - /// Check for presence conflicts and warn user 57 - async fn check_and_warn(repo_path: &Path, change_id_str: &str) -> Result<(), Box<dyn std::error::Error>> { 58 - // Open repo and check forge config 59 - let repo = JjRepo::open(repo_path)?; 60 - let config = repo.forge_config()?; 61 - 62 - if config.is_none() { 63 - // Not linked to forge, no presence to check 64 - return Ok(()); 65 - } 66 - 67 - // TODO: Connect to daemon and check presence 68 - // For now, this is a stub that would integrate with the running daemon 69 - 70 - // Parse change ID 71 - let _change_id: ChangeId = change_id_str.parse() 72 - .map_err(|_| "Invalid change ID")?; 73 - 74 - // In a real implementation: 75 - // 1. Connect to daemon socket 76 - // 2. Query presence for this change 77 - // 3. Show warning if conflicts exist 78 - // 4. Prompt user to continue 79 - 80 - Ok(()) 81 - } 82 - 83 - /// Run jj command directly 84 - fn run_jj(args: &[String]) -> Result<ExitStatus, std::io::Error> { 85 - Command::new("jj") 86 - .args(args) 87 - .stdin(Stdio::inherit()) 88 - .stdout(Stdio::inherit()) 89 - .stderr(Stdio::inherit()) 90 - .status() 91 - } 92 - 93 - /// Wrap jj log to show presence information 94 - pub async fn run_log_with_presence(args: &[String]) -> Result<ExitStatus, std::io::Error> { 95 - // TODO: Intercept jj log output and inject presence information 96 - // For now, just run jj log directly 97 - run_jj(args) 98 - } 99 - 100 - /// Main entry point for alias mode 101 - pub async fn run_alias(args: Vec<String>) -> Result<i32, Box<dyn std::error::Error>> { 102 - if args.is_empty() { 103 - // No args, just run jj 104 - let status = run_jj(&[])?; 105 - return Ok(status.code().unwrap_or(1)); 106 - } 107 - 108 - let status = match args[0].as_str() { 109 - "log" | "l" => run_log_with_presence(&args).await?, 110 - _ => run_with_presence(&args).await?, 111 - }; 112 - 113 - Ok(status.code().unwrap_or(1)) 114 - }
-175
crates/tandem-cli/src/clone.rs
··· 1 - use std::path::{Path, PathBuf}; 2 - use crate::link::LinkError; 3 - use tokio_tungstenite::{connect_async, tungstenite::Message}; 4 - use futures_util::{SinkExt, StreamExt}; 5 - use tandem_core::sync::ForgeDoc; 6 - use yrs::{Transact, StateVector, ReadTxn}; 7 - 8 - #[derive(Debug, thiserror::Error)] 9 - pub enum CloneError { 10 - #[error("Directory already exists: {0}")] 11 - DirectoryExists(PathBuf), 12 - #[error("Failed to create directory: {0}")] 13 - CreateDir(#[from] std::io::Error), 14 - #[error("Failed to initialize jj: {0}")] 15 - JjInit(String), 16 - #[error("Link error: {0}")] 17 - Link(#[from] LinkError), 18 - #[error("Sync error: {0}")] 19 - Sync(String), 20 - #[error("HTTP error: {0}")] 21 - Http(String), 22 - } 23 - 24 - /// Clone a repository from forge 25 - pub async fn clone_repo( 26 - forge_url: &str, 27 - target_dir: Option<&Path>, 28 - token: Option<&str>, 29 - ) -> Result<PathBuf, CloneError> { 30 - // Parse repo name from URL 31 - let repo_name = parse_repo_name(forge_url)?; 32 - 33 - // Determine target directory 34 - let target = match target_dir { 35 - Some(p) => p.to_path_buf(), 36 - None => std::env::current_dir()?.join(&repo_name), 37 - }; 38 - 39 - // Check if directory exists 40 - if target.exists() { 41 - return Err(CloneError::DirectoryExists(target)); 42 - } 43 - 44 - println!("Cloning into '{}'...", target.display()); 45 - 46 - // Create directory 47 - std::fs::create_dir_all(&target)?; 48 - 49 - // Initialize jj repo 50 - init_jj_repo(&target)?; 51 - 52 - // Link to forge 53 - crate::link::link_repo(&target, forge_url, token).await?; 54 - 55 - // Pull initial state 56 - pull_initial_state(&target, forge_url, token).await?; 57 - 58 - println!("✓ Cloned repository to {}", target.display()); 59 - 60 - Ok(target) 61 - } 62 - 63 - /// Parse repository name from forge URL 64 - fn parse_repo_name(url: &str) -> Result<String, CloneError> { 65 - // URL format: https://forge.example.com/org/repo 66 - let url = url.trim_end_matches('/'); 67 - let name = url.rsplit('/').next() 68 - .ok_or_else(|| CloneError::Sync("Invalid URL format".to_string()))?; 69 - Ok(name.to_string()) 70 - } 71 - 72 - /// Initialize a new jj repository 73 - fn init_jj_repo(path: &Path) -> Result<(), CloneError> { 74 - let output = std::process::Command::new("jj") 75 - .arg("init") 76 - .current_dir(path) 77 - .output() 78 - .map_err(|e| CloneError::JjInit(e.to_string()))?; 79 - 80 - if !output.status.success() { 81 - let stderr = String::from_utf8_lossy(&output.stderr); 82 - return Err(CloneError::JjInit(stderr.to_string())); 83 - } 84 - 85 - Ok(()) 86 - } 87 - 88 - /// Pull initial state from forge 89 - async fn pull_initial_state( 90 - path: &Path, 91 - forge_url: &str, 92 - _token: Option<&str>, 93 - ) -> Result<(), CloneError> { 94 - println!(" Syncing initial state..."); 95 - 96 - // Extract repo ID from URL 97 - let repo_id = forge_url 98 - .trim_end_matches('/') 99 - .rsplit('/') 100 - .next() 101 - .ok_or_else(|| CloneError::Sync("Invalid URL".to_string()))?; 102 - 103 - // Build WebSocket URL 104 - let ws_url = format!("{}/sync/{}", 105 - forge_url.replace("https://", "wss://").replace("http://", "ws://"), 106 - repo_id 107 - ); 108 - 109 - // Connect to forge 110 - let (ws_stream, _) = connect_async(&ws_url).await 111 - .map_err(|e| CloneError::Sync(format!("Connection failed: {}", e)))?; 112 - 113 - let (mut write, mut read) = ws_stream.split(); 114 - 115 - // Create empty ForgeDoc 116 - let doc = ForgeDoc::new(); 117 - 118 - // Send empty state vector to get full state 119 - let sv = doc.encode_state_vector(); 120 - write.send(Message::Binary(sv.into())).await 121 - .map_err(|e| CloneError::Sync(format!("Send failed: {}", e)))?; 122 - 123 - // Receive initial state 124 - let mut received_update = false; 125 - let timeout = tokio::time::timeout( 126 - tokio::time::Duration::from_secs(30), 127 - async { 128 - while let Some(msg) = read.next().await { 129 - match msg { 130 - Ok(Message::Binary(data)) => { 131 - doc.apply_update(&data) 132 - .map_err(|e| CloneError::Sync(format!("Apply failed: {:?}", e)))?; 133 - received_update = true; 134 - 135 - // After receiving update, we have the initial state 136 - // In a more sophisticated impl, we'd wait for multiple updates 137 - break; 138 - } 139 - Ok(Message::Close(_)) => break, 140 - Err(e) => return Err(CloneError::Sync(format!("Receive error: {}", e))), 141 - _ => continue, 142 - } 143 - } 144 - Ok::<(), CloneError>(()) 145 - } 146 - ).await; 147 - 148 - match timeout { 149 - Ok(Ok(())) if received_update => { 150 - println!(" ✓ Received {} changes, {} bookmarks", 151 - doc.get_all_change_records().len(), 152 - doc.get_all_bookmarks().len() 153 - ); 154 - } 155 - Ok(Ok(())) => { 156 - println!(" ⚠ No data received (empty repository?)"); 157 - } 158 - Ok(Err(e)) => return Err(e), 159 - Err(_) => { 160 - return Err(CloneError::Sync("Timeout waiting for initial state".to_string())); 161 - } 162 - } 163 - 164 - // Save the ForgeDoc state to a local file for the daemon to use 165 - let doc_path = path.join(".jj").join("forge-doc.bin"); 166 - let txn = doc.doc().transact(); 167 - let state = txn.encode_diff_v1(&StateVector::default()); 168 - std::fs::write(&doc_path, state) 169 - .map_err(|e| CloneError::Sync(format!("Failed to save state: {}", e)))?; 170 - 171 - // Close connection 172 - let _ = write.send(Message::Close(None)).await; 173 - 174 - Ok(()) 175 - }
-166
crates/tandem-cli/src/content.rs
··· 1 - use std::collections::HashSet; 2 - use tandem_core::sync::ForgeDoc; 3 - use tokio::sync::RwLock; 4 - use std::sync::Arc; 5 - 6 - #[derive(Debug, thiserror::Error)] 7 - pub enum ContentError { 8 - #[error("Content not available: {0}")] 9 - NotAvailable(String), 10 - #[error("Fetch failed: {0}")] 11 - FetchFailed(String), 12 - #[error("Network error: {0}")] 13 - Network(String), 14 - } 15 - 16 - /// Manages lazy content fetching 17 - pub struct ContentManager { 18 - doc: Arc<RwLock<ForgeDoc>>, 19 - forge_url: String, 20 - repo_id: String, 21 - /// Hashes we're currently fetching to avoid duplicate requests 22 - pending: RwLock<HashSet<String>>, 23 - } 24 - 25 - impl ContentManager { 26 - pub fn new(doc: Arc<RwLock<ForgeDoc>>, forge_url: String, repo_id: String) -> Self { 27 - Self { 28 - doc, 29 - forge_url, 30 - repo_id, 31 - pending: RwLock::new(HashSet::new()), 32 - } 33 - } 34 - 35 - /// Check if content is available locally 36 - pub async fn has_content(&self, hash: &str) -> bool { 37 - let doc = self.doc.read().await; 38 - doc.has_content(hash) 39 - } 40 - 41 - /// Get content, fetching from forge if needed 42 - pub async fn get_content(&self, hash: &str) -> Result<Vec<u8>, ContentError> { 43 - { 44 - let doc = self.doc.read().await; 45 - if let Some(content) = doc.get_content(hash) { 46 - return Ok(content); 47 - } 48 - } 49 - 50 - self.fetch_content(hash).await 51 - } 52 - 53 - /// Fetch content from forge 54 - async fn fetch_content(&self, hash: &str) -> Result<Vec<u8>, ContentError> { 55 - { 56 - let mut pending = self.pending.write().await; 57 - if pending.contains(hash) { 58 - return Err(ContentError::NotAvailable("Fetch in progress".to_string())); 59 - } 60 - pending.insert(hash.to_string()); 61 - } 62 - 63 - let _cleanup = PendingCleanup { 64 - pending: &self.pending, 65 - hash: hash.to_string(), 66 - }; 67 - 68 - let client = reqwest::Client::new(); 69 - let url = format!("{}/api/repos/{}/content/{}", 70 - self.forge_url, 71 - self.repo_id, 72 - hash 73 - ); 74 - 75 - let response = client.get(&url) 76 - .send() 77 - .await 78 - .map_err(|e| ContentError::Network(e.to_string()))?; 79 - 80 - if !response.status().is_success() { 81 - return Err(ContentError::FetchFailed( 82 - format!("HTTP {}", response.status()) 83 - )); 84 - } 85 - 86 - let content = response.bytes().await 87 - .map_err(|e| ContentError::Network(e.to_string()))?; 88 - 89 - { 90 - let doc = self.doc.read().await; 91 - doc.put_content(hash, content.to_vec()); 92 - } 93 - 94 - Ok(content.to_vec()) 95 - } 96 - 97 - /// Preload content for a set of hashes 98 - pub async fn preload(&self, hashes: &[String]) -> Vec<String> { 99 - let mut failed = Vec::new(); 100 - 101 - for hash in hashes { 102 - if !self.has_content(hash).await { 103 - if self.fetch_content(hash).await.is_err() { 104 - failed.push(hash.clone()); 105 - } 106 - } 107 - } 108 - 109 - failed 110 - } 111 - 112 - /// Preload content for recent changes 113 - pub async fn preload_recent(&self, count_limit: usize) -> Result<usize, ContentError> { 114 - let doc = self.doc.read().await; 115 - let records = doc.get_all_change_records(); 116 - 117 - let mut records: Vec<_> = records.into_iter() 118 - .filter(|r| r.visible) 119 - .collect(); 120 - records.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); 121 - 122 - let hashes: Vec<String> = records 123 - .into_iter() 124 - .take(count_limit) 125 - .map(|r| r.tree.to_string()) 126 - .collect(); 127 - 128 - drop(doc); 129 - 130 - let failed = self.preload(&hashes).await; 131 - Ok(hashes.len() - failed.len()) 132 - } 133 - 134 - /// Preload content for bookmarked changes 135 - pub async fn preload_bookmarks(&self) -> Result<usize, ContentError> { 136 - let doc = self.doc.read().await; 137 - let bookmarks = doc.get_all_bookmarks(); 138 - 139 - let mut hashes = Vec::new(); 140 - for (_name, change_id) in bookmarks { 141 - let records = doc.get_change_records(&change_id); 142 - if let Some(record) = records.first() { 143 - hashes.push(record.tree.to_string()); 144 - } 145 - } 146 - 147 - drop(doc); 148 - 149 - let failed = self.preload(&hashes).await; 150 - Ok(hashes.len() - failed.len()) 151 - } 152 - } 153 - 154 - /// Helper to clean up pending set 155 - struct PendingCleanup<'a> { 156 - pending: &'a RwLock<HashSet<String>>, 157 - hash: String, 158 - } 159 - 160 - impl Drop for PendingCleanup<'_> { 161 - fn drop(&mut self) { 162 - if let Ok(mut pending) = self.pending.try_write() { 163 - pending.remove(&self.hash); 164 - } 165 - } 166 - }
-327
crates/tandem-cli/src/daemon.rs
··· 1 - use std::path::PathBuf; 2 - use std::sync::Arc; 3 - use tokio::sync::{RwLock, mpsc}; 4 - use tandem_core::sync::ForgeDoc; 5 - use crate::presence::PresenceManager; 6 - use crate::content::ContentManager; 7 - use crate::offline::{self, OperationQueue, QueuedOperation}; 8 - use crate::repo::JjRepo; 9 - use tokio_tungstenite::{connect_async, tungstenite::Message}; 10 - use futures_util::{SinkExt, StreamExt}; 11 - 12 - #[derive(Debug, thiserror::Error)] 13 - pub enum DaemonError { 14 - #[error("Not connected to forge")] 15 - NotConnected, 16 - #[error("Connection failed: {0}")] 17 - ConnectionFailed(String), 18 - #[error("Repo error: {0}")] 19 - Repo(#[from] crate::repo::RepoError), 20 - #[error("WebSocket error: {0}")] 21 - WebSocket(String), 22 - #[error("Sync error: {0}")] 23 - Sync(String), 24 - #[error("Offline error: {0}")] 25 - Offline(#[from] crate::offline::OfflineError), 26 - } 27 - 28 - #[derive(Debug)] 29 - pub enum DaemonCommand { 30 - SyncNow, 31 - UpdatePresence { change_id: String }, 32 - Shutdown, 33 - } 34 - 35 - #[derive(Debug, Clone)] 36 - pub enum DaemonEvent { 37 - Connected, 38 - Disconnected, 39 - SyncCompleted, 40 - PresenceWarning { change_id: String, user: String }, 41 - Error(String), 42 - } 43 - 44 - pub struct Daemon { 45 - repo_path: PathBuf, 46 - _forge_url: String, 47 - doc: Arc<RwLock<ForgeDoc>>, 48 - presence_manager: PresenceManager, 49 - content_manager: Option<ContentManager>, 50 - command_rx: mpsc::Receiver<DaemonCommand>, 51 - event_tx: mpsc::Sender<DaemonEvent>, 52 - is_connected: bool, 53 - operation_queue: OperationQueue, 54 - } 55 - 56 - impl Daemon { 57 - pub fn new( 58 - repo_path: PathBuf, 59 - forge_url: String, 60 - command_rx: mpsc::Receiver<DaemonCommand>, 61 - event_tx: mpsc::Sender<DaemonEvent>, 62 - ) -> Self { 63 - let doc = Arc::new(RwLock::new(ForgeDoc::new())); 64 - let user_id = whoami::username().unwrap_or_else(|_| "unknown".to_string()); 65 - let device = whoami::devicename().unwrap_or_else(|_| "unknown".to_string()); 66 - let presence_manager = PresenceManager::new(doc.clone(), user_id, device); 67 - let operation_queue = OperationQueue::load(&repo_path).unwrap_or_default(); 68 - 69 - Self { 70 - repo_path, 71 - _forge_url: forge_url, 72 - doc, 73 - presence_manager, 74 - content_manager: None, 75 - command_rx, 76 - event_tx, 77 - is_connected: false, 78 - operation_queue, 79 - } 80 - } 81 - 82 - pub async fn run(&mut self) -> Result<(), DaemonError> { 83 - // Get repo ID from config 84 - let repo = JjRepo::open(&self.repo_path)?; 85 - let config = repo.forge_config()? 86 - .ok_or_else(|| DaemonError::ConnectionFailed("No forge config".to_string()))?; 87 - 88 - // Extract repo ID from URL (last path segment) 89 - let repo_id = config.forge.url 90 - .rsplit('/') 91 - .next() 92 - .unwrap_or("unknown") 93 - .to_string(); 94 - 95 - // Initialize content manager with repo_id 96 - self.content_manager = Some(ContentManager::new( 97 - self.doc.clone(), 98 - config.forge.url.clone(), 99 - repo_id.clone() 100 - )); 101 - 102 - // Build WebSocket URL 103 - let ws_url = format!("{}/sync/{}", 104 - config.forge.url.replace("https://", "wss://").replace("http://", "ws://"), 105 - repo_id 106 - ); 107 - 108 - tracing::info!("Connecting to forge: {}", ws_url); 109 - 110 - self.preload_content_on_startup().await; 111 - 112 - // Connect with retry loop 113 - loop { 114 - match self.connect_and_sync(&ws_url).await { 115 - Ok(()) => { 116 - tracing::info!("Disconnected from forge, reconnecting..."); 117 - } 118 - Err(e) => { 119 - tracing::error!("Connection error: {}, retrying in 5s...", e); 120 - let _ = self.on_disconnected().await; 121 - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; 122 - } 123 - } 124 - 125 - // Check for shutdown 126 - if let Ok(cmd) = self.command_rx.try_recv() { 127 - if matches!(cmd, DaemonCommand::Shutdown) { 128 - tracing::info!("Daemon shutting down"); 129 - self.presence_manager.clear_presence().await; 130 - break; 131 - } 132 - } 133 - } 134 - 135 - Ok(()) 136 - } 137 - 138 - pub fn is_connected(&self) -> bool { 139 - self.is_connected 140 - } 141 - 142 - async fn connect_and_sync(&mut self, ws_url: &str) -> Result<(), DaemonError> { 143 - let (ws_stream, _) = connect_async(ws_url).await 144 - .map_err(|e| DaemonError::ConnectionFailed(e.to_string()))?; 145 - 146 - let (mut write, mut read) = ws_stream.split(); 147 - 148 - // Mark as connected 149 - self.on_connected().await?; 150 - 151 - // Send our state vector to request initial sync 152 - { 153 - let doc = self.doc.read().await; 154 - let sv = doc.encode_state_vector(); 155 - write.send(Message::Binary(sv.into())).await 156 - .map_err(|e| DaemonError::WebSocket(e.to_string()))?; 157 - } 158 - 159 - loop { 160 - tokio::select! { 161 - // Handle commands from the application 162 - Some(cmd) = self.command_rx.recv() => { 163 - match cmd { 164 - DaemonCommand::Shutdown => { 165 - tracing::info!("Daemon shutting down"); 166 - return Ok(()); 167 - } 168 - DaemonCommand::SyncNow => { 169 - // Send current state 170 - let doc = self.doc.read().await; 171 - let sv = doc.encode_state_vector(); 172 - drop(doc); 173 - write.send(Message::Binary(sv.into())).await 174 - .map_err(|e| DaemonError::WebSocket(e.to_string()))?; 175 - } 176 - DaemonCommand::UpdatePresence { change_id } => { 177 - // Update presence in local doc 178 - if let Ok(cid) = change_id.parse() { 179 - self.presence_manager.update_presence(&cid).await; 180 - 181 - let conflicts = self.presence_manager.check_conflict(&cid).await; 182 - if !conflicts.is_empty() { 183 - for conflict in conflicts { 184 - let _ = self.event_tx.send(DaemonEvent::PresenceWarning { 185 - change_id: cid.to_string(), 186 - user: format!("{}@{}", conflict.user_id, conflict.device), 187 - }).await; 188 - } 189 - } 190 - } 191 - } 192 - } 193 - } 194 - 195 - // Handle messages from the server 196 - Some(msg) = read.next() => { 197 - match msg { 198 - Ok(Message::Binary(data)) => { 199 - // Apply update from server 200 - let doc = self.doc.read().await; 201 - if let Err(e) = doc.apply_update(&data) { 202 - tracing::warn!("Failed to apply update: {:?}", e); 203 - } 204 - } 205 - Ok(Message::Close(_)) => { 206 - tracing::info!("Server closed connection"); 207 - return Ok(()); 208 - } 209 - Ok(Message::Ping(data)) => { 210 - write.send(Message::Pong(data)).await 211 - .map_err(|e| DaemonError::WebSocket(e.to_string()))?; 212 - } 213 - Err(e) => { 214 - tracing::error!("WebSocket error: {}", e); 215 - return Err(DaemonError::WebSocket(e.to_string())); 216 - } 217 - _ => {} 218 - } 219 - } 220 - } 221 - } 222 - } 223 - 224 - /// Handle connection established 225 - async fn on_connected(&mut self) -> Result<(), DaemonError> { 226 - tracing::info!("Connected to forge"); 227 - self.is_connected = true; 228 - offline::set_offline(&self.repo_path, false)?; 229 - 230 - let _ = self.event_tx.send(DaemonEvent::Connected).await; 231 - 232 - // Replay any queued operations 233 - self.replay_queued_operations().await?; 234 - 235 - Ok(()) 236 - } 237 - 238 - /// Handle connection lost 239 - async fn on_disconnected(&mut self) -> Result<(), DaemonError> { 240 - tracing::warn!("Disconnected from forge"); 241 - self.is_connected = false; 242 - offline::set_offline(&self.repo_path, true)?; 243 - 244 - let _ = self.event_tx.send(DaemonEvent::Disconnected).await; 245 - 246 - Ok(()) 247 - } 248 - 249 - /// Queue an operation for offline replay 250 - fn queue_operation(&mut self, op: QueuedOperation) -> Result<(), DaemonError> { 251 - self.operation_queue.enqueue(op); 252 - self.operation_queue.save(&self.repo_path)?; 253 - Ok(()) 254 - } 255 - 256 - /// Replay all queued operations to the forge 257 - async fn replay_queued_operations(&mut self) -> Result<(), DaemonError> { 258 - if self.operation_queue.is_empty() { 259 - return Ok(()); 260 - } 261 - 262 - tracing::info!("Replaying {} queued operations", self.operation_queue.len()); 263 - 264 - let doc = self.doc.read().await; 265 - let count = offline::replay_queue(&self.repo_path, &doc).await?; 266 - drop(doc); 267 - 268 - // Reload queue (should be empty now) 269 - self.operation_queue = OperationQueue::load(&self.repo_path)?; 270 - 271 - tracing::info!("Replayed {} operations", count); 272 - Ok(()) 273 - } 274 - 275 - async fn preload_content_on_startup(&self) { 276 - if let Some(content_manager) = &self.content_manager { 277 - if let Ok(count_loaded) = content_manager.preload_bookmarks().await { 278 - tracing::info!("Preloaded {} bookmarked changes", count_loaded); 279 - } 280 - 281 - if let Ok(count_loaded) = content_manager.preload_recent(10).await { 282 - tracing::info!("Preloaded {} recent changes", count_loaded); 283 - } 284 - } 285 - } 286 - } 287 - 288 - #[derive(Clone)] 289 - pub struct DaemonHandle { 290 - command_tx: mpsc::Sender<DaemonCommand>, 291 - _event_rx: Arc<RwLock<mpsc::Receiver<DaemonEvent>>>, 292 - } 293 - 294 - impl DaemonHandle { 295 - pub async fn sync_now(&self) -> Result<(), DaemonError> { 296 - self.command_tx.send(DaemonCommand::SyncNow).await 297 - .map_err(|_| DaemonError::NotConnected) 298 - } 299 - 300 - pub async fn update_presence(&self, change_id: String) -> Result<(), DaemonError> { 301 - self.command_tx.send(DaemonCommand::UpdatePresence { change_id }).await 302 - .map_err(|_| DaemonError::NotConnected) 303 - } 304 - 305 - pub async fn shutdown(&self) -> Result<(), DaemonError> { 306 - self.command_tx.send(DaemonCommand::Shutdown).await 307 - .map_err(|_| DaemonError::NotConnected) 308 - } 309 - } 310 - 311 - pub fn spawn_daemon(repo_path: PathBuf, forge_url: String) -> DaemonHandle { 312 - let (command_tx, command_rx) = mpsc::channel(32); 313 - let (event_tx, event_rx) = mpsc::channel(32); 314 - 315 - let mut daemon = Daemon::new(repo_path, forge_url, command_rx, event_tx); 316 - 317 - tokio::spawn(async move { 318 - if let Err(e) = daemon.run().await { 319 - tracing::error!("Daemon error: {}", e); 320 - } 321 - }); 322 - 323 - DaemonHandle { 324 - command_tx, 325 - _event_rx: Arc::new(RwLock::new(event_rx)), 326 - } 327 - }
-12
crates/tandem-cli/src/lib.rs
··· 1 - //! Tandem CLI library 2 - //! 3 - //! This module provides the core functionality for the Tandem CLI 4 - 5 - pub mod repo; 6 - pub mod daemon; 7 - pub mod presence; 8 - pub mod link; 9 - pub mod clone; 10 - pub mod offline; 11 - pub mod alias; 12 - pub mod content;
-79
crates/tandem-cli/src/link.rs
··· 1 - use std::path::Path; 2 - use crate::repo::{JjRepo, ForgeConfig, ForgeSettings, RepoError}; 3 - 4 - #[derive(Debug, thiserror::Error)] 5 - pub enum LinkError { 6 - #[error("Repository error: {0}")] 7 - Repo(#[from] RepoError), 8 - #[error("Already linked to forge: {0}")] 9 - AlreadyLinked(String), 10 - #[error("Forge unreachable: {0}")] 11 - Unreachable(String), 12 - #[error("Authentication failed")] 13 - AuthFailed, 14 - #[error("HTTP error: {0}")] 15 - Http(String), 16 - } 17 - 18 - pub async fn link_repo( 19 - repo_path: &Path, 20 - forge_url: &str, 21 - token: Option<&str>, 22 - ) -> Result<(), LinkError> { 23 - let repo = JjRepo::open(repo_path)?; 24 - 25 - if let Some(existing) = repo.forge_config()? { 26 - return Err(LinkError::AlreadyLinked(existing.forge.url)); 27 - } 28 - 29 - let url = normalize_forge_url(forge_url); 30 - 31 - test_forge_connection(&url, token).await?; 32 - 33 - let config = ForgeConfig { 34 - forge: ForgeSettings { 35 - url: url.clone(), 36 - }, 37 - }; 38 - repo.set_forge_config(&config)?; 39 - 40 - if token.is_some() { 41 - println!("Token provided - in production, this would be stored in system keychain"); 42 - } 43 - 44 - println!("✓ Linked to forge: {}", url); 45 - println!(" Run 'jjf daemon start' to begin syncing"); 46 - 47 - Ok(()) 48 - } 49 - 50 - fn normalize_forge_url(url: &str) -> String { 51 - let mut url = url.to_string(); 52 - 53 - if !url.starts_with("http://") && !url.starts_with("https://") { 54 - url = format!("https://{}", url); 55 - } 56 - 57 - url.trim_end_matches('/').to_string() 58 - } 59 - 60 - async fn test_forge_connection(url: &str, token: Option<&str>) -> Result<(), LinkError> { 61 - let client = reqwest::Client::new(); 62 - 63 - let mut req = client.get(format!("{}/health", url)); 64 - if let Some(token) = token { 65 - req = req.header("Authorization", format!("Bearer {}", token)); 66 - } 67 - 68 - let response = req.send().await 69 - .map_err(|e| LinkError::Unreachable(e.to_string()))?; 70 - 71 - if !response.status().is_success() { 72 - if response.status().as_u16() == 401 { 73 - return Err(LinkError::AuthFailed); 74 - } 75 - return Err(LinkError::Http(format!("Status: {}", response.status()))); 76 - } 77 - 78 - Ok(()) 79 - }
-238
crates/tandem-cli/src/main.rs
··· 1 - //! Tandem CLI (jjf) 2 - //! 3 - //! Command-line interface for the Tandem Forge 4 - 5 - use clap::{Parser, Subcommand}; 6 - use tandem_cli::repo::{JjRepo, ForgeConfig, ForgeSettings}; 7 - use std::env; 8 - use std::path::PathBuf; 9 - 10 - #[derive(Parser)] 11 - #[command(name = "jjf")] 12 - #[command(about = "Jujutsu Forge CLI - Manage code reviews and changes", long_about = None)] 13 - struct Cli { 14 - #[command(subcommand)] 15 - command: Commands, 16 - } 17 - 18 - #[derive(Subcommand)] 19 - enum Commands { 20 - /// Initialize a new repository 21 - Init { 22 - /// Repository name 23 - #[arg(short, long)] 24 - name: String, 25 - }, 26 - /// List all changes 27 - List, 28 - /// Show status 29 - Status, 30 - /// Link this repository to a forge 31 - Link { 32 - /// Forge URL (e.g., https://forge.example.com/org/repo) 33 - url: String, 34 - 35 - /// Auth token (if not provided, will prompt or use keychain) 36 - #[arg(long)] 37 - token: Option<String>, 38 - }, 39 - /// Clone a repository from a forge 40 - Clone { 41 - /// Forge URL (e.g., https://forge.example.com/org/repo) 42 - url: String, 43 - 44 - /// Target directory (defaults to repo name) 45 - #[arg(short, long)] 46 - directory: Option<PathBuf>, 47 - 48 - /// Auth token 49 - #[arg(long)] 50 - token: Option<String>, 51 - }, 52 - /// Daemon management 53 - Daemon { 54 - #[command(subcommand)] 55 - action: DaemonAction, 56 - }, 57 - /// Wrapper for jj with presence warnings (use as: alias jj='jjf alias') 58 - Alias { 59 - /// Arguments to pass to jj 60 - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] 61 - args: Vec<String>, 62 - }, 63 - } 64 - 65 - #[derive(Subcommand)] 66 - enum DaemonAction { 67 - /// Start daemon in foreground 68 - Start, 69 - /// Check daemon status 70 - Status, 71 - } 72 - 73 - #[tokio::main] 74 - async fn main() { 75 - let cli = Cli::parse(); 76 - 77 - match cli.command { 78 - Commands::Alias { args } => { 79 - use tandem_cli::alias; 80 - match alias::run_alias(args).await { 81 - Ok(code) => std::process::exit(code), 82 - Err(e) => { 83 - eprintln!("Error: {}", e); 84 - std::process::exit(1); 85 - } 86 - } 87 - } 88 - _ => { 89 - let result = match cli.command { 90 - Commands::Init { name } => handle_init(&name), 91 - Commands::List => handle_list(), 92 - Commands::Status => handle_status(), 93 - Commands::Link { url, token } => handle_link(&url, token.as_deref()).await, 94 - Commands::Clone { url, directory, token } => handle_clone(&url, directory.as_deref(), token.as_deref()).await, 95 - Commands::Daemon { action } => handle_daemon(action).await, 96 - Commands::Alias { .. } => unreachable!(), 97 - }; 98 - 99 - if let Err(e) = result { 100 - eprintln!("Error: {}", e); 101 - std::process::exit(1); 102 - } 103 - } 104 - } 105 - } 106 - 107 - fn handle_init(name: &str) -> Result<(), Box<dyn std::error::Error>> { 108 - let current_dir = env::current_dir()?; 109 - 110 - // Check if .jj directory exists 111 - let jj_dir = current_dir.join(".jj"); 112 - if !jj_dir.exists() { 113 - return Err("Not a jj repository. Run 'jj init' or 'jj git clone' first.".into()); 114 - } 115 - 116 - let repo = JjRepo::open(&current_dir)?; 117 - 118 - // Check if forge is already configured 119 - if let Some(existing_config) = repo.forge_config()? { 120 - println!("Forge already configured: {}", existing_config.forge.url); 121 - return Ok(()); 122 - } 123 - 124 - // Create forge configuration 125 - let config = ForgeConfig { 126 - forge: ForgeSettings { 127 - url: format!("https://forge.example.com/{}", name), 128 - }, 129 - }; 130 - 131 - repo.set_forge_config(&config)?; 132 - println!("Initialized forge configuration for repository: {}", name); 133 - println!("Forge URL: {}", config.forge.url); 134 - 135 - Ok(()) 136 - } 137 - 138 - fn handle_list() -> Result<(), Box<dyn std::error::Error>> { 139 - let current_dir = env::current_dir()?; 140 - let repo = JjRepo::open(&current_dir)?; 141 - 142 - let changes = repo.list_changes()?; 143 - 144 - if changes.is_empty() { 145 - println!("No changes found."); 146 - } else { 147 - println!("Changes:"); 148 - for change in changes { 149 - println!(" {} - {}", change.id, change.description); 150 - } 151 - } 152 - 153 - Ok(()) 154 - } 155 - 156 - fn handle_status() -> Result<(), Box<dyn std::error::Error>> { 157 - let current_dir = env::current_dir()?; 158 - 159 - // Check if we're in a jj repository 160 - let jj_dir = current_dir.join(".jj"); 161 - if !jj_dir.exists() { 162 - println!("Not a jj repository"); 163 - return Ok(()); 164 - } 165 - 166 - let repo = JjRepo::open(&current_dir)?; 167 - 168 - // Check forge configuration 169 - match repo.forge_config()? { 170 - Some(config) => { 171 - println!("Repository: {}", repo.path().display()); 172 - println!("Forge URL: {}", config.forge.url); 173 - println!("Status: Connected"); 174 - } 175 - None => { 176 - println!("Repository: {}", repo.path().display()); 177 - println!("Forge: Not configured"); 178 - println!("Run 'jjf init --name <repo-name>' to configure"); 179 - } 180 - } 181 - 182 - Ok(()) 183 - } 184 - 185 - async fn handle_link(url: &str, token: Option<&str>) -> Result<(), Box<dyn std::error::Error>> { 186 - use tandem_cli::link; 187 - 188 - let cwd = env::current_dir()?; 189 - link::link_repo(&cwd, url, token).await?; 190 - Ok(()) 191 - } 192 - 193 - async fn handle_clone( 194 - url: &str, 195 - directory: Option<&std::path::Path>, 196 - token: Option<&str>, 197 - ) -> Result<(), Box<dyn std::error::Error>> { 198 - use tandem_cli::clone; 199 - 200 - clone::clone_repo(url, directory, token).await?; 201 - Ok(()) 202 - } 203 - 204 - async fn handle_daemon(action: DaemonAction) -> Result<(), Box<dyn std::error::Error>> { 205 - use tandem_cli::daemon; 206 - 207 - let current_dir = env::current_dir()?; 208 - let repo = JjRepo::open(&current_dir)?; 209 - 210 - let config = repo.forge_config()? 211 - .ok_or("Forge not configured. Run 'jjf init --name <repo-name>' first.")?; 212 - 213 - match action { 214 - DaemonAction::Start => { 215 - println!("Starting daemon..."); 216 - println!("Repo: {}", repo.path().display()); 217 - println!("Forge: {}", config.forge.url); 218 - 219 - let handle = daemon::spawn_daemon( 220 - repo.path().to_path_buf(), 221 - config.forge.url.clone() 222 - ); 223 - 224 - println!("Daemon started. Press Ctrl+C to stop."); 225 - 226 - tokio::signal::ctrl_c().await?; 227 - 228 - println!("\nShutting down daemon..."); 229 - handle.shutdown().await?; 230 - println!("Daemon stopped."); 231 - } 232 - DaemonAction::Status => { 233 - println!("Daemon status: Not implemented yet"); 234 - } 235 - } 236 - 237 - Ok(()) 238 - }
-161
crates/tandem-cli/src/offline.rs
··· 1 - use std::path::{Path, PathBuf}; 2 - use serde::{Serialize, Deserialize}; 3 - use chrono::{DateTime, Utc}; 4 - use tandem_core::types::{ChangeRecord, ChangeId}; 5 - 6 - #[derive(Debug, thiserror::Error)] 7 - pub enum OfflineError { 8 - #[error("IO error: {0}")] 9 - Io(#[from] std::io::Error), 10 - #[error("Serialization error: {0}")] 11 - Serialization(#[from] serde_json::Error), 12 - } 13 - 14 - /// Types of operations that can be queued offline 15 - #[derive(Debug, Clone, Serialize, Deserialize)] 16 - #[serde(tag = "type", rename_all = "snake_case")] 17 - pub enum QueuedOperation { 18 - /// A change was created or modified 19 - ChangeUpdated { 20 - record: ChangeRecord, 21 - timestamp: DateTime<Utc>, 22 - }, 23 - /// A bookmark was moved 24 - BookmarkMoved { 25 - name: String, 26 - target: ChangeId, 27 - timestamp: DateTime<Utc>, 28 - }, 29 - /// Presence was updated 30 - PresenceUpdated { 31 - change_id: ChangeId, 32 - timestamp: DateTime<Utc>, 33 - }, 34 - } 35 - 36 - /// Queue for offline operations 37 - #[derive(Debug, Default, Serialize, Deserialize)] 38 - pub struct OperationQueue { 39 - operations: Vec<QueuedOperation>, 40 - } 41 - 42 - impl OperationQueue { 43 - pub fn new() -> Self { 44 - Self::default() 45 - } 46 - 47 - /// Load queue from disk 48 - pub fn load(repo_path: &Path) -> Result<Self, OfflineError> { 49 - let queue_path = Self::queue_path(repo_path); 50 - 51 - if !queue_path.exists() { 52 - return Ok(Self::new()); 53 - } 54 - 55 - let content = std::fs::read_to_string(&queue_path)?; 56 - let queue: Self = serde_json::from_str(&content)?; 57 - Ok(queue) 58 - } 59 - 60 - /// Save queue to disk 61 - pub fn save(&self, repo_path: &Path) -> Result<(), OfflineError> { 62 - let queue_path = Self::queue_path(repo_path); 63 - 64 - // Create parent directory if needed 65 - if let Some(parent) = queue_path.parent() { 66 - std::fs::create_dir_all(parent)?; 67 - } 68 - 69 - let content = serde_json::to_string_pretty(self)?; 70 - std::fs::write(&queue_path, content)?; 71 - Ok(()) 72 - } 73 - 74 - /// Add operation to queue 75 - pub fn enqueue(&mut self, op: QueuedOperation) { 76 - self.operations.push(op); 77 - } 78 - 79 - /// Get number of queued operations 80 - pub fn len(&self) -> usize { 81 - self.operations.len() 82 - } 83 - 84 - /// Check if queue is empty 85 - pub fn is_empty(&self) -> bool { 86 - self.operations.is_empty() 87 - } 88 - 89 - /// Take all operations (clears queue) 90 - pub fn drain(&mut self) -> Vec<QueuedOperation> { 91 - std::mem::take(&mut self.operations) 92 - } 93 - 94 - /// Clear the queue and delete the file 95 - pub fn clear(&mut self, repo_path: &Path) -> Result<(), OfflineError> { 96 - self.operations.clear(); 97 - let queue_path = Self::queue_path(repo_path); 98 - if queue_path.exists() { 99 - std::fs::remove_file(&queue_path)?; 100 - } 101 - Ok(()) 102 - } 103 - 104 - fn queue_path(repo_path: &Path) -> PathBuf { 105 - repo_path.join(".jj").join("forge-queue.json") 106 - } 107 - } 108 - 109 - /// Replay queued operations to forge 110 - pub async fn replay_queue( 111 - repo_path: &Path, 112 - doc: &tandem_core::sync::ForgeDoc, 113 - ) -> Result<usize, OfflineError> { 114 - let mut queue = OperationQueue::load(repo_path)?; 115 - 116 - if queue.is_empty() { 117 - return Ok(0); 118 - } 119 - 120 - let operations = queue.drain(); 121 - let count = operations.len(); 122 - 123 - for op in operations { 124 - match op { 125 - QueuedOperation::ChangeUpdated { record, .. } => { 126 - doc.insert_change(&record); 127 - } 128 - QueuedOperation::BookmarkMoved { name, target, .. } => { 129 - doc.set_bookmark(&name, &target); 130 - } 131 - QueuedOperation::PresenceUpdated { .. } => { 132 - // Presence updates are ephemeral, skip old ones 133 - } 134 - } 135 - } 136 - 137 - // Clear the queue file 138 - queue.clear(repo_path)?; 139 - 140 - Ok(count) 141 - } 142 - 143 - /// Check if we're in offline mode (no connection to forge) 144 - pub fn is_offline(repo_path: &Path) -> bool { 145 - // Check for offline marker file 146 - let marker = repo_path.join(".jj").join("forge-offline"); 147 - marker.exists() 148 - } 149 - 150 - /// Set offline mode 151 - pub fn set_offline(repo_path: &Path, offline: bool) -> Result<(), OfflineError> { 152 - let marker = repo_path.join(".jj").join("forge-offline"); 153 - 154 - if offline { 155 - std::fs::write(&marker, "")?; 156 - } else if marker.exists() { 157 - std::fs::remove_file(&marker)?; 158 - } 159 - 160 - Ok(()) 161 - }
-113
crates/tandem-cli/src/presence.rs
··· 1 - use tandem_core::types::{ChangeId, PresenceInfo}; 2 - use tandem_core::sync::ForgeDoc; 3 - use chrono::{Utc, Duration}; 4 - use std::sync::Arc; 5 - use tokio::sync::RwLock; 6 - 7 - /// Presence manager for tracking who's editing what 8 - pub struct PresenceManager { 9 - doc: Arc<RwLock<ForgeDoc>>, 10 - user_id: String, 11 - device: String, 12 - } 13 - 14 - impl PresenceManager { 15 - pub fn new(doc: Arc<RwLock<ForgeDoc>>, user_id: String, device: String) -> Self { 16 - Self { doc, user_id, device } 17 - } 18 - 19 - /// Update our presence (call when user edits a change) 20 - pub async fn update_presence(&self, change_id: &ChangeId) { 21 - let info = PresenceInfo { 22 - user_id: self.user_id.clone(), 23 - change_id: *change_id, 24 - device: self.device.clone(), 25 - timestamp: Utc::now(), 26 - }; 27 - 28 - let doc = self.doc.read().await; 29 - doc.update_presence(&info); 30 - } 31 - 32 - /// Clear our presence (call when leaving a change) 33 - pub async fn clear_presence(&self) { 34 - let doc = self.doc.read().await; 35 - doc.remove_presence(&self.user_id); 36 - } 37 - 38 - /// Get all active presence (excluding stale entries > 5 min) 39 - pub async fn get_active_presence(&self) -> Vec<PresenceInfo> { 40 - let doc = self.doc.read().await; 41 - let all = doc.get_all_presence(); 42 - let cutoff = Utc::now() - Duration::minutes(5); 43 - 44 - all.into_iter() 45 - .filter(|p| p.timestamp > cutoff) 46 - .collect() 47 - } 48 - 49 - /// Check if anyone else is editing this change 50 - pub async fn check_conflict(&self, change_id: &ChangeId) -> Vec<PresenceInfo> { 51 - self.get_active_presence().await 52 - .into_iter() 53 - .filter(|p| &p.change_id == change_id && p.user_id != self.user_id) 54 - .collect() 55 - } 56 - } 57 - 58 - /// Format presence warning message 59 - pub fn format_presence_warning(conflicts: &[PresenceInfo]) -> String { 60 - if conflicts.is_empty() { 61 - return String::new(); 62 - } 63 - 64 - if conflicts.len() == 1 { 65 - let p = &conflicts[0]; 66 - format!( 67 - "⚠ This change is currently being edited by {}@{}", 68 - p.user_id, p.device 69 - ) 70 - } else { 71 - let users: Vec<String> = conflicts 72 - .iter() 73 - .map(|p| format!("{}@{}", p.user_id, p.device)) 74 - .collect(); 75 - format!( 76 - "⚠ This change is currently being edited by: {}", 77 - users.join(", ") 78 - ) 79 - } 80 - } 81 - 82 - /// Format presence info for jj log output 83 - pub fn format_log_presence(presences: &[PresenceInfo], change_id: &ChangeId) -> Option<String> { 84 - let editing: Vec<&PresenceInfo> = presences 85 - .iter() 86 - .filter(|p| &p.change_id == change_id) 87 - .collect(); 88 - 89 - if editing.is_empty() { 90 - return None; 91 - } 92 - 93 - if editing.len() == 1 { 94 - Some(format!("({} editing)", editing[0].user_id)) 95 - } else { 96 - let count = editing.len(); 97 - Some(format!("({} users editing)", count)) 98 - } 99 - } 100 - 101 - /// Prompt user to continue when there's a conflict 102 - pub fn prompt_continue(warning: &str) -> bool { 103 - use std::io::{self, Write}; 104 - 105 - println!("{}", warning); 106 - print!("Continue anyway? [y/N] "); 107 - io::stdout().flush().unwrap(); 108 - 109 - let mut input = String::new(); 110 - io::stdin().read_line(&mut input).unwrap(); 111 - 112 - matches!(input.trim().to_lowercase().as_str(), "y" | "yes") 113 - }
-259
crates/tandem-cli/src/repo.rs
··· 1 - use std::path::{Path, PathBuf}; 2 - use std::collections::HashMap; 3 - use tandem_core::types::{Change, ChangeId, Identity, TreeHash}; 4 - use jj_lib::workspace::Workspace; 5 - use jj_lib::settings::UserSettings; 6 - use jj_lib::repo::{StoreFactories, Repo}; 7 - use jj_lib::revset::RevsetExpression; 8 - use jj_lib::object_id::ObjectId; 9 - use chrono::{DateTime, Utc}; 10 - 11 - /// Error type for repo operations 12 - #[derive(Debug, thiserror::Error)] 13 - pub enum RepoError { 14 - #[error("Repository not found at {0}")] 15 - NotFound(PathBuf), 16 - #[error("Not a jj repository")] 17 - NotJjRepo, 18 - #[error("IO error: {0}")] 19 - Io(#[from] std::io::Error), 20 - #[error("Internal error: {0}")] 21 - Internal(String), 22 - } 23 - 24 - /// Wrapper around jj repository 25 - pub struct JjRepo { 26 - path: PathBuf, 27 - workspace: Workspace, 28 - } 29 - 30 - impl JjRepo { 31 - /// Open a jj repository at the given path 32 - pub fn open(path: impl AsRef<Path>) -> Result<Self, RepoError> { 33 - let path = path.as_ref().to_path_buf(); 34 - 35 - // Check for .jj directory 36 - let jj_dir = path.join(".jj"); 37 - if !jj_dir.exists() { 38 - return Err(RepoError::NotJjRepo); 39 - } 40 - 41 - // Create default user settings (required by jj-lib) 42 - let config = jj_lib::config::StackedConfig::empty(); 43 - let settings = UserSettings::from_config(config) 44 - .map_err(|e| RepoError::Internal(format!("Failed to create settings: {}", e)))?; 45 - 46 - // Create store factories for loading the repository 47 - let store_factories = StoreFactories::default(); 48 - 49 - // Empty working copy factories map (use defaults) 50 - let wc_factories = HashMap::new(); 51 - 52 - // Load the workspace 53 - let workspace = Workspace::load(&settings, &path, &store_factories, &wc_factories) 54 - .map_err(|e| RepoError::Internal(format!("Failed to load workspace: {}", e)))?; 55 - 56 - Ok(Self { path, workspace }) 57 - } 58 - 59 - /// Get repository root path 60 - pub fn path(&self) -> &Path { 61 - &self.path 62 - } 63 - 64 - /// List all visible changes in the repository 65 - pub fn list_changes(&self) -> Result<Vec<Change>, RepoError> { 66 - let repo_loader = self.workspace.repo_loader(); 67 - let repo = repo_loader 68 - .load_at_head() 69 - .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?; 70 - 71 - // Get all visible commits (equivalent to "jj log") 72 - // Use revset to get all commits 73 - let revset_expression = RevsetExpression::all(); 74 - let evaluated = revset_expression 75 - .evaluate(repo.as_ref()) 76 - .map_err(|e| RepoError::Internal(format!("Failed to evaluate revset: {}", e)))?; 77 - 78 - let mut changes = Vec::new(); 79 - for commit_id_result in evaluated.iter() { 80 - let commit_id = commit_id_result 81 - .map_err(|e| RepoError::Internal(format!("Failed to iterate commits: {}", e)))?; 82 - let commit = repo 83 - .store() 84 - .get_commit(&commit_id) 85 - .map_err(|e| RepoError::Internal(format!("Failed to get commit: {}", e)))?; 86 - 87 - changes.push(Self::convert_commit_to_change(&commit, repo.as_ref())?); 88 - } 89 - 90 - Ok(changes) 91 - } 92 - 93 - /// Convert jj-lib Commit to our Change type 94 - fn convert_commit_to_change( 95 - commit: &jj_lib::commit::Commit, 96 - repo: &jj_lib::repo::ReadonlyRepo, 97 - ) -> Result<Change, RepoError> { 98 - // Convert change_id (jj's stable ID) from bytes 99 - let change_id_bytes = commit.change_id().as_bytes(); 100 - if change_id_bytes.len() != 32 { 101 - return Err(RepoError::Internal(format!( 102 - "Invalid change_id length: expected 32, got {}", 103 - change_id_bytes.len() 104 - ))); 105 - } 106 - let mut change_id = [0u8; 32]; 107 - change_id.copy_from_slice(change_id_bytes); 108 - 109 - // Convert tree_id from jj to our TreeHash 110 - // For now, we'll use a simplified hash of the tree content 111 - // In jj 0.37, MergedTree::resolve() is async and complex to use here 112 - // We'll extract tree_id from the underlying backend commit via the store 113 - // FIXME: Implement proper tree ID extraction - for now use a placeholder based on change_id 114 - // This is a temporary workaround until we properly handle async tree resolution 115 - let mut tree_hash = [0u8; 20]; 116 - // Use first 20 bytes of change_id as a temporary tree hash placeholder 117 - tree_hash.copy_from_slice(&change_id[..20]); 118 - 119 - // Convert parent change_ids 120 - let parents = commit 121 - .parent_ids() 122 - .iter() 123 - .filter_map(|parent_commit_id| { 124 - // Look up parent commit to get its change_id 125 - repo.store() 126 - .get_commit(parent_commit_id) 127 - .ok() 128 - .and_then(|parent_commit| { 129 - let parent_change_id_bytes = parent_commit.change_id().as_bytes(); 130 - if parent_change_id_bytes.len() == 32 { 131 - let mut parent_change_id = [0u8; 32]; 132 - parent_change_id.copy_from_slice(parent_change_id_bytes); 133 - Some(ChangeId(parent_change_id)) 134 - } else { 135 - None 136 - } 137 - }) 138 - }) 139 - .collect(); 140 - 141 - let author = Identity { 142 - name: Some(commit.author().name.clone()), 143 - email: commit.author().email.clone(), 144 - }; 145 - 146 - // Convert timestamp from jj's Timestamp to chrono DateTime 147 - let timestamp_millis = commit.author().timestamp.timestamp.0; 148 - let timestamp = DateTime::from_timestamp(timestamp_millis / 1000, 0) 149 - .unwrap_or_else(|| Utc::now()); 150 - 151 - Ok(Change { 152 - id: ChangeId(change_id), 153 - tree: TreeHash(tree_hash), 154 - parents, 155 - description: commit.description().to_string(), 156 - author, 157 - timestamp, 158 - }) 159 - } 160 - 161 - /// Get a specific change by ID 162 - pub fn get_change(&self, id: &ChangeId) -> Result<Option<Change>, RepoError> { 163 - let repo_loader = self.workspace.repo_loader(); 164 - let repo = repo_loader 165 - .load_at_head() 166 - .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?; 167 - 168 - // In jj, we need to find the commit with this change_id 169 - // We'll search through all commits to find one with matching change_id 170 - let revset_expression = RevsetExpression::all(); 171 - let evaluated = revset_expression 172 - .evaluate(repo.as_ref()) 173 - .map_err(|e| RepoError::Internal(format!("Failed to evaluate revset: {}", e)))?; 174 - 175 - for commit_id_result in evaluated.iter() { 176 - let commit_id = commit_id_result 177 - .map_err(|e| RepoError::Internal(format!("Failed to iterate commits: {}", e)))?; 178 - let commit = repo 179 - .store() 180 - .get_commit(&commit_id) 181 - .map_err(|e| RepoError::Internal(format!("Failed to get commit: {}", e)))?; 182 - 183 - // Check if this commit's change_id matches 184 - let commit_change_id_bytes = commit.change_id().as_bytes(); 185 - if commit_change_id_bytes == id.0.as_slice() { 186 - return Ok(Some(Self::convert_commit_to_change(&commit, repo.as_ref())?)); 187 - } 188 - } 189 - 190 - Ok(None) 191 - } 192 - 193 - /// Get the current working copy change ID 194 - pub fn working_copy_change_id(&self) -> Result<Option<ChangeId>, RepoError> { 195 - let repo_loader = self.workspace.repo_loader(); 196 - let repo = repo_loader 197 - .load_at_head() 198 - .map_err(|e| RepoError::Internal(format!("Failed to load repo: {}", e)))?; 199 - 200 - // Get the working copy commit ID from the op store 201 - // In jj, we need to get it from the operation view 202 - let view = repo.view(); 203 - let wc_commit_id = view 204 - .get_wc_commit_id(self.workspace.workspace_name()) 205 - .ok_or_else(|| RepoError::Internal("No working copy commit for this workspace".to_string()))?; 206 - 207 - // Load the commit to get its change_id 208 - let commit = repo 209 - .store() 210 - .get_commit(wc_commit_id) 211 - .map_err(|e| RepoError::Internal(format!("Failed to get working copy commit: {}", e)))?; 212 - 213 - // Extract the change_id 214 - let change_id_bytes = commit.change_id().as_bytes(); 215 - if change_id_bytes.len() != 32 { 216 - return Err(RepoError::Internal(format!( 217 - "Invalid change_id length: expected 32, got {}", 218 - change_id_bytes.len() 219 - ))); 220 - } 221 - let mut change_id = [0u8; 32]; 222 - change_id.copy_from_slice(change_id_bytes); 223 - 224 - Ok(Some(ChangeId(change_id))) 225 - } 226 - 227 - /// Check if forge is configured for this repo 228 - pub fn forge_config(&self) -> Result<Option<ForgeConfig>, RepoError> { 229 - let config_path = self.path.join(".jj").join("forge.toml"); 230 - if !config_path.exists() { 231 - return Ok(None); 232 - } 233 - 234 - let content = std::fs::read_to_string(&config_path)?; 235 - let config: ForgeConfig = toml::from_str(&content) 236 - .map_err(|e| RepoError::Internal(e.to_string()))?; 237 - Ok(Some(config)) 238 - } 239 - 240 - /// Write forge configuration 241 - pub fn set_forge_config(&self, config: &ForgeConfig) -> Result<(), RepoError> { 242 - let config_path = self.path.join(".jj").join("forge.toml"); 243 - let content = toml::to_string_pretty(config) 244 - .map_err(|e| RepoError::Internal(e.to_string()))?; 245 - std::fs::write(&config_path, content)?; 246 - Ok(()) 247 - } 248 - } 249 - 250 - /// Forge configuration stored in .jj/forge.toml 251 - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 252 - pub struct ForgeConfig { 253 - pub forge: ForgeSettings, 254 - } 255 - 256 - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 257 - pub struct ForgeSettings { 258 - pub url: String, 259 - }
-18
crates/tandem-core/Cargo.toml
··· 1 - [package] 2 - name = "tandem-core" 3 - version.workspace = true 4 - edition.workspace = true 5 - authors.workspace = true 6 - license.workspace = true 7 - repository.workspace = true 8 - 9 - [dependencies] 10 - serde.workspace = true 11 - serde_json.workspace = true 12 - uuid.workspace = true 13 - chrono.workspace = true 14 - yrs.workspace = true 15 - hex.workspace = true 16 - rand.workspace = true 17 - base64.workspace = true 18 - sha1 = "0.10.6"
-17
crates/tandem-core/src/content.rs
··· 1 - //! Content request/response types for lazy blob loading 2 - 3 - use serde::{Deserialize, Serialize}; 4 - 5 - /// Request for lazy content 6 - #[derive(Debug, Clone, Serialize, Deserialize)] 7 - pub struct ContentRequest { 8 - pub hash: String, 9 - pub state_vector: Vec<u8>, 10 - } 11 - 12 - /// Response with content update 13 - #[derive(Debug, Clone, Serialize, Deserialize)] 14 - pub struct ContentResponse { 15 - pub hash: String, 16 - pub update: Vec<u8>, 17 - }
-29
crates/tandem-core/src/lib.rs
··· 1 - //! Tandem Core Library 2 - //! 3 - //! Shared data model and synchronization primitives for the Tandem project. 4 - 5 - pub mod content; 6 - pub mod model; 7 - pub mod object_store; 8 - pub mod sync; 9 - pub mod types; 10 - 11 - // Re-export model types 12 - pub use model::Repository; 13 - 14 - // Re-export object store types 15 - pub use object_store::{ 16 - hash_blob, FileMode, MemoryObjectStore, ObjectRef, ObjectStore, Tree, TreeEntry, 17 - }; 18 - 19 - // Re-export sync types 20 - pub use sync::*; 21 - 22 - // Re-export core types 23 - pub use types::{ 24 - BlobHash, Bookmark, BookmarkRules, Change, ChangeId, ChangeRecord, Identity, PresenceInfo, 25 - TreeHash, 26 - }; 27 - 28 - // Re-export content types 29 - pub use content::{ContentRequest, ContentResponse};
-13
crates/tandem-core/src/model.rs
··· 1 - //! Data model for Tandem 2 - 3 - use serde::{Deserialize, Serialize}; 4 - use uuid::Uuid; 5 - use chrono::{DateTime, Utc}; 6 - 7 - #[derive(Debug, Clone, Serialize, Deserialize)] 8 - pub struct Repository { 9 - pub id: Uuid, 10 - pub name: String, 11 - pub created_at: DateTime<Utc>, 12 - pub updated_at: DateTime<Utc>, 13 - }
-181
crates/tandem-core/src/object_store.rs
··· 1 - //! Content-addressed object storage for trees and blobs (git-compatible) 2 - 3 - use crate::types::{BlobHash, TreeHash}; 4 - use serde::{Deserialize, Serialize}; 5 - use sha1::{Digest, Sha1}; 6 - use std::collections::HashMap; 7 - 8 - /// File mode (simplified, git-compatible) 9 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 10 - pub enum FileMode { 11 - Regular, // 100644 12 - Executable, // 100755 13 - Symlink, // 120000 14 - Directory, // 040000 15 - } 16 - 17 - impl FileMode { 18 - pub fn as_str(&self) -> &str { 19 - match self { 20 - FileMode::Regular => "100644", 21 - FileMode::Executable => "100755", 22 - FileMode::Symlink => "120000", 23 - FileMode::Directory => "040000", 24 - } 25 - } 26 - } 27 - 28 - /// Reference to either a tree or blob 29 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 30 - pub enum ObjectRef { 31 - Tree(TreeHash), 32 - Blob(BlobHash), 33 - } 34 - 35 - /// Entry in a tree 36 - #[derive(Debug, Clone, Serialize, Deserialize)] 37 - pub struct TreeEntry { 38 - pub name: String, 39 - pub mode: FileMode, 40 - pub hash: ObjectRef, 41 - } 42 - 43 - /// A tree object (directory) 44 - #[derive(Debug, Clone, Serialize, Deserialize)] 45 - pub struct Tree { 46 - pub entries: Vec<TreeEntry>, 47 - } 48 - 49 - impl Tree { 50 - pub fn new() -> Self { 51 - Self { 52 - entries: Vec::new(), 53 - } 54 - } 55 - 56 - pub fn add_entry(&mut self, name: String, mode: FileMode, hash: ObjectRef) { 57 - self.entries.push(TreeEntry { name, mode, hash }); 58 - } 59 - 60 - /// Compute git-compatible hash of this tree 61 - pub fn hash(&self) -> TreeHash { 62 - let mut content = Vec::new(); 63 - 64 - // Sort entries by name (git requirement) 65 - let mut sorted_entries = self.entries.clone(); 66 - sorted_entries.sort_by(|a, b| a.name.cmp(&b.name)); 67 - 68 - // Build tree content in git format 69 - for entry in sorted_entries { 70 - // Mode (as string) 71 - content.extend_from_slice(entry.mode.as_str().as_bytes()); 72 - content.push(b' '); 73 - 74 - // Name 75 - content.extend_from_slice(entry.name.as_bytes()); 76 - content.push(b'\0'); 77 - 78 - // Hash (as raw bytes, 20 bytes for SHA1) 79 - match entry.hash { 80 - ObjectRef::Tree(h) => content.extend_from_slice(&h.0), 81 - ObjectRef::Blob(h) => content.extend_from_slice(&h.0), 82 - } 83 - } 84 - 85 - // Git format: "tree {size}\0{content}" 86 - let header = format!("tree {}\0", content.len()); 87 - let mut full_content = Vec::new(); 88 - full_content.extend_from_slice(header.as_bytes()); 89 - full_content.extend_from_slice(&content); 90 - 91 - // Compute SHA1 92 - let mut hasher = Sha1::new(); 93 - hasher.update(&full_content); 94 - let result = hasher.finalize(); 95 - 96 - // Convert to [u8; 20] 97 - let mut hash_bytes = [0u8; 20]; 98 - hash_bytes.copy_from_slice(&result); 99 - 100 - TreeHash(hash_bytes) 101 - } 102 - } 103 - 104 - impl Default for Tree { 105 - fn default() -> Self { 106 - Self::new() 107 - } 108 - } 109 - 110 - /// Compute git-compatible hash of blob content 111 - pub fn hash_blob(content: &[u8]) -> BlobHash { 112 - // Git format: "blob {size}\0{content}" 113 - let header = format!("blob {}\0", content.len()); 114 - let mut full_content = Vec::new(); 115 - full_content.extend_from_slice(header.as_bytes()); 116 - full_content.extend_from_slice(content); 117 - 118 - // Compute SHA1 119 - let mut hasher = Sha1::new(); 120 - hasher.update(&full_content); 121 - let result = hasher.finalize(); 122 - 123 - // Convert to [u8; 20] 124 - let mut hash_bytes = [0u8; 20]; 125 - hash_bytes.copy_from_slice(&result); 126 - 127 - BlobHash(hash_bytes) 128 - } 129 - 130 - /// Trait for content-addressed object storage 131 - pub trait ObjectStore: Send + Sync { 132 - fn get_tree(&self, hash: &TreeHash) -> Option<Tree>; 133 - fn get_blob(&self, hash: &BlobHash) -> Option<Vec<u8>>; 134 - fn put_tree(&mut self, tree: &Tree) -> TreeHash; 135 - fn put_blob(&mut self, content: &[u8]) -> BlobHash; 136 - fn has_tree(&self, hash: &TreeHash) -> bool; 137 - fn has_blob(&self, hash: &BlobHash) -> bool; 138 - } 139 - 140 - /// In-memory implementation for testing/prototyping 141 - #[derive(Debug, Default)] 142 - pub struct MemoryObjectStore { 143 - trees: HashMap<TreeHash, Tree>, 144 - blobs: HashMap<BlobHash, Vec<u8>>, 145 - } 146 - 147 - impl MemoryObjectStore { 148 - pub fn new() -> Self { 149 - Self::default() 150 - } 151 - } 152 - 153 - impl ObjectStore for MemoryObjectStore { 154 - fn get_tree(&self, hash: &TreeHash) -> Option<Tree> { 155 - self.trees.get(hash).cloned() 156 - } 157 - 158 - fn get_blob(&self, hash: &BlobHash) -> Option<Vec<u8>> { 159 - self.blobs.get(hash).cloned() 160 - } 161 - 162 - fn put_tree(&mut self, tree: &Tree) -> TreeHash { 163 - let hash = tree.hash(); 164 - self.trees.insert(hash, tree.clone()); 165 - hash 166 - } 167 - 168 - fn put_blob(&mut self, content: &[u8]) -> BlobHash { 169 - let hash = hash_blob(content); 170 - self.blobs.insert(hash, content.to_vec()); 171 - hash 172 - } 173 - 174 - fn has_tree(&self, hash: &TreeHash) -> bool { 175 - self.trees.contains_key(hash) 176 - } 177 - 178 - fn has_blob(&self, hash: &BlobHash) -> bool { 179 - self.blobs.contains_key(hash) 180 - } 181 - }
-659
crates/tandem-core/src/sync.rs
··· 1 - //! Synchronization primitives using Yrs CRDT 2 - 3 - use crate::types::{ChangeId, ChangeRecord, PresenceInfo}; 4 - use serde_json; 5 - use std::collections::HashMap; 6 - use std::sync::{Arc, RwLock}; 7 - use yrs::updates::decoder::Decode; 8 - use yrs::updates::encoder::Encode; 9 - use yrs::{Doc, Map, ReadTxn, StateVector, Transact, TransactionMut, Update, WriteTxn}; 10 - 11 - /// Y.Doc structure for forge sync 12 - /// 13 - /// Structure: 14 - /// - Y.Map("changes") → {record_id: ChangeRecord} 15 - /// - Y.Map("bookmarks") → {name: ChangeId} 16 - /// - Y.Map("presence") → {user_id: PresenceInfo} 17 - /// - Subdocuments keyed by hash → Y.Map("data") → base64-encoded blob content 18 - pub struct ForgeDoc { 19 - doc: Doc, 20 - subdocs: Arc<RwLock<HashMap<String, Arc<Doc>>>>, 21 - } 22 - 23 - impl ForgeDoc { 24 - pub fn new() -> Self { 25 - Self { 26 - doc: Doc::new(), 27 - subdocs: Arc::new(RwLock::new(HashMap::new())), 28 - } 29 - } 30 - 31 - /// Get the underlying Y.Doc for sync operations 32 - pub fn doc(&self) -> &Doc { 33 - &self.doc 34 - } 35 - 36 - // Change operations 37 - 38 - /// Insert a change record into the CRDT 39 - pub fn insert_change(&self, record: &ChangeRecord) { 40 - let mut txn = self.doc.transact_mut(); 41 - let changes = txn.get_or_insert_map("changes"); 42 - let record_id = record.record_id.to_string(); 43 - let json = serde_json::to_string(record).expect("Failed to serialize ChangeRecord"); 44 - changes.insert(&mut txn, record_id, json); 45 - } 46 - 47 - /// Get all records for a specific change_id (handles divergence) 48 - pub fn get_change_records(&self, change_id: &ChangeId) -> Vec<ChangeRecord> { 49 - let txn = self.doc.transact(); 50 - let changes = match txn.get_map("changes") { 51 - Some(map) => map, 52 - None => return Vec::new(), 53 - }; 54 - 55 - let mut records = Vec::new(); 56 - for (_key, value) in changes.iter(&txn) { 57 - if let Ok(json) = value.cast::<String>() { 58 - if let Ok(record) = serde_json::from_str::<ChangeRecord>(&json) { 59 - if record.change_id == *change_id { 60 - records.push(record); 61 - } 62 - } 63 - } 64 - } 65 - records 66 - } 67 - 68 - /// Get all change records 69 - pub fn get_all_change_records(&self) -> Vec<ChangeRecord> { 70 - let txn = self.doc.transact(); 71 - let changes = match txn.get_map("changes") { 72 - Some(map) => map, 73 - None => return Vec::new(), 74 - }; 75 - 76 - let mut records = Vec::new(); 77 - for (_key, value) in changes.iter(&txn) { 78 - if let Ok(json) = value.cast::<String>() { 79 - if let Ok(record) = serde_json::from_str::<ChangeRecord>(&json) { 80 - records.push(record); 81 - } 82 - } 83 - } 84 - records 85 - } 86 - 87 - /// Mark a change record as hidden (for abandoned changes) 88 - pub fn mark_change_hidden(&self, record_id: &str) { 89 - let mut txn = self.doc.transact_mut(); 90 - let changes = txn.get_or_insert_map("changes"); 91 - 92 - if let Some(value) = changes.get(&txn, record_id) { 93 - if let Ok(json) = value.cast::<String>() { 94 - if let Ok(mut record) = serde_json::from_str::<ChangeRecord>(&json) { 95 - record.visible = false; 96 - let updated_json = serde_json::to_string(&record) 97 - .expect("Failed to serialize ChangeRecord"); 98 - changes.insert(&mut txn, record_id, updated_json); 99 - } 100 - } 101 - } 102 - } 103 - 104 - // Bookmark operations 105 - 106 - /// Set a bookmark to point at a change 107 - pub fn set_bookmark(&self, name: &str, target: &ChangeId) { 108 - let mut txn = self.doc.transact_mut(); 109 - let bookmarks = txn.get_or_insert_map("bookmarks"); 110 - let target_str = target.to_string(); 111 - bookmarks.insert(&mut txn, name, target_str); 112 - } 113 - 114 - /// Get the change a bookmark points to 115 - pub fn get_bookmark(&self, name: &str) -> Option<ChangeId> { 116 - let txn = self.doc.transact(); 117 - let bookmarks = txn.get_map("bookmarks")?; 118 - let value = bookmarks.get(&txn, name)?; 119 - let target_str = value.cast::<String>().ok()?; 120 - target_str.parse().ok() 121 - } 122 - 123 - /// Get all bookmarks 124 - pub fn get_all_bookmarks(&self) -> Vec<(String, ChangeId)> { 125 - let txn = self.doc.transact(); 126 - let bookmarks = match txn.get_map("bookmarks") { 127 - Some(map) => map, 128 - None => return Vec::new(), 129 - }; 130 - 131 - let mut result = Vec::new(); 132 - for (key, value) in bookmarks.iter(&txn) { 133 - if let Ok(target_str) = value.cast::<String>() { 134 - if let Ok(change_id) = target_str.parse() { 135 - result.push((key.to_string(), change_id)); 136 - } 137 - } 138 - } 139 - result 140 - } 141 - 142 - /// Remove a bookmark 143 - pub fn remove_bookmark(&self, name: &str) { 144 - let mut txn = self.doc.transact_mut(); 145 - let bookmarks = txn.get_or_insert_map("bookmarks"); 146 - bookmarks.remove(&mut txn, name); 147 - } 148 - 149 - // Presence operations 150 - 151 - /// Update presence information for a user 152 - pub fn update_presence(&self, info: &PresenceInfo) { 153 - let mut txn = self.doc.transact_mut(); 154 - let presence = txn.get_or_insert_map("presence"); 155 - let json = serde_json::to_string(info).expect("Failed to serialize PresenceInfo"); 156 - presence.insert(&mut txn, info.user_id.as_str(), json); 157 - } 158 - 159 - /// Get presence information for a user 160 - pub fn get_presence(&self, user_id: &str) -> Option<PresenceInfo> { 161 - let txn = self.doc.transact(); 162 - let presence = txn.get_map("presence")?; 163 - let value = presence.get(&txn, user_id)?; 164 - let json = value.cast::<String>().ok()?; 165 - serde_json::from_str(&json).ok() 166 - } 167 - 168 - /// Get all presence information 169 - pub fn get_all_presence(&self) -> Vec<PresenceInfo> { 170 - let txn = self.doc.transact(); 171 - let presence = match txn.get_map("presence") { 172 - Some(map) => map, 173 - None => return Vec::new(), 174 - }; 175 - 176 - let mut result = Vec::new(); 177 - for (_key, value) in presence.iter(&txn) { 178 - if let Ok(json) = value.cast::<String>() { 179 - if let Ok(info) = serde_json::from_str::<PresenceInfo>(&json) { 180 - result.push(info); 181 - } 182 - } 183 - } 184 - result 185 - } 186 - 187 - /// Remove presence information for a user 188 - pub fn remove_presence(&self, user_id: &str) { 189 - let mut txn = self.doc.transact_mut(); 190 - let presence = txn.get_or_insert_map("presence"); 191 - presence.remove(&mut txn, user_id); 192 - } 193 - 194 - // Content/subdocument operations 195 - 196 - /// Check if content for a hash is available locally 197 - pub fn has_content(&self, hash: &str) -> bool { 198 - let subdocs = self.subdocs.read().unwrap(); 199 - if let Some(subdoc) = subdocs.get(hash) { 200 - let txn = subdoc.transact(); 201 - if let Some(data_map) = txn.get_map("data") { 202 - return data_map.get(&txn, "content").is_some(); 203 - } 204 - } 205 - false 206 - } 207 - 208 - /// Get content if available locally (doesn't fetch) 209 - pub fn get_content(&self, hash: &str) -> Option<Vec<u8>> { 210 - let subdocs = self.subdocs.read().unwrap(); 211 - let subdoc = subdocs.get(hash)?; 212 - let txn = subdoc.transact(); 213 - let data_map = txn.get_map("data")?; 214 - let base64_str = data_map.get(&txn, "content")?.cast::<String>().ok()?; 215 - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &base64_str).ok() 216 - } 217 - 218 - /// Store content locally (for content we've fetched or created) 219 - pub fn put_content(&self, hash: &str, content: Vec<u8>) { 220 - let subdoc = self.get_subdoc(hash); 221 - let mut txn = subdoc.transact_mut(); 222 - let data_map = txn.get_or_insert_map("data"); 223 - let base64_str = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &content); 224 - data_map.insert(&mut txn, "content", base64_str); 225 - } 226 - 227 - /// Get a subdocument for a specific hash (creates if doesn't exist) 228 - /// The subdocument can be synced independently 229 - pub fn get_subdoc(&self, hash: &str) -> Arc<Doc> { 230 - let mut subdocs = self.subdocs.write().unwrap(); 231 - subdocs 232 - .entry(hash.to_string()) 233 - .or_insert_with(|| Arc::new(Doc::new())) 234 - .clone() 235 - } 236 - 237 - /// List all subdocument hashes we have locally 238 - pub fn list_local_content(&self) -> Vec<String> { 239 - let subdocs = self.subdocs.read().unwrap(); 240 - subdocs.keys().cloned().collect() 241 - } 242 - 243 - /// Encode subdoc state vector for requesting content 244 - pub fn encode_subdoc_state_vector(&self, hash: &str) -> Option<Vec<u8>> { 245 - let subdocs = self.subdocs.read().unwrap(); 246 - let subdoc = subdocs.get(hash)?; 247 - let txn = subdoc.transact(); 248 - Some(txn.state_vector().encode_v1()) 249 - } 250 - 251 - /// Apply update to a subdocument 252 - pub fn apply_subdoc_update(&self, hash: &str, update: &[u8]) -> Result<(), yrs::encoding::read::Error> { 253 - let subdoc = self.get_subdoc(hash); 254 - let mut txn = subdoc.transact_mut(); 255 - let update = Update::decode_v1(update)?; 256 - let _result = txn.apply_update(update); 257 - Ok(()) 258 - } 259 - 260 - // Sync protocol 261 - 262 - /// Encode the current state vector for sync 263 - pub fn encode_state_vector(&self) -> Vec<u8> { 264 - let txn = self.doc.transact(); 265 - txn.state_vector().encode_v1() 266 - } 267 - 268 - /// Encode an update based on a remote state vector 269 - pub fn encode_update_from(&self, state_vector: &[u8]) -> Vec<u8> { 270 - let txn = self.doc.transact(); 271 - let remote_sv = StateVector::decode_v1(state_vector) 272 - .expect("Failed to decode state vector"); 273 - txn.encode_diff_v1(&remote_sv) 274 - } 275 - 276 - /// Apply an update from a remote peer 277 - pub fn apply_update(&self, update: &[u8]) -> Result<(), yrs::encoding::read::Error> { 278 - let mut txn = self.doc.transact_mut(); 279 - let update = Update::decode_v1(update)?; 280 - let _result = txn.apply_update(update); 281 - Ok(()) 282 - } 283 - 284 - // Transactions for atomic operations 285 - 286 - /// Execute a function within a transaction for atomic updates 287 - pub fn transact<F, R>(&self, f: F) -> R 288 - where 289 - F: FnOnce(&mut TransactionMut) -> R, 290 - { 291 - let mut txn = self.doc.transact_mut(); 292 - f(&mut txn) 293 - } 294 - } 295 - 296 - impl Default for ForgeDoc { 297 - fn default() -> Self { 298 - Self::new() 299 - } 300 - } 301 - 302 - #[cfg(test)] 303 - mod tests { 304 - use super::*; 305 - use crate::types::{Identity, TreeHash}; 306 - use chrono::Utc; 307 - use uuid::Uuid; 308 - 309 - fn create_test_record(change_id: ChangeId) -> ChangeRecord { 310 - ChangeRecord { 311 - record_id: Uuid::new_v4(), 312 - change_id, 313 - tree: TreeHash([0u8; 20]), 314 - parents: vec![], 315 - description: "Test change".to_string(), 316 - author: Identity { 317 - email: "test@example.com".to_string(), 318 - name: Some("Test User".to_string()), 319 - }, 320 - timestamp: Utc::now(), 321 - visible: true, 322 - } 323 - } 324 - 325 - #[test] 326 - fn test_insert_and_retrieve_change_records() { 327 - let doc = ForgeDoc::new(); 328 - let change_id = ChangeId::new(); 329 - let record = create_test_record(change_id); 330 - 331 - doc.insert_change(&record); 332 - 333 - let retrieved = doc.get_change_records(&change_id); 334 - assert_eq!(retrieved.len(), 1); 335 - assert_eq!(retrieved[0].change_id, change_id); 336 - assert_eq!(retrieved[0].record_id, record.record_id); 337 - } 338 - 339 - #[test] 340 - fn test_multiple_records_same_change_id() { 341 - let doc = ForgeDoc::new(); 342 - let change_id = ChangeId::new(); 343 - 344 - let record1 = create_test_record(change_id); 345 - let record2 = create_test_record(change_id); 346 - 347 - doc.insert_change(&record1); 348 - doc.insert_change(&record2); 349 - 350 - let retrieved = doc.get_change_records(&change_id); 351 - assert_eq!(retrieved.len(), 2); 352 - 353 - let record_ids: Vec<_> = retrieved.iter().map(|r| r.record_id).collect(); 354 - assert!(record_ids.contains(&record1.record_id)); 355 - assert!(record_ids.contains(&record2.record_id)); 356 - } 357 - 358 - #[test] 359 - fn test_get_all_change_records() { 360 - let doc = ForgeDoc::new(); 361 - let change_id1 = ChangeId::new(); 362 - let change_id2 = ChangeId::new(); 363 - 364 - let record1 = create_test_record(change_id1); 365 - let record2 = create_test_record(change_id2); 366 - 367 - doc.insert_change(&record1); 368 - doc.insert_change(&record2); 369 - 370 - let all = doc.get_all_change_records(); 371 - assert_eq!(all.len(), 2); 372 - } 373 - 374 - #[test] 375 - fn test_mark_change_hidden() { 376 - let doc = ForgeDoc::new(); 377 - let change_id = ChangeId::new(); 378 - let record = create_test_record(change_id); 379 - let record_id = record.record_id.to_string(); 380 - 381 - doc.insert_change(&record); 382 - doc.mark_change_hidden(&record_id); 383 - 384 - let retrieved = doc.get_change_records(&change_id); 385 - assert_eq!(retrieved.len(), 1); 386 - assert_eq!(retrieved[0].visible, false); 387 - } 388 - 389 - #[test] 390 - fn test_bookmark_operations() { 391 - let doc = ForgeDoc::new(); 392 - let change_id = ChangeId::new(); 393 - 394 - doc.set_bookmark("main", &change_id); 395 - 396 - let retrieved = doc.get_bookmark("main"); 397 - assert_eq!(retrieved, Some(change_id)); 398 - 399 - let all = doc.get_all_bookmarks(); 400 - assert_eq!(all.len(), 1); 401 - assert_eq!(all[0].0, "main"); 402 - assert_eq!(all[0].1, change_id); 403 - 404 - doc.remove_bookmark("main"); 405 - assert_eq!(doc.get_bookmark("main"), None); 406 - } 407 - 408 - #[test] 409 - fn test_presence_operations() { 410 - let doc = ForgeDoc::new(); 411 - let change_id = ChangeId::new(); 412 - let info = PresenceInfo { 413 - user_id: "user1".to_string(), 414 - change_id, 415 - device: "laptop".to_string(), 416 - timestamp: Utc::now(), 417 - }; 418 - 419 - doc.update_presence(&info); 420 - 421 - let retrieved = doc.get_presence("user1"); 422 - assert!(retrieved.is_some()); 423 - assert_eq!(retrieved.unwrap().user_id, "user1"); 424 - 425 - let all = doc.get_all_presence(); 426 - assert_eq!(all.len(), 1); 427 - 428 - doc.remove_presence("user1"); 429 - assert_eq!(doc.get_presence("user1"), None); 430 - } 431 - 432 - #[test] 433 - fn test_sync_between_docs() { 434 - let doc1 = ForgeDoc::new(); 435 - let doc2 = ForgeDoc::new(); 436 - 437 - let change_id = ChangeId::new(); 438 - let record = create_test_record(change_id); 439 - 440 - // Insert into doc1 441 - doc1.insert_change(&record); 442 - doc1.set_bookmark("main", &change_id); 443 - 444 - // Sync from doc1 to doc2 445 - let sv2 = doc2.encode_state_vector(); 446 - let update = doc1.encode_update_from(&sv2); 447 - doc2.apply_update(&update).unwrap(); 448 - 449 - // Verify doc2 has the data 450 - let retrieved = doc2.get_change_records(&change_id); 451 - assert_eq!(retrieved.len(), 1); 452 - assert_eq!(retrieved[0].change_id, change_id); 453 - 454 - let bookmark = doc2.get_bookmark("main"); 455 - assert_eq!(bookmark, Some(change_id)); 456 - } 457 - 458 - #[test] 459 - fn test_bidirectional_sync() { 460 - let doc1 = ForgeDoc::new(); 461 - let doc2 = ForgeDoc::new(); 462 - 463 - let change_id1 = ChangeId::new(); 464 - let change_id2 = ChangeId::new(); 465 - let record1 = create_test_record(change_id1); 466 - let record2 = create_test_record(change_id2); 467 - 468 - // Insert different records into each doc 469 - doc1.insert_change(&record1); 470 - doc2.insert_change(&record2); 471 - 472 - // Sync doc1 -> doc2 473 - let sv2 = doc2.encode_state_vector(); 474 - let update1 = doc1.encode_update_from(&sv2); 475 - doc2.apply_update(&update1).unwrap(); 476 - 477 - // Sync doc2 -> doc1 478 - let sv1 = doc1.encode_state_vector(); 479 - let update2 = doc2.encode_update_from(&sv1); 480 - doc1.apply_update(&update2).unwrap(); 481 - 482 - // Both docs should have both records 483 - let all1 = doc1.get_all_change_records(); 484 - let all2 = doc2.get_all_change_records(); 485 - assert_eq!(all1.len(), 2); 486 - assert_eq!(all2.len(), 2); 487 - } 488 - 489 - #[test] 490 - fn test_atomic_transaction() { 491 - let doc = ForgeDoc::new(); 492 - let change_id1 = ChangeId::new(); 493 - let change_id2 = ChangeId::new(); 494 - 495 - // Use transaction to atomically insert multiple records 496 - doc.transact(|txn| { 497 - let changes = txn.get_or_insert_map("changes"); 498 - 499 - let record1 = create_test_record(change_id1); 500 - let json1 = serde_json::to_string(&record1).unwrap(); 501 - changes.insert(txn, record1.record_id.to_string(), json1); 502 - 503 - let record2 = create_test_record(change_id2); 504 - let json2 = serde_json::to_string(&record2).unwrap(); 505 - changes.insert(txn, record2.record_id.to_string(), json2); 506 - }); 507 - 508 - let all = doc.get_all_change_records(); 509 - assert_eq!(all.len(), 2); 510 - } 511 - 512 - #[test] 513 - fn test_state_vector_encoding() { 514 - let doc = ForgeDoc::new(); 515 - let change_id = ChangeId::new(); 516 - let record = create_test_record(change_id); 517 - 518 - doc.insert_change(&record); 519 - 520 - let sv = doc.encode_state_vector(); 521 - assert!(!sv.is_empty()); 522 - } 523 - 524 - #[test] 525 - fn test_put_and_get_content() { 526 - let doc = ForgeDoc::new(); 527 - let hash = "abc123"; 528 - let content = b"Hello, World!".to_vec(); 529 - 530 - doc.put_content(hash, content.clone()); 531 - 532 - assert!(doc.has_content(hash)); 533 - let retrieved = doc.get_content(hash); 534 - assert_eq!(retrieved, Some(content)); 535 - } 536 - 537 - #[test] 538 - fn test_has_content_returns_false_for_missing() { 539 - let doc = ForgeDoc::new(); 540 - assert!(!doc.has_content("nonexistent")); 541 - } 542 - 543 - #[test] 544 - fn test_get_content_returns_none_for_missing() { 545 - let doc = ForgeDoc::new(); 546 - assert_eq!(doc.get_content("nonexistent"), None); 547 - } 548 - 549 - #[test] 550 - fn test_list_local_content() { 551 - let doc = ForgeDoc::new(); 552 - let hash1 = "hash1"; 553 - let hash2 = "hash2"; 554 - 555 - doc.put_content(hash1, b"content1".to_vec()); 556 - doc.put_content(hash2, b"content2".to_vec()); 557 - 558 - let mut hashes = doc.list_local_content(); 559 - hashes.sort(); 560 - 561 - assert_eq!(hashes.len(), 2); 562 - assert!(hashes.contains(&hash1.to_string())); 563 - assert!(hashes.contains(&hash2.to_string())); 564 - } 565 - 566 - #[test] 567 - fn test_get_subdoc_creates_new() { 568 - let doc = ForgeDoc::new(); 569 - let hash = "test_hash"; 570 - 571 - let subdoc = doc.get_subdoc(hash); 572 - assert!(Arc::strong_count(&subdoc) >= 1); 573 - 574 - // Getting the same subdoc returns the same instance 575 - let subdoc2 = doc.get_subdoc(hash); 576 - assert!(Arc::ptr_eq(&subdoc, &subdoc2)); 577 - } 578 - 579 - #[test] 580 - fn test_encode_subdoc_state_vector() { 581 - let doc = ForgeDoc::new(); 582 - let hash = "test_hash"; 583 - 584 - doc.put_content(hash, b"some content".to_vec()); 585 - 586 - let sv = doc.encode_subdoc_state_vector(hash); 587 - assert!(sv.is_some()); 588 - assert!(!sv.unwrap().is_empty()); 589 - } 590 - 591 - #[test] 592 - fn test_encode_subdoc_state_vector_nonexistent() { 593 - let doc = ForgeDoc::new(); 594 - let sv = doc.encode_subdoc_state_vector("nonexistent"); 595 - assert_eq!(sv, None); 596 - } 597 - 598 - #[test] 599 - fn test_subdoc_sync_between_docs() { 600 - let doc1 = ForgeDoc::new(); 601 - let doc2 = ForgeDoc::new(); 602 - let hash = "sync_test"; 603 - let content = b"sync this content".to_vec(); 604 - 605 - // Put content in doc1 606 - doc1.put_content(hash, content.clone()); 607 - 608 - // Get state vector from doc2 for this hash 609 - let _subdoc2 = doc2.get_subdoc(hash); 610 - let sv2 = doc2.encode_subdoc_state_vector(hash).unwrap(); 611 - 612 - // Generate update from doc1 613 - let subdoc1 = doc1.get_subdoc(hash); 614 - let txn1 = subdoc1.transact(); 615 - let sv2_decoded = StateVector::decode_v1(&sv2).unwrap(); 616 - let update = txn1.encode_diff_v1(&sv2_decoded); 617 - drop(txn1); 618 - 619 - // Apply update to doc2 620 - doc2.apply_subdoc_update(hash, &update).unwrap(); 621 - 622 - // Verify content is synced 623 - assert!(doc2.has_content(hash)); 624 - let retrieved = doc2.get_content(hash); 625 - assert_eq!(retrieved, Some(content)); 626 - } 627 - 628 - #[test] 629 - fn test_apply_subdoc_update_creates_subdoc_if_missing() { 630 - let doc = ForgeDoc::new(); 631 - let hash = "new_hash"; 632 - 633 - // Create a dummy update (empty update) 634 - let temp_doc = Doc::new(); 635 - let txn = temp_doc.transact(); 636 - let sv = txn.state_vector(); 637 - let update = txn.encode_diff_v1(&sv); 638 - drop(txn); 639 - 640 - // Should not error even though subdoc doesn't exist yet 641 - let result = doc.apply_subdoc_update(hash, &update); 642 - assert!(result.is_ok()); 643 - 644 - // Subdoc should now exist 645 - assert!(doc.list_local_content().contains(&hash.to_string())); 646 - } 647 - 648 - #[test] 649 - fn test_content_base64_encoding() { 650 - let doc = ForgeDoc::new(); 651 - let hash = "binary_test"; 652 - let content = vec![0u8, 1, 2, 3, 255, 254, 253]; 653 - 654 - doc.put_content(hash, content.clone()); 655 - 656 - let retrieved = doc.get_content(hash); 657 - assert_eq!(retrieved, Some(content)); 658 - } 659 - }
-255
crates/tandem-core/src/types.rs
··· 1 - //! Core types for Tandem 2 - 3 - use chrono::{DateTime, Utc}; 4 - use serde::{Deserialize, Serialize}; 5 - use std::fmt; 6 - use std::str::FromStr; 7 - use uuid::Uuid; 8 - 9 - /// Stable identifier for a change, persists across rebases 10 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 11 - pub struct ChangeId(pub [u8; 32]); 12 - 13 - impl ChangeId { 14 - /// Create a new random ChangeId 15 - pub fn new() -> Self { 16 - let mut bytes = [0u8; 32]; 17 - rand::Rng::fill(&mut rand::thread_rng(), &mut bytes); 18 - ChangeId(bytes) 19 - } 20 - } 21 - 22 - impl Default for ChangeId { 23 - fn default() -> Self { 24 - Self::new() 25 - } 26 - } 27 - 28 - impl fmt::Display for ChangeId { 29 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 30 - write!(f, "{}", hex::encode(self.0)) 31 - } 32 - } 33 - 34 - impl FromStr for ChangeId { 35 - type Err = String; 36 - 37 - fn from_str(s: &str) -> Result<Self, Self::Err> { 38 - let bytes = hex::decode(s).map_err(|e| format!("Invalid hex: {}", e))?; 39 - if bytes.len() != 32 { 40 - return Err(format!("Expected 32 bytes, got {}", bytes.len())); 41 - } 42 - let mut arr = [0u8; 32]; 43 - arr.copy_from_slice(&bytes); 44 - Ok(ChangeId(arr)) 45 - } 46 - } 47 - 48 - /// Content-addressed tree hash 49 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 50 - pub struct TreeHash(pub [u8; 20]); 51 - 52 - impl TreeHash { 53 - /// Create TreeHash from hex string (for compatibility with object_store) 54 - pub fn new(hash: String) -> Self { 55 - assert_eq!(hash.len(), 40, "TreeHash must be 40 characters"); 56 - TreeHash::from_str(&hash).expect("Invalid hex string") 57 - } 58 - 59 - /// Get hex string representation (for compatibility with object_store) 60 - pub fn as_str(&self) -> String { 61 - self.to_string() 62 - } 63 - } 64 - 65 - impl fmt::Display for TreeHash { 66 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 - write!(f, "{}", hex::encode(self.0)) 68 - } 69 - } 70 - 71 - impl FromStr for TreeHash { 72 - type Err = String; 73 - 74 - fn from_str(s: &str) -> Result<Self, Self::Err> { 75 - let bytes = hex::decode(s).map_err(|e| format!("Invalid hex: {}", e))?; 76 - if bytes.len() != 20 { 77 - return Err(format!("Expected 20 bytes, got {}", bytes.len())); 78 - } 79 - let mut arr = [0u8; 20]; 80 - arr.copy_from_slice(&bytes); 81 - Ok(TreeHash(arr)) 82 - } 83 - } 84 - 85 - /// Content-addressed blob hash 86 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 87 - pub struct BlobHash(pub [u8; 20]); 88 - 89 - impl BlobHash { 90 - /// Create BlobHash from hex string (for compatibility with object_store) 91 - pub fn new(hash: String) -> Self { 92 - assert_eq!(hash.len(), 40, "BlobHash must be 40 characters"); 93 - BlobHash::from_str(&hash).expect("Invalid hex string") 94 - } 95 - 96 - /// Get hex string representation (for compatibility with object_store) 97 - pub fn as_str(&self) -> String { 98 - self.to_string() 99 - } 100 - } 101 - 102 - impl fmt::Display for BlobHash { 103 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 104 - write!(f, "{}", hex::encode(self.0)) 105 - } 106 - } 107 - 108 - impl FromStr for BlobHash { 109 - type Err = String; 110 - 111 - fn from_str(s: &str) -> Result<Self, Self::Err> { 112 - let bytes = hex::decode(s).map_err(|e| format!("Invalid hex: {}", e))?; 113 - if bytes.len() != 20 { 114 - return Err(format!("Expected 20 bytes, got {}", bytes.len())); 115 - } 116 - let mut arr = [0u8; 20]; 117 - arr.copy_from_slice(&bytes); 118 - Ok(BlobHash(arr)) 119 - } 120 - } 121 - 122 - /// User identity 123 - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 124 - pub struct Identity { 125 - pub email: String, 126 - pub name: Option<String>, 127 - } 128 - 129 - /// The fundamental unit - identity persists across rebases 130 - #[derive(Debug, Clone, Serialize, Deserialize)] 131 - pub struct Change { 132 - pub id: ChangeId, 133 - pub tree: TreeHash, 134 - pub parents: Vec<ChangeId>, 135 - pub description: String, 136 - pub author: Identity, 137 - pub timestamp: DateTime<Utc>, 138 - } 139 - 140 - /// Record stored in Y.Doc - append-only with unique keys 141 - #[derive(Debug, Clone, Serialize, Deserialize)] 142 - pub struct ChangeRecord { 143 - pub record_id: Uuid, 144 - pub change_id: ChangeId, 145 - pub tree: TreeHash, 146 - pub parents: Vec<ChangeId>, 147 - pub description: String, 148 - pub author: Identity, 149 - pub timestamp: DateTime<Utc>, 150 - pub visible: bool, // false = abandoned/hidden 151 - } 152 - 153 - impl ChangeRecord { 154 - /// Create a ChangeRecord from a Change 155 - pub fn from_change(change: &Change) -> Self { 156 - ChangeRecord { 157 - record_id: Uuid::new_v4(), 158 - change_id: change.id, 159 - tree: change.tree, 160 - parents: change.parents.clone(), 161 - description: change.description.clone(), 162 - author: change.author.clone(), 163 - timestamp: change.timestamp, 164 - visible: true, 165 - } 166 - } 167 - } 168 - 169 - /// Rules for bookmark protection 170 - #[derive(Debug, Clone, Default, Serialize, Deserialize)] 171 - pub struct BookmarkRules { 172 - pub require_ci: bool, 173 - pub require_review: bool, 174 - } 175 - 176 - /// Named pointer to a change 177 - #[derive(Debug, Clone, Serialize, Deserialize)] 178 - pub struct Bookmark { 179 - pub name: String, 180 - pub target: ChangeId, 181 - pub protected: bool, 182 - pub rules: BookmarkRules, 183 - } 184 - 185 - /// Presence information for a user 186 - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 187 - pub struct PresenceInfo { 188 - pub user_id: String, 189 - pub change_id: ChangeId, 190 - pub device: String, 191 - pub timestamp: DateTime<Utc>, 192 - } 193 - 194 - #[cfg(test)] 195 - mod tests { 196 - use super::*; 197 - 198 - #[test] 199 - fn test_change_id_new() { 200 - let id1 = ChangeId::new(); 201 - let id2 = ChangeId::new(); 202 - assert_ne!(id1, id2); 203 - } 204 - 205 - #[test] 206 - fn test_change_id_display_parse() { 207 - let id = ChangeId::new(); 208 - let hex_str = id.to_string(); 209 - assert_eq!(hex_str.len(), 64); // 32 bytes * 2 chars per byte 210 - let parsed = ChangeId::from_str(&hex_str).unwrap(); 211 - assert_eq!(id, parsed); 212 - } 213 - 214 - #[test] 215 - fn test_tree_hash_display_parse() { 216 - let hash = TreeHash([1u8; 20]); 217 - let hex_str = hash.to_string(); 218 - assert_eq!(hex_str.len(), 40); // 20 bytes * 2 chars per byte 219 - let parsed = TreeHash::from_str(&hex_str).unwrap(); 220 - assert_eq!(hash, parsed); 221 - } 222 - 223 - #[test] 224 - fn test_blob_hash_display_parse() { 225 - let hash = BlobHash([2u8; 20]); 226 - let hex_str = hash.to_string(); 227 - assert_eq!(hex_str.len(), 40); // 20 bytes * 2 chars per byte 228 - let parsed = BlobHash::from_str(&hex_str).unwrap(); 229 - assert_eq!(hash, parsed); 230 - } 231 - 232 - #[test] 233 - fn test_change_record_from_change() { 234 - let change = Change { 235 - id: ChangeId::new(), 236 - tree: TreeHash([0u8; 20]), 237 - parents: vec![], 238 - description: "Test change".to_string(), 239 - author: Identity { 240 - email: "test@example.com".to_string(), 241 - name: Some("Test User".to_string()), 242 - }, 243 - timestamp: Utc::now(), 244 - }; 245 - 246 - let record = ChangeRecord::from_change(&change); 247 - assert_eq!(record.change_id, change.id); 248 - assert_eq!(record.tree, change.tree); 249 - assert_eq!(record.parents, change.parents); 250 - assert_eq!(record.description, change.description); 251 - assert_eq!(record.author, change.author); 252 - assert_eq!(record.timestamp, change.timestamp); 253 - assert_eq!(record.visible, true); 254 - } 255 - }
-31
crates/tandem-server/Cargo.toml
··· 1 - [package] 2 - name = "tandem-server" 3 - version.workspace = true 4 - edition.workspace = true 5 - authors.workspace = true 6 - license.workspace = true 7 - repository.workspace = true 8 - 9 - [dependencies] 10 - tandem-core.workspace = true 11 - 12 - axum.workspace = true 13 - tokio.workspace = true 14 - async-trait.workspace = true 15 - futures-util.workspace = true 16 - tower.workspace = true 17 - tower-http.workspace = true 18 - sqlx.workspace = true 19 - 20 - serde.workspace = true 21 - serde_json.workspace = true 22 - 23 - tracing.workspace = true 24 - tracing-subscriber.workspace = true 25 - 26 - chrono.workspace = true 27 - rand.workspace = true 28 - hex.workspace = true 29 - uuid.workspace = true 30 - thiserror = "2.0.17" 31 - yrs.workspace = true
-31
crates/tandem-server/migrations/001_init.sql
··· 1 - CREATE TABLE IF NOT EXISTS repos ( 2 - id TEXT PRIMARY KEY, 3 - name TEXT NOT NULL, 4 - org TEXT NOT NULL, 5 - created_at TEXT DEFAULT (datetime('now')), 6 - UNIQUE(org, name) 7 - ); 8 - 9 - CREATE TABLE IF NOT EXISTS users ( 10 - id TEXT PRIMARY KEY, 11 - email TEXT UNIQUE NOT NULL, 12 - name TEXT, 13 - password_hash TEXT NOT NULL, 14 - created_at TEXT DEFAULT (datetime('now')) 15 - ); 16 - 17 - CREATE TABLE IF NOT EXISTS repo_access ( 18 - repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE, 19 - user_id TEXT REFERENCES users(id) ON DELETE CASCADE, 20 - role TEXT CHECK(role IN ('read', 'write', 'admin')) NOT NULL, 21 - PRIMARY KEY (repo_id, user_id) 22 - ); 23 - 24 - CREATE TABLE IF NOT EXISTS auth_tokens ( 25 - token TEXT PRIMARY KEY, 26 - user_id TEXT REFERENCES users(id) ON DELETE CASCADE, 27 - expires_at TEXT NOT NULL 28 - ); 29 - 30 - CREATE INDEX IF NOT EXISTS idx_repo_access_user ON repo_access(user_id); 31 - CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
-159
crates/tandem-server/src/auth.rs
··· 1 - use crate::{AppState, db::UserRow}; 2 - use axum::{ 3 - Json, 4 - extract::{Request, State}, 5 - http::{StatusCode, header}, 6 - middleware::Next, 7 - response::Response, 8 - }; 9 - use chrono::{Duration, Utc}; 10 - use serde::{Deserialize, Serialize}; 11 - 12 - #[derive(Debug, Serialize, Deserialize)] 13 - pub struct LoginRequest { 14 - pub email: String, 15 - pub password: String, 16 - } 17 - 18 - #[derive(Debug, Serialize)] 19 - pub struct LoginResponse { 20 - pub token: String, 21 - pub expires_at: String, 22 - } 23 - 24 - #[derive(Debug, Serialize)] 25 - pub struct UserResponse { 26 - pub id: String, 27 - pub email: String, 28 - pub name: Option<String>, 29 - } 30 - 31 - #[derive(Debug, Clone)] 32 - pub struct AuthenticatedUser { 33 - pub id: String, 34 - pub email: String, 35 - pub name: Option<String>, 36 - } 37 - 38 - impl From<UserRow> for AuthenticatedUser { 39 - fn from(row: UserRow) -> Self { 40 - Self { 41 - id: row.id, 42 - email: row.email, 43 - name: row.name, 44 - } 45 - } 46 - } 47 - 48 - /// Login handler 49 - pub async fn login( 50 - State(state): State<AppState>, 51 - Json(req): Json<LoginRequest>, 52 - ) -> Result<Json<LoginResponse>, StatusCode> { 53 - // Find user by email 54 - let user = state 55 - .db 56 - .get_user_by_email(&req.email) 57 - .await 58 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? 59 - .ok_or(StatusCode::UNAUTHORIZED)?; 60 - 61 - // Verify password (simple comparison for now - use bcrypt in production) 62 - if !verify_password(&req.password, &user.password_hash) { 63 - return Err(StatusCode::UNAUTHORIZED); 64 - } 65 - 66 - // Generate token 67 - let token = generate_token(); 68 - let expires_at = Utc::now() + Duration::days(7); 69 - 70 - // Store token 71 - state 72 - .db 73 - .create_token(&token, &user.id, expires_at) 74 - .await 75 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 76 - 77 - Ok(Json(LoginResponse { 78 - token, 79 - expires_at: expires_at.to_rfc3339(), 80 - })) 81 - } 82 - 83 - /// Get current user handler 84 - pub async fn get_me(user: AuthenticatedUser) -> Json<UserResponse> { 85 - Json(UserResponse { 86 - id: user.id, 87 - email: user.email, 88 - name: user.name, 89 - }) 90 - } 91 - 92 - /// Authentication middleware 93 - pub async fn auth_middleware( 94 - State(state): State<AppState>, 95 - mut request: Request, 96 - next: Next, 97 - ) -> Result<Response, StatusCode> { 98 - // Extract bearer token 99 - let token = request 100 - .headers() 101 - .get(header::AUTHORIZATION) 102 - .and_then(|h| h.to_str().ok()) 103 - .and_then(|h| h.strip_prefix("Bearer ")) 104 - .ok_or(StatusCode::UNAUTHORIZED)?; 105 - 106 - // Verify token and get user 107 - let user = state 108 - .db 109 - .verify_token(token) 110 - .await 111 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? 112 - .ok_or(StatusCode::UNAUTHORIZED)?; 113 - 114 - // Add user to request extensions 115 - request 116 - .extensions_mut() 117 - .insert(AuthenticatedUser::from(user)); 118 - 119 - Ok(next.run(request).await) 120 - } 121 - 122 - /// Extract authenticated user from request 123 - #[async_trait::async_trait] 124 - impl<S> axum::extract::FromRequestParts<S> for AuthenticatedUser 125 - where 126 - S: Send + Sync, 127 - { 128 - type Rejection = StatusCode; 129 - 130 - async fn from_request_parts( 131 - parts: &mut axum::http::request::Parts, 132 - _state: &S, 133 - ) -> Result<Self, Self::Rejection> { 134 - parts 135 - .extensions 136 - .get::<AuthenticatedUser>() 137 - .cloned() 138 - .ok_or(StatusCode::UNAUTHORIZED) 139 - } 140 - } 141 - 142 - fn generate_token() -> String { 143 - use rand::Rng; 144 - let mut rng = rand::thread_rng(); 145 - let bytes: [u8; 32] = rng.r#gen(); 146 - hex::encode(bytes) 147 - } 148 - 149 - fn verify_password(password: &str, hash: &str) -> bool { 150 - // Simple comparison for prototype - use bcrypt in production 151 - // Hash is just the password for now 152 - password == hash 153 - } 154 - 155 - /// Hash a password (for user creation) 156 - pub fn hash_password(password: &str) -> String { 157 - // Simple passthrough for prototype - use bcrypt in production 158 - password.to_string() 159 - }
-110
crates/tandem-server/src/authz.rs
··· 1 - use axum::http::StatusCode; 2 - use crate::{AppState, auth::AuthenticatedUser}; 3 - 4 - /// Role levels (ordered by permission level) 5 - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 6 - pub enum Role { 7 - Read = 0, 8 - Write = 1, 9 - Admin = 2, 10 - } 11 - 12 - impl Role { 13 - pub fn from_str(s: &str) -> Option<Self> { 14 - match s { 15 - "read" => Some(Role::Read), 16 - "write" => Some(Role::Write), 17 - "admin" => Some(Role::Admin), 18 - _ => None, 19 - } 20 - } 21 - 22 - pub fn as_str(&self) -> &'static str { 23 - match self { 24 - Role::Read => "read", 25 - Role::Write => "write", 26 - Role::Admin => "admin", 27 - } 28 - } 29 - } 30 - 31 - /// Check if user has at least the required role for a repo 32 - pub async fn check_role( 33 - state: &AppState, 34 - user: &AuthenticatedUser, 35 - repo_id: &str, 36 - required: Role, 37 - ) -> Result<(), StatusCode> { 38 - let role_str = state.db.get_user_role(&user.id, repo_id).await 39 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 40 - 41 - let role = role_str 42 - .and_then(|s| Role::from_str(&s)) 43 - .ok_or(StatusCode::FORBIDDEN)?; 44 - 45 - if role >= required { 46 - Ok(()) 47 - } else { 48 - Err(StatusCode::FORBIDDEN) 49 - } 50 - } 51 - 52 - /// Helper to require read access 53 - pub async fn require_read( 54 - state: &AppState, 55 - user: &AuthenticatedUser, 56 - repo_id: &str, 57 - ) -> Result<(), StatusCode> { 58 - check_role(state, user, repo_id, Role::Read).await 59 - } 60 - 61 - /// Helper to require write access 62 - pub async fn require_write( 63 - state: &AppState, 64 - user: &AuthenticatedUser, 65 - repo_id: &str, 66 - ) -> Result<(), StatusCode> { 67 - check_role(state, user, repo_id, Role::Write).await 68 - } 69 - 70 - /// Helper to require admin access 71 - pub async fn require_admin( 72 - state: &AppState, 73 - user: &AuthenticatedUser, 74 - repo_id: &str, 75 - ) -> Result<(), StatusCode> { 76 - check_role(state, user, repo_id, Role::Admin).await 77 - } 78 - 79 - /// Check if a bookmark is protected 80 - pub async fn is_bookmark_protected( 81 - state: &AppState, 82 - repo_id: &str, 83 - bookmark_name: &str, 84 - ) -> Result<bool, StatusCode> { 85 - let doc = state.docs.get_or_load(repo_id).await 86 - .map_err(|_| StatusCode::NOT_FOUND)?; 87 - 88 - let doc = doc.read().await; 89 - let _bookmarks = doc.get_all_bookmarks(); 90 - 91 - // For now, consider bookmarks named "main" or "master" as protected 92 - // TODO: Make this configurable per-repo 93 - Ok(matches!(bookmark_name, "main" | "master")) 94 - } 95 - 96 - /// Check if user can move a bookmark 97 - pub async fn can_move_bookmark( 98 - state: &AppState, 99 - user: &AuthenticatedUser, 100 - repo_id: &str, 101 - bookmark_name: &str, 102 - ) -> Result<(), StatusCode> { 103 - let protected = is_bookmark_protected(state, repo_id, bookmark_name).await?; 104 - 105 - if protected { 106 - require_admin(state, user, repo_id).await 107 - } else { 108 - require_write(state, user, repo_id).await 109 - } 110 - }
-229
crates/tandem-server/src/db.rs
··· 1 - use chrono::{DateTime, Utc}; 2 - use sqlx::{FromRow, sqlite::SqlitePool}; 3 - 4 - #[derive(Debug, Clone, FromRow)] 5 - pub struct RepoRow { 6 - pub id: String, 7 - pub name: String, 8 - pub org: String, 9 - pub created_at: DateTime<Utc>, 10 - } 11 - 12 - #[derive(Debug, Clone, FromRow)] 13 - pub struct UserRow { 14 - pub id: String, 15 - pub email: String, 16 - pub name: Option<String>, 17 - pub password_hash: String, 18 - pub created_at: DateTime<Utc>, 19 - } 20 - 21 - #[derive(Debug, Clone, FromRow)] 22 - pub struct RepoAccessRow { 23 - pub repo_id: String, 24 - pub user_id: String, 25 - pub role: String, 26 - } 27 - 28 - #[derive(Debug, Clone, FromRow)] 29 - pub struct AuthTokenRow { 30 - pub token: String, 31 - pub user_id: String, 32 - pub expires_at: DateTime<Utc>, 33 - } 34 - 35 - #[derive(Clone)] 36 - pub struct Database { 37 - pool: SqlitePool, 38 - } 39 - 40 - impl Database { 41 - pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { 42 - let pool = SqlitePool::connect(database_url).await?; 43 - Ok(Self { pool }) 44 - } 45 - 46 - pub async fn run_migrations(&self) -> Result<(), sqlx::Error> { 47 - sqlx::query(include_str!("../migrations/001_init.sql")) 48 - .execute(&self.pool) 49 - .await?; 50 - Ok(()) 51 - } 52 - 53 - // Repo operations 54 - pub async fn list_repos(&self) -> Result<Vec<RepoRow>, sqlx::Error> { 55 - sqlx::query_as::<_, RepoRow>( 56 - "SELECT id, name, org, created_at FROM repos ORDER BY created_at DESC", 57 - ) 58 - .fetch_all(&self.pool) 59 - .await 60 - } 61 - 62 - /// List repos that a user has access to 63 - pub async fn list_repos_for_user(&self, user_id: &str) -> Result<Vec<RepoRow>, sqlx::Error> { 64 - sqlx::query_as::<_, RepoRow>( 65 - r#" 66 - SELECT r.id, r.name, r.org, r.created_at 67 - FROM repos r 68 - INNER JOIN repo_access ra ON r.id = ra.repo_id 69 - WHERE ra.user_id = ? 70 - ORDER BY r.created_at DESC 71 - "# 72 - ) 73 - .bind(user_id) 74 - .fetch_all(&self.pool) 75 - .await 76 - } 77 - 78 - pub async fn get_repo(&self, id: &str) -> Result<Option<RepoRow>, sqlx::Error> { 79 - sqlx::query_as::<_, RepoRow>("SELECT id, name, org, created_at FROM repos WHERE id = ?") 80 - .bind(id) 81 - .fetch_optional(&self.pool) 82 - .await 83 - } 84 - 85 - pub async fn create_repo( 86 - &self, 87 - id: &str, 88 - name: &str, 89 - org: &str, 90 - ) -> Result<RepoRow, sqlx::Error> { 91 - let now = Utc::now(); 92 - sqlx::query("INSERT INTO repos (id, name, org, created_at) VALUES (?, ?, ?, ?)") 93 - .bind(id) 94 - .bind(name) 95 - .bind(org) 96 - .bind(now.to_rfc3339()) 97 - .execute(&self.pool) 98 - .await?; 99 - 100 - Ok(RepoRow { 101 - id: id.to_string(), 102 - name: name.to_string(), 103 - org: org.to_string(), 104 - created_at: now, 105 - }) 106 - } 107 - 108 - pub async fn delete_repo(&self, id: &str) -> Result<bool, sqlx::Error> { 109 - let result = sqlx::query("DELETE FROM repos WHERE id = ?") 110 - .bind(id) 111 - .execute(&self.pool) 112 - .await?; 113 - Ok(result.rows_affected() > 0) 114 - } 115 - 116 - // User operations 117 - pub async fn get_user(&self, id: &str) -> Result<Option<UserRow>, sqlx::Error> { 118 - sqlx::query_as::<_, UserRow>( 119 - "SELECT id, email, name, password_hash, created_at FROM users WHERE id = ?", 120 - ) 121 - .bind(id) 122 - .fetch_optional(&self.pool) 123 - .await 124 - } 125 - 126 - pub async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRow>, sqlx::Error> { 127 - sqlx::query_as::<_, UserRow>( 128 - "SELECT id, email, name, password_hash, created_at FROM users WHERE email = ?", 129 - ) 130 - .bind(email) 131 - .fetch_optional(&self.pool) 132 - .await 133 - } 134 - 135 - pub async fn create_user( 136 - &self, 137 - id: &str, 138 - email: &str, 139 - name: Option<&str>, 140 - password_hash: &str, 141 - ) -> Result<UserRow, sqlx::Error> { 142 - let now = Utc::now(); 143 - sqlx::query( 144 - "INSERT INTO users (id, email, name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", 145 - ) 146 - .bind(id) 147 - .bind(email) 148 - .bind(name) 149 - .bind(password_hash) 150 - .bind(now.to_rfc3339()) 151 - .execute(&self.pool) 152 - .await?; 153 - 154 - Ok(UserRow { 155 - id: id.to_string(), 156 - email: email.to_string(), 157 - name: name.map(String::from), 158 - password_hash: password_hash.to_string(), 159 - created_at: now, 160 - }) 161 - } 162 - 163 - // Access control 164 - pub async fn get_user_role( 165 - &self, 166 - user_id: &str, 167 - repo_id: &str, 168 - ) -> Result<Option<String>, sqlx::Error> { 169 - let result = sqlx::query_as::<_, (String,)>( 170 - "SELECT role FROM repo_access WHERE user_id = ? AND repo_id = ?", 171 - ) 172 - .bind(user_id) 173 - .bind(repo_id) 174 - .fetch_optional(&self.pool) 175 - .await?; 176 - Ok(result.map(|r| r.0)) 177 - } 178 - 179 - pub async fn set_user_role( 180 - &self, 181 - user_id: &str, 182 - repo_id: &str, 183 - role: &str, 184 - ) -> Result<(), sqlx::Error> { 185 - sqlx::query("INSERT OR REPLACE INTO repo_access (user_id, repo_id, role) VALUES (?, ?, ?)") 186 - .bind(user_id) 187 - .bind(repo_id) 188 - .bind(role) 189 - .execute(&self.pool) 190 - .await?; 191 - Ok(()) 192 - } 193 - 194 - // Auth tokens 195 - pub async fn create_token( 196 - &self, 197 - token: &str, 198 - user_id: &str, 199 - expires_at: DateTime<Utc>, 200 - ) -> Result<(), sqlx::Error> { 201 - sqlx::query("INSERT INTO auth_tokens (token, user_id, expires_at) VALUES (?, ?, ?)") 202 - .bind(token) 203 - .bind(user_id) 204 - .bind(expires_at.to_rfc3339()) 205 - .execute(&self.pool) 206 - .await?; 207 - Ok(()) 208 - } 209 - 210 - pub async fn verify_token(&self, token: &str) -> Result<Option<UserRow>, sqlx::Error> { 211 - sqlx::query_as::<_, UserRow>( 212 - "SELECT u.id, u.email, u.name, u.password_hash, u.created_at 213 - FROM users u 214 - INNER JOIN auth_tokens t ON u.id = t.user_id 215 - WHERE t.token = ? AND t.expires_at > datetime('now')", 216 - ) 217 - .bind(token) 218 - .fetch_optional(&self.pool) 219 - .await 220 - } 221 - 222 - pub async fn delete_token(&self, token: &str) -> Result<(), sqlx::Error> { 223 - sqlx::query("DELETE FROM auth_tokens WHERE token = ?") 224 - .bind(token) 225 - .execute(&self.pool) 226 - .await?; 227 - Ok(()) 228 - } 229 - }
-131
crates/tandem-server/src/docs.rs
··· 1 - use std::collections::HashMap; 2 - use std::path::{Path, PathBuf}; 3 - use std::sync::Arc; 4 - use tandem_core::sync::ForgeDoc; 5 - use tokio::sync::RwLock; 6 - use yrs::{ReadTxn, StateVector, Transact}; 7 - 8 - #[derive(Debug, thiserror::Error)] 9 - pub enum DocError { 10 - #[error("Repository not found: {0}")] 11 - NotFound(String), 12 - #[error("IO error: {0}")] 13 - Io(#[from] std::io::Error), 14 - #[error("Serialization error: {0}")] 15 - Serialization(String), 16 - } 17 - 18 - /// Manages Y.Doc instances for all repositories 19 - pub struct DocManager { 20 - /// Directory where doc files are stored 21 - data_dir: PathBuf, 22 - /// In-memory cache of loaded docs 23 - docs: RwLock<HashMap<String, Arc<RwLock<ForgeDoc>>>>, 24 - } 25 - 26 - impl DocManager { 27 - pub fn new(data_dir: impl AsRef<Path>) -> Self { 28 - Self { 29 - data_dir: data_dir.as_ref().to_path_buf(), 30 - docs: RwLock::new(HashMap::new()), 31 - } 32 - } 33 - 34 - /// Get or load a ForgeDoc for a repository 35 - pub async fn get_or_load(&self, repo_id: &str) -> Result<Arc<RwLock<ForgeDoc>>, DocError> { 36 - // Check cache first 37 - { 38 - let docs = self.docs.read().await; 39 - if let Some(doc) = docs.get(repo_id) { 40 - return Ok(Arc::clone(doc)); 41 - } 42 - } 43 - 44 - // Load from disk or create new 45 - let doc = self.load_or_create(repo_id).await?; 46 - let doc = Arc::new(RwLock::new(doc)); 47 - 48 - // Cache it 49 - { 50 - let mut docs = self.docs.write().await; 51 - docs.insert(repo_id.to_string(), Arc::clone(&doc)); 52 - } 53 - 54 - Ok(doc) 55 - } 56 - 57 - /// Create a new doc for a repository 58 - pub async fn create(&self, repo_id: &str) -> Result<Arc<RwLock<ForgeDoc>>, DocError> { 59 - let doc = ForgeDoc::new(); 60 - let doc = Arc::new(RwLock::new(doc)); 61 - 62 - // Cache it 63 - { 64 - let mut docs = self.docs.write().await; 65 - docs.insert(repo_id.to_string(), Arc::clone(&doc)); 66 - } 67 - 68 - // Save to disk 69 - self.save(repo_id).await?; 70 - 71 - Ok(doc) 72 - } 73 - 74 - /// Save a doc to disk 75 - pub async fn save(&self, repo_id: &str) -> Result<(), DocError> { 76 - let docs = self.docs.read().await; 77 - let doc = docs 78 - .get(repo_id) 79 - .ok_or_else(|| DocError::NotFound(repo_id.to_string()))?; 80 - 81 - let doc = doc.read().await; 82 - 83 - // Encode the full document state 84 - let state = { 85 - let txn = doc.doc().transact(); 86 - txn.encode_diff_v1(&StateVector::default()) 87 - }; 88 - 89 - // Write to file 90 - let path = self.doc_path(repo_id); 91 - if let Some(parent) = path.parent() { 92 - tokio::fs::create_dir_all(parent).await?; 93 - } 94 - tokio::fs::write(&path, state).await?; 95 - 96 - Ok(()) 97 - } 98 - 99 - /// Save all docs to disk 100 - pub async fn save_all(&self) -> Result<(), DocError> { 101 - let repo_ids: Vec<String> = { 102 - let docs = self.docs.read().await; 103 - docs.keys().cloned().collect() 104 - }; 105 - 106 - for repo_id in repo_ids { 107 - self.save(&repo_id).await?; 108 - } 109 - 110 - Ok(()) 111 - } 112 - 113 - /// Load doc from disk or create new 114 - async fn load_or_create(&self, repo_id: &str) -> Result<ForgeDoc, DocError> { 115 - let path = self.doc_path(repo_id); 116 - 117 - if path.exists() { 118 - let data = tokio::fs::read(&path).await?; 119 - let doc = ForgeDoc::new(); 120 - doc.apply_update(&data) 121 - .map_err(|e| DocError::Serialization(e.to_string()))?; 122 - Ok(doc) 123 - } else { 124 - Ok(ForgeDoc::new()) 125 - } 126 - } 127 - 128 - fn doc_path(&self, repo_id: &str) -> PathBuf { 129 - self.data_dir.join(format!("{}.yrs", repo_id)) 130 - } 131 - }
-152
crates/tandem-server/src/events.rs
··· 1 - use axum::{ 2 - extract::{ws::{Message, WebSocket, WebSocketUpgrade}, Path, State}, 3 - response::IntoResponse, 4 - }; 5 - use futures_util::{SinkExt, StreamExt}; 6 - use serde::Serialize; 7 - use tokio::sync::{broadcast, RwLock}; 8 - use std::collections::HashMap; 9 - use crate::AppState; 10 - 11 - /// Events sent to web UI clients 12 - #[derive(Debug, Clone, Serialize)] 13 - #[serde(tag = "type", rename_all = "snake_case")] 14 - pub enum Event { 15 - ChangeUpdated { 16 - change_id: String, 17 - record_id: String, 18 - }, 19 - BookmarkMoved { 20 - name: String, 21 - target: String, 22 - }, 23 - PresenceChanged { 24 - user_id: String, 25 - change_id: Option<String>, 26 - }, 27 - Connected { 28 - repo_id: String, 29 - }, 30 - } 31 - 32 - /// Manages event subscriptions for web UI clients 33 - pub struct EventManager { 34 - channels: RwLock<HashMap<String, broadcast::Sender<Event>>>, 35 - } 36 - 37 - impl EventManager { 38 - pub fn new() -> Self { 39 - Self { 40 - channels: RwLock::new(HashMap::new()), 41 - } 42 - } 43 - 44 - /// Subscribe to events for a repository 45 - pub async fn subscribe(&self, repo_id: &str) -> broadcast::Receiver<Event> { 46 - let mut channels = self.channels.write().await; 47 - 48 - if let Some(sender) = channels.get(repo_id) { 49 - sender.subscribe() 50 - } else { 51 - let (tx, rx) = broadcast::channel(100); 52 - channels.insert(repo_id.to_string(), tx); 53 - rx 54 - } 55 - } 56 - 57 - /// Broadcast an event to all subscribers of a repository 58 - pub async fn broadcast(&self, repo_id: &str, event: Event) { 59 - let channels = self.channels.read().await; 60 - if let Some(sender) = channels.get(repo_id) { 61 - let _ = sender.send(event); 62 - } 63 - } 64 - 65 - /// Emit change updated event 66 - pub async fn emit_change_updated(&self, repo_id: &str, change_id: &str, record_id: &str) { 67 - self.broadcast(repo_id, Event::ChangeUpdated { 68 - change_id: change_id.to_string(), 69 - record_id: record_id.to_string(), 70 - }).await; 71 - } 72 - 73 - /// Emit bookmark moved event 74 - pub async fn emit_bookmark_moved(&self, repo_id: &str, name: &str, target: &str) { 75 - self.broadcast(repo_id, Event::BookmarkMoved { 76 - name: name.to_string(), 77 - target: target.to_string(), 78 - }).await; 79 - } 80 - 81 - /// Emit presence changed event 82 - pub async fn emit_presence_changed(&self, repo_id: &str, user_id: &str, change_id: Option<&str>) { 83 - self.broadcast(repo_id, Event::PresenceChanged { 84 - user_id: user_id.to_string(), 85 - change_id: change_id.map(|s| s.to_string()), 86 - }).await; 87 - } 88 - } 89 - 90 - impl Default for EventManager { 91 - fn default() -> Self { 92 - Self::new() 93 - } 94 - } 95 - 96 - /// WebSocket handler for web UI events 97 - pub async fn events_handler( 98 - ws: WebSocketUpgrade, 99 - Path(repo_id): Path<String>, 100 - State(state): State<AppState>, 101 - ) -> impl IntoResponse { 102 - ws.on_upgrade(move |socket| handle_events(socket, repo_id, state)) 103 - } 104 - 105 - async fn handle_events(socket: WebSocket, repo_id: String, state: AppState) { 106 - let (mut sender, mut receiver) = socket.split(); 107 - 108 - // Subscribe to events for this repo 109 - let mut event_rx = state.events.subscribe(&repo_id).await; 110 - 111 - tracing::info!("Web UI client connected to events for repo {}", repo_id); 112 - 113 - // Send connected event 114 - let connected = Event::Connected { repo_id: repo_id.clone() }; 115 - if let Ok(json) = serde_json::to_string(&connected) { 116 - let _ = sender.send(Message::Text(json)).await; 117 - } 118 - 119 - loop { 120 - tokio::select! { 121 - // Forward events to client 122 - Ok(event) = event_rx.recv() => { 123 - if let Ok(json) = serde_json::to_string(&event) { 124 - if let Err(e) = sender.send(Message::Text(json)).await { 125 - tracing::error!("Failed to send event: {}", e); 126 - break; 127 - } 128 - } 129 - } 130 - // Handle incoming messages (ping/pong, close) 131 - Some(msg) = receiver.next() => { 132 - match msg { 133 - Ok(Message::Close(_)) => { 134 - tracing::info!("Web UI client disconnected from repo {}", repo_id); 135 - break; 136 - } 137 - Ok(Message::Ping(data)) => { 138 - if let Err(e) = sender.send(Message::Pong(data)).await { 139 - tracing::error!("Failed to send pong: {}", e); 140 - break; 141 - } 142 - } 143 - Err(e) => { 144 - tracing::error!("WebSocket error: {}", e); 145 - break; 146 - } 147 - _ => {} 148 - } 149 - } 150 - } 151 - } 152 - }
-55
crates/tandem-server/src/handlers/bookmarks.rs
··· 1 - use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 - use serde::{Deserialize, Serialize}; 3 - use crate::{AppState, auth::AuthenticatedUser, authz}; 4 - 5 - #[derive(Serialize)] 6 - pub struct BookmarkResponse { 7 - pub name: String, 8 - pub target: String, 9 - } 10 - 11 - #[derive(Deserialize)] 12 - pub struct MoveBookmarkRequest { 13 - pub name: String, 14 - pub target: String, 15 - } 16 - 17 - pub async fn list_bookmarks( 18 - State(state): State<AppState>, 19 - Path(repo_id): Path<String>, 20 - user: AuthenticatedUser, 21 - ) -> Result<Json<Vec<BookmarkResponse>>, StatusCode> { 22 - authz::require_read(&state, &user, &repo_id).await?; 23 - 24 - let doc = state.docs.get_or_load(&repo_id).await 25 - .map_err(|_| StatusCode::NOT_FOUND)?; 26 - 27 - let doc = doc.read().await; 28 - let bookmarks = doc.get_all_bookmarks(); 29 - 30 - Ok(Json(bookmarks.into_iter().map(|(name, target)| BookmarkResponse { 31 - name, 32 - target: target.to_string(), 33 - }).collect())) 34 - } 35 - 36 - pub async fn move_bookmark( 37 - State(state): State<AppState>, 38 - Path(repo_id): Path<String>, 39 - user: AuthenticatedUser, 40 - Json(req): Json<MoveBookmarkRequest>, 41 - ) -> Result<StatusCode, StatusCode> { 42 - authz::can_move_bookmark(&state, &user, &repo_id, &req.name).await?; 43 - 44 - let doc = state.docs.get_or_load(&repo_id).await 45 - .map_err(|_| StatusCode::NOT_FOUND)?; 46 - 47 - // Parse target change_id 48 - let target: tandem_core::types::ChangeId = req.target.parse() 49 - .map_err(|_| StatusCode::BAD_REQUEST)?; 50 - 51 - let doc = doc.read().await; 52 - doc.set_bookmark(&req.name, &target); 53 - 54 - Ok(StatusCode::OK) 55 - }
-90
crates/tandem-server/src/handlers/changes.rs
··· 1 - use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 - use serde::Serialize; 3 - use std::collections::HashMap; 4 - use crate::{AppState, auth::AuthenticatedUser, authz}; 5 - 6 - #[derive(Serialize)] 7 - pub struct ChangeResponse { 8 - pub change_id: String, 9 - pub tree: String, 10 - pub parents: Vec<String>, 11 - pub description: String, 12 - pub author_email: String, 13 - pub author_name: Option<String>, 14 - pub timestamp: String, 15 - pub divergent: bool, 16 - } 17 - 18 - pub async fn list_changes( 19 - State(state): State<AppState>, 20 - Path(repo_id): Path<String>, 21 - user: AuthenticatedUser, 22 - ) -> Result<Json<Vec<ChangeResponse>>, StatusCode> { 23 - authz::require_read(&state, &user, &repo_id).await?; 24 - 25 - let doc = state.docs.get_or_load(&repo_id).await 26 - .map_err(|_| StatusCode::NOT_FOUND)?; 27 - 28 - let doc = doc.read().await; 29 - let records = doc.get_all_change_records(); 30 - 31 - // Group by change_id to detect divergence 32 - let mut by_id: HashMap<String, Vec<_>> = HashMap::new(); 33 - for record in records { 34 - if record.visible { 35 - by_id.entry(record.change_id.to_string()) 36 - .or_default() 37 - .push(record); 38 - } 39 - } 40 - 41 - let changes: Vec<ChangeResponse> = by_id.into_iter().map(|(id, records)| { 42 - let record = &records[0]; 43 - ChangeResponse { 44 - change_id: id, 45 - tree: record.tree.to_string(), 46 - parents: record.parents.iter().map(|p| p.to_string()).collect(), 47 - description: record.description.clone(), 48 - author_email: record.author.email.clone(), 49 - author_name: record.author.name.clone(), 50 - timestamp: record.timestamp.to_rfc3339(), 51 - divergent: records.len() > 1, 52 - } 53 - }).collect(); 54 - 55 - Ok(Json(changes)) 56 - } 57 - 58 - pub async fn get_change( 59 - State(state): State<AppState>, 60 - Path((repo_id, change_id)): Path<(String, String)>, 61 - user: AuthenticatedUser, 62 - ) -> Result<Json<ChangeResponse>, StatusCode> { 63 - authz::require_read(&state, &user, &repo_id).await?; 64 - 65 - let doc = state.docs.get_or_load(&repo_id).await 66 - .map_err(|_| StatusCode::NOT_FOUND)?; 67 - 68 - let doc = doc.read().await; 69 - 70 - // Parse change_id 71 - let cid: tandem_core::types::ChangeId = change_id.parse() 72 - .map_err(|_| StatusCode::BAD_REQUEST)?; 73 - 74 - let records = doc.get_change_records(&cid); 75 - if records.is_empty() { 76 - return Err(StatusCode::NOT_FOUND); 77 - } 78 - 79 - let record = &records[0]; 80 - Ok(Json(ChangeResponse { 81 - change_id: record.change_id.to_string(), 82 - tree: record.tree.to_string(), 83 - parents: record.parents.iter().map(|p| p.to_string()).collect(), 84 - description: record.description.clone(), 85 - author_email: record.author.email.clone(), 86 - author_name: record.author.name.clone(), 87 - timestamp: record.timestamp.to_rfc3339(), 88 - divergent: records.len() > 1, 89 - })) 90 - }
-20
crates/tandem-server/src/handlers/content.rs
··· 1 - use axum::{extract::{Path, State}, http::StatusCode, body::Bytes}; 2 - use crate::{AppState, auth::AuthenticatedUser, authz}; 3 - 4 - pub async fn get_content( 5 - State(state): State<AppState>, 6 - Path((repo_id, hash)): Path<(String, String)>, 7 - user: AuthenticatedUser, 8 - ) -> Result<Bytes, StatusCode> { 9 - authz::require_read(&state, &user, &repo_id).await?; 10 - 11 - let doc = state.docs.get_or_load(&repo_id).await 12 - .map_err(|_| StatusCode::NOT_FOUND)?; 13 - 14 - let doc = doc.read().await; 15 - 16 - let content = doc.get_content(&hash) 17 - .ok_or(StatusCode::NOT_FOUND)?; 18 - 19 - Ok(Bytes::from(content)) 20 - }
-5
crates/tandem-server/src/handlers/mod.rs
··· 1 - pub mod repos; 2 - pub mod changes; 3 - pub mod bookmarks; 4 - pub mod presence; 5 - pub mod content;
-32
crates/tandem-server/src/handlers/presence.rs
··· 1 - use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 - use serde::Serialize; 3 - use crate::{AppState, auth::AuthenticatedUser, authz}; 4 - 5 - #[derive(Serialize)] 6 - pub struct PresenceResponse { 7 - pub user_id: String, 8 - pub change_id: String, 9 - pub device: String, 10 - pub timestamp: String, 11 - } 12 - 13 - pub async fn get_presence( 14 - State(state): State<AppState>, 15 - Path(repo_id): Path<String>, 16 - user: AuthenticatedUser, 17 - ) -> Result<Json<Vec<PresenceResponse>>, StatusCode> { 18 - authz::require_read(&state, &user, &repo_id).await?; 19 - 20 - let doc = state.docs.get_or_load(&repo_id).await 21 - .map_err(|_| StatusCode::NOT_FOUND)?; 22 - 23 - let doc = doc.read().await; 24 - let presence_list = doc.get_all_presence(); 25 - 26 - Ok(Json(presence_list.into_iter().map(|p| PresenceResponse { 27 - user_id: p.user_id, 28 - change_id: p.change_id.to_string(), 29 - device: p.device, 30 - timestamp: p.timestamp.to_rfc3339(), 31 - }).collect())) 32 - }
-78
crates/tandem-server/src/handlers/repos.rs
··· 1 - use axum::{extract::{Path, State}, http::StatusCode, Json}; 2 - use serde::{Deserialize, Serialize}; 3 - use crate::{AppState, auth::AuthenticatedUser, authz}; 4 - 5 - #[derive(Serialize)] 6 - pub struct RepoResponse { 7 - pub id: String, 8 - pub name: String, 9 - pub org: String, 10 - pub created_at: String, 11 - } 12 - 13 - #[derive(Deserialize)] 14 - pub struct CreateRepoRequest { 15 - pub name: String, 16 - pub org: String, 17 - } 18 - 19 - pub async fn list_repos( 20 - State(state): State<AppState>, 21 - user: AuthenticatedUser, 22 - ) -> Result<Json<Vec<RepoResponse>>, StatusCode> { 23 - // Filter by user access 24 - let repos = state.db.list_repos_for_user(&user.id).await 25 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 26 - 27 - Ok(Json(repos.into_iter().map(|r| RepoResponse { 28 - id: r.id, 29 - name: r.name, 30 - org: r.org, 31 - created_at: r.created_at.to_rfc3339(), 32 - }).collect())) 33 - } 34 - 35 - pub async fn create_repo( 36 - State(state): State<AppState>, 37 - user: AuthenticatedUser, 38 - Json(req): Json<CreateRepoRequest>, 39 - ) -> Result<Json<RepoResponse>, StatusCode> { 40 - let id = uuid::Uuid::new_v4().to_string(); 41 - 42 - let repo = state.db.create_repo(&id, &req.name, &req.org).await 43 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 44 - 45 - // Grant admin access to creator 46 - state.db.set_user_role(&user.id, &id, "admin").await 47 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 48 - 49 - // Create empty doc for repo 50 - state.docs.create(&id).await 51 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 52 - 53 - Ok(Json(RepoResponse { 54 - id: repo.id, 55 - name: repo.name, 56 - org: repo.org, 57 - created_at: repo.created_at.to_rfc3339(), 58 - })) 59 - } 60 - 61 - pub async fn get_repo( 62 - State(state): State<AppState>, 63 - Path(id): Path<String>, 64 - user: AuthenticatedUser, 65 - ) -> Result<Json<RepoResponse>, StatusCode> { 66 - authz::require_read(&state, &user, &id).await?; 67 - 68 - let repo = state.db.get_repo(&id).await 69 - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? 70 - .ok_or(StatusCode::NOT_FOUND)?; 71 - 72 - Ok(Json(RepoResponse { 73 - id: repo.id, 74 - name: repo.name, 75 - org: repo.org, 76 - created_at: repo.created_at.to_rfc3339(), 77 - })) 78 - }
-161
crates/tandem-server/src/main.rs
··· 1 - //! Tandem Server 2 - //! 3 - //! Forge server built with Axum, Yrs, and SQLite 4 - 5 - mod auth; 6 - mod authz; 7 - mod db; 8 - mod docs; 9 - mod events; 10 - mod handlers; 11 - mod sync; 12 - 13 - use axum::{ 14 - Json, Router, 15 - middleware, 16 - routing::{get, post}, 17 - }; 18 - use std::sync::Arc; 19 - use tower_http::cors::CorsLayer; 20 - use tower_http::trace::TraceLayer; 21 - 22 - use auth::{auth_middleware, get_me, login}; 23 - use db::Database; 24 - use docs::DocManager; 25 - use events::EventManager; 26 - use sync::SyncManager; 27 - 28 - #[derive(Clone)] 29 - pub struct AppState { 30 - db: Database, 31 - docs: Arc<DocManager>, 32 - events: Arc<EventManager>, 33 - sync: Arc<SyncManager>, 34 - } 35 - 36 - #[tokio::main] 37 - async fn main() { 38 - tracing_subscriber::fmt() 39 - .with_env_filter( 40 - tracing_subscriber::EnvFilter::try_from_default_env() 41 - .unwrap_or_else(|_| "tandem_server=debug,tower_http=debug".into()), 42 - ) 43 - .init(); 44 - 45 - let database_url = 46 - std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:tandem.db".to_string()); 47 - 48 - let db = Database::new(&database_url) 49 - .await 50 - .expect("Failed to connect to database"); 51 - 52 - db.run_migrations().await.expect("Failed to run migrations"); 53 - 54 - tracing::info!("Database initialized and migrations applied"); 55 - 56 - let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "data".to_string()); 57 - let docs = Arc::new(DocManager::new(&data_dir)); 58 - tracing::info!("DocManager initialized with data directory: {}", data_dir); 59 - 60 - let events = Arc::new(EventManager::new()); 61 - tracing::info!("EventManager initialized"); 62 - 63 - let sync = Arc::new(SyncManager::new()); 64 - tracing::info!("SyncManager initialized"); 65 - 66 - let state = AppState { 67 - db, 68 - docs: Arc::clone(&docs), 69 - events, 70 - sync, 71 - }; 72 - 73 - let app = Router::new() 74 - .nest("/api", api_routes(state.clone())) 75 - .nest("/sync", sync_routes()) 76 - .nest("/events", event_routes()) 77 - .route("/health", get(health_check)) 78 - .layer(CorsLayer::permissive()) 79 - .layer(TraceLayer::new_for_http()) 80 - .with_state(state); 81 - 82 - let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 83 - println!("Server running on http://localhost:3000"); 84 - 85 - axum::serve(listener, app) 86 - .with_graceful_shutdown(shutdown_signal(docs)) 87 - .await 88 - .unwrap(); 89 - } 90 - 91 - async fn shutdown_signal(docs: Arc<DocManager>) { 92 - let ctrl_c = async { 93 - tokio::signal::ctrl_c() 94 - .await 95 - .expect("Failed to install Ctrl+C handler"); 96 - }; 97 - 98 - #[cfg(unix)] 99 - let terminate = async { 100 - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 101 - .expect("Failed to install SIGTERM handler") 102 - .recv() 103 - .await; 104 - }; 105 - 106 - #[cfg(not(unix))] 107 - let terminate = std::future::pending::<()>(); 108 - 109 - tokio::select! { 110 - _ = ctrl_c => { 111 - tracing::info!("Received Ctrl+C, shutting down gracefully"); 112 - }, 113 - _ = terminate => { 114 - tracing::info!("Received SIGTERM, shutting down gracefully"); 115 - }, 116 - } 117 - 118 - tracing::info!("Saving all Y.Doc states to disk"); 119 - if let Err(e) = docs.save_all().await { 120 - tracing::error!("Failed to save docs: {}", e); 121 - } else { 122 - tracing::info!("All Y.Doc states saved successfully"); 123 - } 124 - } 125 - 126 - fn api_routes(state: AppState) -> Router<AppState> { 127 - // Public routes (no authentication required) 128 - let public_routes = Router::new().route("/auth/login", post(login)); 129 - 130 - // Protected routes (authentication required) 131 - let protected_routes = Router::new() 132 - .route("/auth/me", get(get_me)) 133 - .route("/repos", get(handlers::repos::list_repos).post(handlers::repos::create_repo)) 134 - .route("/repos/:id", get(handlers::repos::get_repo)) 135 - .route("/repos/:id/changes", get(handlers::changes::list_changes)) 136 - .route("/repos/:id/changes/:cid", get(handlers::changes::get_change)) 137 - .route( 138 - "/repos/:id/bookmarks", 139 - get(handlers::bookmarks::list_bookmarks).post(handlers::bookmarks::move_bookmark), 140 - ) 141 - .route("/repos/:id/presence", get(handlers::presence::get_presence)) 142 - .route("/repos/:id/content/:hash", get(handlers::content::get_content)) 143 - .route_layer(middleware::from_fn_with_state( 144 - state.clone(), 145 - auth_middleware, 146 - )); 147 - 148 - public_routes.merge(protected_routes) 149 - } 150 - 151 - fn sync_routes() -> Router<AppState> { 152 - Router::new().route("/:repo_id", get(sync::sync_handler)) 153 - } 154 - 155 - fn event_routes() -> Router<AppState> { 156 - Router::new().route("/:repo_id", get(events::events_handler)) 157 - } 158 - 159 - async fn health_check() -> Json<serde_json::Value> { 160 - Json(serde_json::json!({"status": "ok"})) 161 - }
-129
crates/tandem-server/src/sync.rs
··· 1 - use axum::{ 2 - extract::{ws::{Message, WebSocket, WebSocketUpgrade}, Path, State}, 3 - response::IntoResponse, 4 - }; 5 - use futures_util::{SinkExt, StreamExt}; 6 - use std::collections::HashMap; 7 - use tokio::sync::RwLock; 8 - use uuid::Uuid; 9 - use crate::AppState; 10 - 11 - /// WebSocket handler for yrs sync 12 - pub async fn sync_handler( 13 - ws: WebSocketUpgrade, 14 - Path(repo_id): Path<String>, 15 - State(state): State<AppState>, 16 - ) -> impl IntoResponse { 17 - ws.on_upgrade(move |socket| handle_sync(socket, repo_id, state)) 18 - } 19 - 20 - async fn handle_sync(socket: WebSocket, repo_id: String, state: AppState) { 21 - let (mut sender, mut receiver) = socket.split(); 22 - 23 - let doc = match state.docs.get_or_load(&repo_id).await { 24 - Ok(doc) => doc, 25 - Err(e) => { 26 - tracing::error!("Failed to load doc for {}: {}", repo_id, e); 27 - return; 28 - } 29 - }; 30 - 31 - let client_id = Uuid::new_v4(); 32 - let mut broadcast_rx = state.sync.subscribe(&repo_id).await; 33 - 34 - tracing::info!("Client {} connected to sync for repo {}", client_id, repo_id); 35 - 36 - loop { 37 - tokio::select! { 38 - Some(msg) = receiver.next() => { 39 - match msg { 40 - Ok(Message::Binary(data)) => { 41 - let doc = doc.read().await; 42 - 43 - if let Err(_e) = doc.apply_update(&data) { 44 - // Might be a state vector - compute diff and send 45 - let update = doc.encode_update_from(&data); 46 - drop(doc); 47 - if let Err(e) = sender.send(Message::Binary(update)).await { 48 - tracing::error!("Failed to send update: {}", e); 49 - break; 50 - } 51 - } else { 52 - // Successfully applied update 53 - drop(doc); 54 - 55 - // Save to disk 56 - if let Err(e) = state.docs.save(&repo_id).await { 57 - tracing::warn!("Failed to save doc: {}", e); 58 - } 59 - 60 - // Broadcast to other clients 61 - state.sync.broadcast(&repo_id, client_id, data).await; 62 - } 63 - } 64 - Ok(Message::Close(_)) => { 65 - tracing::info!("Client {} disconnected from repo {}", client_id, repo_id); 66 - break; 67 - } 68 - Ok(Message::Ping(data)) => { 69 - if let Err(e) = sender.send(Message::Pong(data)).await { 70 - tracing::error!("Failed to send pong: {}", e); 71 - break; 72 - } 73 - } 74 - Err(e) => { 75 - tracing::error!("WebSocket error: {}", e); 76 - break; 77 - } 78 - _ => {} 79 - } 80 - } 81 - Ok(msg) = broadcast_rx.recv() => { 82 - // Don't echo back to sender 83 - if msg.sender_id != client_id { 84 - if let Err(e) = sender.send(Message::Binary(msg.data)).await { 85 - tracing::error!("Failed to forward broadcast: {}", e); 86 - break; 87 - } 88 - } 89 - } 90 - } 91 - } 92 - } 93 - 94 - /// Message wrapper that includes sender ID to prevent echo 95 - #[derive(Clone, Debug)] 96 - pub(crate) struct BroadcastMessage { 97 - sender_id: Uuid, 98 - data: Vec<u8>, 99 - } 100 - 101 - /// Track connected clients for broadcasting 102 - pub struct SyncManager { 103 - channels: RwLock<HashMap<String, tokio::sync::broadcast::Sender<BroadcastMessage>>>, 104 - } 105 - 106 - impl SyncManager { 107 - pub fn new() -> Self { 108 - Self { 109 - channels: RwLock::new(HashMap::new()), 110 - } 111 - } 112 - 113 - pub async fn subscribe(&self, repo_id: &str) -> tokio::sync::broadcast::Receiver<BroadcastMessage> { 114 - let mut channels = self.channels.write().await; 115 - let tx = channels.entry(repo_id.to_string()).or_insert_with(|| { 116 - let (tx, _rx) = tokio::sync::broadcast::channel(100); 117 - tx 118 - }); 119 - tx.subscribe() 120 - } 121 - 122 - pub async fn broadcast(&self, repo_id: &str, sender_id: Uuid, data: Vec<u8>) { 123 - let channels = self.channels.read().await; 124 - if let Some(tx) = channels.get(repo_id) { 125 - let msg = BroadcastMessage { sender_id, data }; 126 - let _ = tx.send(msg); 127 - } 128 - } 129 - }