Unified · Split
| @@ -0,0 +1,22 @@ | ||
| 1 | +{ | |
| 2 | + "name": "T3 Code Dev", | |
| 3 | + "image": "debian:bookworm", | |
| 4 | + "features": { | |
| 5 | + "ghcr.io/devcontainers-extra/features/bun:1": {}, | |
| 6 | + "ghcr.io/devcontainers/features/node:1": { | |
| 7 | + "version": "24", | |
| 8 | + "nodeGypDependencies": true | |
| 9 | + }, | |
| 10 | + "ghcr.io/devcontainers/features/python:1": { | |
| 11 | + "version": "3.12" | |
| 12 | + } | |
| 13 | + }, | |
| 14 | + "postCreateCommand": { | |
| 15 | + "bun-install": "bun install --backend=copyfile --frozen-lockfile" | |
| 16 | + }, | |
| 17 | + "customizations": { | |
| 18 | + "vscode": { | |
| 19 | + "extensions": ["oxc.oxc-vscode"] | |
| 20 | + } | |
| 21 | + } | |
| 22 | +} | |
| @@ -0,0 +1,302 @@ | ||
| 1 | +# Remote Architecture | |
| 2 | + | |
| 3 | +This document describes the target architecture for first-class remote environments in T3 Code. | |
| 4 | + | |
| 5 | +It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. | |
| 6 | + | |
| 7 | +## Goals | |
| 8 | + | |
| 9 | +- Treat remote environments as first-class product primitives, not special cases. | |
| 10 | +- Support multiple ways to reach the same environment. | |
| 11 | +- Keep the T3 server as the execution boundary. | |
| 12 | +- Let desktop, mobile, and web all share the same conceptual model. | |
| 13 | +- Avoid introducing a local control plane unless product pressure proves it is necessary. | |
| 14 | + | |
| 15 | +## Non-goals | |
| 16 | + | |
| 17 | +- Replacing the existing WebSocket server boundary with a custom transport protocol. | |
| 18 | +- Making SSH the only remote story. | |
| 19 | +- Syncing provider auth across machines. | |
| 20 | +- Shipping every access method in the first iteration. | |
| 21 | + | |
| 22 | +## High-level architecture | |
| 23 | + | |
| 24 | +T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. | |
| 25 | + | |
| 26 | +Remote support should preserve that boundary. | |
| 27 | + | |
| 28 | +```text | |
| 29 | +┌──────────────────────────────────────────────┐ | |
| 30 | +│ Client (desktop / mobile / web) │ | |
| 31 | +│ │ | |
| 32 | +│ - known environments │ | |
| 33 | +│ - connection manager │ | |
| 34 | +│ - environment-aware routing │ | |
| 35 | +└───────────────┬──────────────────────────────┘ | |
| 36 | + │ | |
| 37 | + │ resolves one access endpoint | |
| 38 | + │ | |
| 39 | +┌───────────────▼──────────────────────────────┐ | |
| 40 | +│ Access method │ | |
| 41 | +│ │ | |
| 42 | +│ - direct ws / wss │ | |
| 43 | +│ - tunneled ws / wss │ | |
| 44 | +│ - desktop-managed ssh bootstrap + forward │ | |
| 45 | +└───────────────┬──────────────────────────────┘ | |
| 46 | + │ | |
| 47 | + │ connects to one T3 server | |
| 48 | + │ | |
| 49 | +┌───────────────▼──────────────────────────────┐ | |
| 50 | +│ Execution environment = one T3 server │ | |
| 51 | +│ │ | |
| 52 | +│ - environment identity │ | |
| 53 | +│ - provider state │ | |
| 54 | +│ - projects / threads / terminals │ | |
| 55 | +│ - git / filesystem / process runtime │ | |
| 56 | +└──────────────────────────────────────────────┘ | |
| 57 | +``` | |
| 58 | + | |
| 59 | +The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. | |
| 60 | + | |
| 61 | +## Domain model | |
| 62 | + | |
| 63 | +### ExecutionEnvironment | |
| 64 | + | |
| 65 | +An `ExecutionEnvironment` is one running T3 server instance. | |
| 66 | + | |
| 67 | +It is the unit that owns: | |
| 68 | + | |
| 69 | +- provider availability and auth state | |
| 70 | +- model availability | |
| 71 | +- projects and threads | |
| 72 | +- terminal processes | |
| 73 | +- filesystem access | |
| 74 | +- git operations | |
| 75 | +- server settings | |
| 76 | + | |
| 77 | +It is identified by a stable `environmentId`. | |
| 78 | + | |
| 79 | +This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. | |
| 80 | + | |
| 81 | +### KnownEnvironment | |
| 82 | + | |
| 83 | +A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. | |
| 84 | + | |
| 85 | +It is not server-authored. It is local to a device or client profile. | |
| 86 | + | |
| 87 | +Examples: | |
| 88 | + | |
| 89 | +- a saved LAN URL | |
| 90 | +- a saved public `wss://` endpoint | |
| 91 | +- a desktop-managed SSH host entry | |
| 92 | +- a saved tunneled environment | |
| 93 | + | |
| 94 | +A known environment may or may not know the target `environmentId` before first successful connect. | |
| 95 | + | |
| 96 | +### AccessEndpoint | |
| 97 | + | |
| 98 | +An `AccessEndpoint` is one concrete way to reach a known environment. | |
| 99 | + | |
| 100 | +This is the key abstraction that keeps SSH from taking over the model. | |
| 101 | + | |
| 102 | +A single environment may have many endpoints: | |
| 103 | + | |
| 104 | +- `wss://t3.example.com` | |
| 105 | +- `ws://10.0.0.25:3773` | |
| 106 | +- a tunneled relay URL | |
| 107 | +- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL | |
| 108 | + | |
| 109 | +The environment stays the same. Only the access path changes. | |
| 110 | + | |
| 111 | +### RepositoryIdentity | |
| 112 | + | |
| 113 | +`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. | |
| 114 | + | |
| 115 | +It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. | |
| 116 | + | |
| 117 | +### Workspace / Project | |
| 118 | + | |
| 119 | +The current `Project` model remains environment-local. | |
| 120 | + | |
| 121 | +That means: | |
| 122 | + | |
| 123 | +- a local clone and a remote clone are different projects | |
| 124 | +- they may share a `RepositoryIdentity` | |
| 125 | +- threads still bind to one project in one environment | |
| 126 | + | |
| 127 | +## Access methods | |
| 128 | + | |
| 129 | +Access methods answer one question: | |
| 130 | + | |
| 131 | +How does the client speak WebSocket to a T3 server? | |
| 132 | + | |
| 133 | +They do not answer: | |
| 134 | + | |
| 135 | +- how the server got started | |
| 136 | +- who manages the server process | |
| 137 | +- whether the environment is local or remote | |
| 138 | + | |
| 139 | +### 1. Direct WebSocket access | |
| 140 | + | |
| 141 | +Examples: | |
| 142 | + | |
| 143 | +- `ws://10.0.0.15:3773` | |
| 144 | +- `wss://t3.example.com` | |
| 145 | + | |
| 146 | +This is the base model and should be the first-class default. | |
| 147 | + | |
| 148 | +Benefits: | |
| 149 | + | |
| 150 | +- works for desktop, mobile, and web | |
| 151 | +- no client-specific process management required | |
| 152 | +- best fit for hosted or self-managed remote T3 deployments | |
| 153 | + | |
| 154 | +### 2. Tunneled WebSocket access | |
| 155 | + | |
| 156 | +Examples: | |
| 157 | + | |
| 158 | +- public relay URLs | |
| 159 | +- private network relay URLs | |
| 160 | +- local tunnel products such as pipenet | |
| 161 | + | |
| 162 | +This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. | |
| 163 | + | |
| 164 | +For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. | |
| 165 | + | |
| 166 | +This is especially useful when: | |
| 167 | + | |
| 168 | +- the host is behind NAT | |
| 169 | +- inbound ports are unavailable | |
| 170 | +- mobile must reach a desktop-hosted environment | |
| 171 | +- a machine should be reachable without exposing raw LAN or public ports | |
| 172 | + | |
| 173 | +### 3. Desktop-managed SSH access | |
| 174 | + | |
| 175 | +SSH is an access and launch helper, not a separate environment type. | |
| 176 | + | |
| 177 | +The desktop main process can use SSH to: | |
| 178 | + | |
| 179 | +- reach a machine | |
| 180 | +- probe it | |
| 181 | +- launch or reuse a remote T3 server | |
| 182 | +- establish a local port forward | |
| 183 | + | |
| 184 | +After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. | |
| 185 | + | |
| 186 | +This keeps the renderer transport model consistent with every other access method. | |
| 187 | + | |
| 188 | +## Launch methods | |
| 189 | + | |
| 190 | +Launch methods answer a different question: | |
| 191 | + | |
| 192 | +How does a T3 server come to exist on the target machine? | |
| 193 | + | |
| 194 | +Launch and access should stay separate in the design. | |
| 195 | + | |
| 196 | +### 1. Pre-existing server | |
| 197 | + | |
| 198 | +The simplest launch method is no launch at all. | |
| 199 | + | |
| 200 | +The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. | |
| 201 | + | |
| 202 | +This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. | |
| 203 | + | |
| 204 | +### 2. Desktop-managed remote launch over SSH | |
| 205 | + | |
| 206 | +This is the main place where Zed is a useful reference. | |
| 207 | + | |
| 208 | +Useful ideas to borrow from Zed: | |
| 209 | + | |
| 210 | +- remote probing | |
| 211 | +- platform detection | |
| 212 | +- session directories with pid/log metadata | |
| 213 | +- reconnect-friendly launcher behavior | |
| 214 | +- desktop-owned connection UX | |
| 215 | + | |
| 216 | +What should be different in T3: | |
| 217 | + | |
| 218 | +- no custom stdio/socket proxy protocol between renderer and remote runtime | |
| 219 | +- no attempt to make the remote runtime look like an editor transport | |
| 220 | +- keep the final client-to-server connection as WebSocket | |
| 221 | + | |
| 222 | +The recommended T3 flow is: | |
| 223 | + | |
| 224 | +1. Desktop connects over SSH. | |
| 225 | +2. Desktop probes the remote machine and verifies T3 availability. | |
| 226 | +3. Desktop launches or reuses a remote T3 server. | |
| 227 | +4. Desktop establishes local port forwarding. | |
| 228 | +5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. | |
| 229 | + | |
| 230 | +### 3. Client-managed local publish | |
| 231 | + | |
| 232 | +This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. | |
| 233 | + | |
| 234 | +This is useful for: | |
| 235 | + | |
| 236 | +- exposing a desktop-hosted environment to mobile | |
| 237 | +- temporary remote access without changing router or firewall settings | |
| 238 | + | |
| 239 | +This is still a launch concern, not a new environment kind. | |
| 240 | + | |
| 241 | +## Why access and launch must stay separate | |
| 242 | + | |
| 243 | +These concerns are easy to conflate, but separating them prevents architectural drift. | |
| 244 | + | |
| 245 | +Examples: | |
| 246 | + | |
| 247 | +- A manually hosted T3 server might be reached through direct `wss`. | |
| 248 | +- The same server might also be reachable through a tunnel. | |
| 249 | +- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. | |
| 250 | +- A local desktop server might be published through a tunnel for mobile. | |
| 251 | + | |
| 252 | +In all of those cases, the `ExecutionEnvironment` is the same kind of thing. | |
| 253 | + | |
| 254 | +Only the launch and access paths differ. | |
| 255 | + | |
| 256 | +## Security model | |
| 257 | + | |
| 258 | +Remote support must assume that some environments will be reachable over untrusted networks. | |
| 259 | + | |
| 260 | +That means: | |
| 261 | + | |
| 262 | +- remote-capable environments should require explicit authentication | |
| 263 | +- tunnel exposure should not rely on obscurity | |
| 264 | +- client-saved endpoints should carry enough auth metadata to reconnect safely | |
| 265 | + | |
| 266 | +T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. | |
| 267 | + | |
| 268 | +For publicly reachable environments, authenticated access should be treated as required. | |
| 269 | + | |
| 270 | +## Relationship to Zed | |
| 271 | + | |
| 272 | +Zed is a useful reference implementation for managed remote launch and reconnect behavior. | |
| 273 | + | |
| 274 | +The relevant lessons are: | |
| 275 | + | |
| 276 | +- remote bootstrap should be explicit | |
| 277 | +- reconnect should be first-class | |
| 278 | +- connection UX belongs in the client shell | |
| 279 | +- runtime ownership should stay clearly on the remote host | |
| 280 | + | |
| 281 | +The important mismatch is transport shape. | |
| 282 | + | |
| 283 | +Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. | |
| 284 | + | |
| 285 | +T3 should not copy that part. | |
| 286 | + | |
| 287 | +T3 already has the right runtime boundary: | |
| 288 | + | |
| 289 | +- one T3 server per environment | |
| 290 | +- ordinary HTTP/WebSocket between client and environment | |
| 291 | + | |
| 292 | +So T3 should borrow Zed's launch discipline, not its transport protocol. | |
| 293 | + | |
| 294 | +## Recommended rollout | |
| 295 | + | |
| 296 | +1. First-class known environments and access endpoints. | |
| 297 | +2. Direct `ws` / `wss` remote environments. | |
| 298 | +3. Authenticated tunnel-backed environments. | |
| 299 | +4. Desktop-managed SSH launch and forwarding. | |
| 300 | +5. Multi-environment UI improvements after the base runtime path is proven. | |
| 301 | + | |
| 302 | +This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. | |
| @@ -9,7 +9,8 @@ on: · 8 unmodified lines | ||
| 9 | 9 | jobs: |
| 10 | 10 | quality: |
| 11 | 11 | name: Format, Lint, Typecheck, Test, Browser Test, Build |
| 12 | − runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| 12 | + runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 13 | + timeout-minutes: 10 | |
| 13 | 14 | steps: |
| 14 | 15 | - name: Checkout |
| 15 | 16 | uses: actions/checkout@v6 |
| @@ -70,12 +71,13 @@ jobs: · 54 unmodified lines | ||
| 70 | 71 | |
| 71 | 72 | - name: Verify preload bundle output |
| 72 | 73 | run: | |
| 73 | − test -f apps/desktop/dist-electron/preload.js | |
| 74 | − grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js | |
| 74 | + test -f apps/desktop/dist-electron/preload.cjs | |
| 75 | + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs | |
| 75 | 76 | |
| 76 | 77 | release_smoke: |
| 77 | 78 | name: Release Smoke |
| 78 | − runs-on: ubuntu-24.04 | |
| 79 | + runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 80 | + timeout-minutes: 10 | |
| 79 | 81 | steps: |
| 80 | 82 | - name: Checkout |
| 81 | 83 | uses: actions/checkout@v6 |
| @@ -90,5 +92,8 @@ jobs: · 8 unmodified lines | ||
| 90 | 92 | with: |
| 91 | 93 | node-version-file: package.json |
| 92 | 94 | |
| 95 | + - name: Install dependencies | |
| 96 | + run: bun install --frozen-lockfile | |
| 97 | + | |
| 93 | 98 | - name: Exercise release-only workflow steps |
| 94 | 99 | run: node scripts/release-smoke.ts |
| @@ -1,14 +1,24 @@ | ||
| 1 | −name: Release Desktop | |
| 1 | +name: Release | |
| 2 | 2 | |
| 3 | 3 | on: |
| 4 | 4 | push: |
| 5 | 5 | tags: |
| 6 | 6 | - "v*.*.*" |
| 7 | + schedule: | |
| 8 | + - cron: "0 9 * * *" | |
| 7 | 9 | workflow_dispatch: |
| 8 | 10 | inputs: |
| 11 | + channel: | |
| 12 | + description: "Release channel" | |
| 13 | + required: false | |
| 14 | + default: stable | |
| 15 | + type: choice | |
| 16 | + options: | |
| 17 | + - stable | |
| 18 | + - nightly | |
| 9 | 19 | version: |
| 10 | 20 | description: "Release version (for example 1.2.3 or v1.2.3)" |
| 11 | − required: true | |
| 21 | + required: false | |
| 12 | 22 | type: string |
| 13 | 23 | |
| 14 | 24 | permissions: |
| @@ -18,42 +28,24 @@ permissions: · 3 unmodified lines | ||
| 18 | 28 | jobs: |
| 19 | 29 | preflight: |
| 20 | 30 | name: Preflight |
| 21 | − runs-on: ubuntu-24.04 | |
| 31 | + runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 32 | + timeout-minutes: 10 | |
| 22 | 33 | outputs: |
| 34 | + release_channel: ${{ steps.release_meta.outputs.release_channel }} | |
| 23 | 35 | version: ${{ steps.release_meta.outputs.version }} |
| 24 | 36 | tag: ${{ steps.release_meta.outputs.tag }} |
| 37 | + release_name: ${{ steps.release_meta.outputs.name }} | |
| 38 | + short_sha: ${{ steps.release_meta.outputs.short_sha }} | |
| 39 | + previous_tag: ${{ steps.previous_tag.outputs.previous_tag }} | |
| 40 | + cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }} | |
| 25 | 41 | is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }} |
| 26 | 42 | make_latest: ${{ steps.release_meta.outputs.make_latest }} |
| 27 | 43 | ref: ${{ github.sha }} |
| 28 | 44 | steps: |
| 29 | 45 | - name: Checkout |
| 30 | 46 | uses: actions/checkout@v6 |
| 31 | − | |
| 32 | − - id: release_meta | |
| 33 | − name: Resolve release version | |
| 34 | − shell: bash | |
| 35 | − run: | | |
| 36 | − if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| 37 | − raw="${{ github.event.inputs.version }}" | |
| 38 | − else | |
| 39 | − raw="${GITHUB_REF_NAME}" | |
| 40 | − fi | |
| 41 | − | |
| 42 | − version="${raw#v}" | |
| 43 | − if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then | |
| 44 | − echo "Invalid release version: $raw" >&2 | |
| 45 | − exit 1 | |
| 46 | − fi | |
| 47 | − | |
| 48 | − echo "version=$version" >> "$GITHUB_OUTPUT" | |
| 49 | − echo "tag=v$version" >> "$GITHUB_OUTPUT" | |
| 50 | − if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| 51 | − echo "is_prerelease=false" >> "$GITHUB_OUTPUT" | |
| 52 | − echo "make_latest=true" >> "$GITHUB_OUTPUT" | |
| 53 | − else | |
| 54 | − echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| 55 | − echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| 56 | − fi | |
| 47 | + with: | |
| 48 | + fetch-depth: 0 | |
| 57 | 49 | |
| 58 | 50 | - name: Setup Bun |
| 59 | 51 | uses: oven-sh/setup-bun@v2 |
| @@ -68,6 +60,60 @@ jobs: · 8 unmodified lines | ||
| 68 | 60 | - name: Install dependencies |
| 69 | 61 | run: bun install --frozen-lockfile |
| 70 | 62 | |
| 63 | + - id: release_meta | |
| 64 | + name: Resolve release version | |
| 65 | + shell: bash | |
| 66 | + env: | |
| 67 | + DISPATCH_CHANNEL: ${{ github.event.inputs.channel }} | |
| 68 | + DISPATCH_VERSION: ${{ github.event.inputs.version }} | |
| 69 | + NIGHTLY_DATE: ${{ github.run_started_at }} | |
| 70 | + NIGHTLY_SHA: ${{ github.sha }} | |
| 71 | + NIGHTLY_RUN_NUMBER: ${{ github.run_number }} | |
| 72 | + run: | | |
| 73 | + if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then | |
| 74 | + nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)" | |
| 75 | + | |
| 76 | + node scripts/resolve-nightly-release.ts \ | |
| 77 | + --date "$nightly_date" \ | |
| 78 | + --run-number "$NIGHTLY_RUN_NUMBER" \ | |
| 79 | + --sha "$NIGHTLY_SHA" \ | |
| 80 | + --github-output | |
| 81 | + | |
| 82 | + echo "release_channel=nightly" >> "$GITHUB_OUTPUT" | |
| 83 | + echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT" | |
| 84 | + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| 85 | + echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| 86 | + else | |
| 87 | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then | |
| 88 | + raw="${DISPATCH_VERSION}" | |
| 89 | + if [[ -z "$raw" ]]; then | |
| 90 | + echo "workflow_dispatch stable releases require the version input." >&2 | |
| 91 | + exit 1 | |
| 92 | + fi | |
| 93 | + else | |
| 94 | + raw="${GITHUB_REF_NAME}" | |
| 95 | + fi | |
| 96 | + | |
| 97 | + version="${raw#v}" | |
| 98 | + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then | |
| 99 | + echo "Invalid release version: $raw" >&2 | |
| 100 | + exit 1 | |
| 101 | + fi | |
| 102 | + | |
| 103 | + echo "release_channel=stable" >> "$GITHUB_OUTPUT" | |
| 104 | + echo "version=$version" >> "$GITHUB_OUTPUT" | |
| 105 | + echo "tag=v$version" >> "$GITHUB_OUTPUT" | |
| 106 | + echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT" | |
| 107 | + echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT" | |
| 108 | + if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| 109 | + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" | |
| 110 | + echo "make_latest=true" >> "$GITHUB_OUTPUT" | |
| 111 | + else | |
| 112 | + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" | |
| 113 | + echo "make_latest=false" >> "$GITHUB_OUTPUT" | |
| 114 | + fi | |
| 115 | + fi | |
| 116 | + | |
| 71 | 117 | - name: Lint |
| 72 | 118 | run: bun run lint |
| 73 | 119 | |
| @@ -77,34 +123,48 @@ jobs: · 3 unmodified lines | ||
| 77 | 123 | - name: Test |
| 78 | 124 | run: bun run test |
| 79 | 125 | |
| 126 | + - id: previous_tag | |
| 127 | + name: Resolve previous release tag | |
| 128 | + run: | | |
| 129 | + node scripts/resolve-previous-release-tag.ts \ | |
| 130 | + --channel "${{ steps.release_meta.outputs.release_channel }}" \ | |
| 131 | + --current-tag "${{ steps.release_meta.outputs.tag }}" \ | |
| 132 | + --github-output | |
| 133 | + | |
| 80 | 134 | build: |
| 81 | 135 | name: Build ${{ matrix.label }} |
| 82 | 136 | needs: preflight |
| 83 | 137 | runs-on: ${{ matrix.runner }} |
| 138 | + timeout-minutes: 30 | |
| 84 | 139 | strategy: |
| 85 | 140 | fail-fast: false |
| 86 | 141 | matrix: |
| 87 | 142 | include: |
| 88 | 143 | - label: macOS arm64 |
| 89 | − runner: macos-14 | |
| 144 | + runner: blacksmith-12vcpu-macos-26 | |
| 90 | 145 | platform: mac |
| 91 | 146 | target: dmg |
| 92 | 147 | arch: arm64 |
| 93 | 148 | - label: macOS x64 |
| 94 | − runner: macos-15-intel | |
| 149 | + runner: blacksmith-12vcpu-macos-26 | |
| 95 | 150 | platform: mac |
| 96 | 151 | target: dmg |
| 97 | 152 | arch: x64 |
| 98 | 153 | - label: Linux x64 |
| 99 | − runner: ubuntu-24.04 | |
| 154 | + runner: blacksmith-32vcpu-ubuntu-2404 | |
| 100 | 155 | platform: linux |
| 101 | 156 | target: AppImage |
| 102 | 157 | arch: x64 |
| 103 | 158 | - label: Windows x64 |
| 104 | − runner: windows-2022 | |
| 159 | + runner: windows-2022 # blacksmith-32vcpu-windows-2025 | |
| 105 | 160 | platform: win |
| 106 | 161 | target: nsis |
| 107 | 162 | arch: x64 |
| 163 | + # - label: Windows arm64 | |
| 164 | + # runner: windows-11-arm | |
| 165 | + # platform: win | |
| 166 | + # target: nsis | |
| 167 | + # arch: arm64 | |
| 108 | 168 | steps: |
| 109 | 169 | - name: Checkout |
| 110 | 170 | uses: actions/checkout@v6 |
| @@ -204,18 +264,31 @@ jobs: · 93 unmodified lines | ||
| 204 | 264 | "release/*.AppImage" \ |
| 205 | 265 | "release/*.exe" \ |
| 206 | 266 | "release/*.blockmap" \ |
| 207 | − "release/latest*.yml"; do | |
| 267 | + "release/*.yml"; do | |
| 208 | 268 | for file in $pattern; do |
| 209 | 269 | cp "$file" release-publish/ |
| 210 | 270 | done |
| 211 | 271 | done |
| 212 | 272 | |
| 213 | 273 | if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then |
| 214 | − if [[ -f release-publish/latest-mac.yml ]]; then | |
| 215 | − mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml" | |
| 216 | − fi | |
| 274 | + shopt -s nullglob | |
| 275 | + for manifest in release-publish/*-mac.yml; do | |
| 276 | + mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml" | |
| 277 | + done | |
| 217 | 278 | fi |
| 218 | 279 | |
| 280 | + # Enable if Windows arm64 builds are enabled. | |
| 281 | + # Windows updater metadata is channel-specific (for example | |
| 282 | + # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the | |
| 283 | + # release job can merge matching arm64/x64 manifests back into one | |
| 284 | + # canonical manifest per channel. | |
| 285 | + # if [[ "${{ matrix.platform }}" == "win" ]]; then | |
| 286 | + # shopt -s nullglob | |
| 287 | + # for manifest in release-publish/*.yml; do | |
| 288 | + # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml" | |
| 289 | + # done | |
| 290 | + # fi | |
| 291 | + | |
| 219 | 292 | - name: Upload build artifacts |
| 220 | 293 | uses: actions/upload-artifact@v7 |
| 221 | 294 | with: |
| @@ -226,7 +299,8 @@ jobs: · 4 unmodified lines | ||
| 226 | 299 | publish_cli: |
| 227 | 300 | name: Publish CLI to npm |
| 228 | 301 | needs: [preflight, build] |
| 229 | − runs-on: ubuntu-24.04 | |
| 302 | + runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 | |
| 303 | + timeout-minutes: 10 | |
| 230 | 304 | steps: |
| 231 | 305 | - name: Checkout |
| 232 | 306 | uses: actions/checkout@v6 |
| @@ -254,12 +328,13 @@ jobs: · 21 unmodified lines | ||
| 254 | 328 | run: bun run build --filter=@t3tools/web --filter=t3 |
| 255 | 329 | |
| 256 | 330 | - name: Publish CLI package |
| 257 | − run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| 331 | + run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose | |
| 258 | 332 | |
| 259 | 333 | release: |
| 260 | 334 | name: Publish GitHub Release |
| 261 | 335 | needs: [preflight, build, publish_cli] |
| 262 | − runs-on: ubuntu-24.04 | |
| 336 | + runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 337 | + timeout-minutes: 10 | |
| 263 | 338 | steps: |
| 264 | 339 | - name: Checkout |
| 265 | 340 | uses: actions/checkout@v6 |
| @@ -280,18 +355,53 @@ jobs: · 14 unmodified lines | ||
| 280 | 355 | |
| 281 | 356 | - name: Merge macOS updater manifests |
| 282 | 357 | run: | |
| 283 | − node scripts/merge-mac-update-manifests.ts \ | |
| 284 | − release-assets/latest-mac.yml \ | |
| 285 | − release-assets/latest-mac-x64.yml | |
| 286 | − rm -f release-assets/latest-mac-x64.yml | |
| 358 | + shopt -s nullglob | |
| 359 | + for x64_manifest in release-assets/*-mac-x64.yml; do | |
| 360 | + arm64_manifest="${x64_manifest%-x64.yml}.yml" | |
| 361 | + if [[ -f "$arm64_manifest" ]]; then | |
| 362 | + node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest" | |
| 363 | + rm -f "$x64_manifest" | |
| 364 | + fi | |
| 365 | + done | |
| 366 | + | |
| 367 | + # - name: Merge Windows updater manifests | |
| 368 | + # run: | | |
| 369 | + # shopt -s nullglob | |
| 370 | + # found_windows_manifest=false | |
| 371 | + # for x64_manifest in release-assets/*-win-x64.yml; do | |
| 372 | + # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then | |
| 373 | + # continue | |
| 374 | + # fi | |
| 375 | + | |
| 376 | + # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}" | |
| 377 | + # output_manifest="${x64_manifest/-win-x64.yml/.yml}" | |
| 378 | + # if [[ ! -f "$arm64_manifest" ]]; then | |
| 379 | + # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2 | |
| 380 | + # exit 1 | |
| 381 | + # fi | |
| 382 | + | |
| 383 | + # found_windows_manifest=true | |
| 384 | + # node scripts/merge-update-manifests.ts --platform win \ | |
| 385 | + # "$arm64_manifest" \ | |
| 386 | + # "$x64_manifest" \ | |
| 387 | + # "$output_manifest" | |
| 388 | + # rm -f "$arm64_manifest" "$x64_manifest" | |
| 389 | + # done | |
| 390 | + | |
| 391 | + # if [[ "$found_windows_manifest" != true ]]; then | |
| 392 | + # echo "No Windows updater manifests found to merge." >&2 | |
| 393 | + # exit 1 | |
| 394 | + # fi | |
| 287 | 395 | |
| 288 | 396 | - name: Publish release |
| 397 | + if: needs.preflight.outputs.previous_tag != '' | |
| 289 | 398 | uses: softprops/action-gh-release@v2 |
| 290 | 399 | with: |
| 291 | 400 | tag_name: ${{ needs.preflight.outputs.tag }} |
| 292 | 401 | target_commitish: ${{ needs.preflight.outputs.ref }} |
| 293 | − name: T3 Code v${{ needs.preflight.outputs.version }} | |
| 402 | + name: ${{ needs.preflight.outputs.release_name }} | |
| 294 | 403 | generate_release_notes: true |
| 404 | + previous_tag: ${{ needs.preflight.outputs.previous_tag }} | |
| 295 | 405 | prerelease: ${{ needs.preflight.outputs.is_prerelease }} |
| 296 | 406 | make_latest: ${{ needs.preflight.outputs.make_latest }} |
| 297 | 407 | files: | |
| @@ -300,13 +410,34 @@ jobs: · 2 unmodified lines | ||
| 300 | 410 | release-assets/*.AppImage |
| 301 | 411 | release-assets/*.exe |
| 302 | 412 | release-assets/*.blockmap |
| 303 | − release-assets/latest*.yml | |
| 413 | + release-assets/*.yml | |
| 414 | + fail_on_unmatched_files: true | |
| 415 | + | |
| 416 | + - name: Publish first release | |
| 417 | + if: needs.preflight.outputs.previous_tag == '' | |
| 418 | + uses: softprops/action-gh-release@v2 | |
| 419 | + with: | |
| 420 | + tag_name: ${{ needs.preflight.outputs.tag }} | |
| 421 | + target_commitish: ${{ needs.preflight.outputs.ref }} | |
| 422 | + name: ${{ needs.preflight.outputs.release_name }} | |
| 423 | + generate_release_notes: true | |
| 424 | + prerelease: ${{ needs.preflight.outputs.is_prerelease }} | |
| 425 | + make_latest: ${{ needs.preflight.outputs.make_latest }} | |
| 426 | + files: | | |
| 427 | + release-assets/*.dmg | |
| 428 | + release-assets/*.zip | |
| 429 | + release-assets/*.AppImage | |
| 430 | + release-assets/*.exe | |
| 431 | + release-assets/*.blockmap | |
| 432 | + release-assets/*.yml | |
| 304 | 433 | fail_on_unmatched_files: true |
| 305 | 434 | |
| 306 | 435 | finalize: |
| 307 | 436 | name: Finalize release |
| 437 | + if: needs.preflight.outputs.release_channel == 'stable' | |
| 308 | 438 | needs: [preflight, release] |
| 309 | − runs-on: ubuntu-24.04 | |
| 439 | + runs-on: blacksmith-8vcpu-ubuntu-2404 | |
| 440 | + timeout-minutes: 10 | |
| 310 | 441 | steps: |
| 311 | 442 | - id: app_token |
| 312 | 443 | name: Mint release app token |
| @@ -344,6 +475,9 @@ jobs: · 31 unmodified lines | ||
| 344 | 475 | with: |
| 345 | 476 | node-version-file: package.json |
| 346 | 477 | |
| 478 | + - name: Install dependencies | |
| 479 | + run: bun install --frozen-lockfile | |
| 480 | + | |
| 347 | 481 | - id: update_versions |
| 348 | 482 | name: Update version strings |
| 349 | 483 | env: |
| @@ -21,3 +21,4 @@ apps/web/src/components/__screenshots__ · 20 unmodified lines | ||
| 21 | 21 | .vitest-* |
| 22 | 22 | __screenshots__/ |
| 23 | 23 | .tanstack |
| 24 | +squashfs-root/ | |
| @@ -9,7 +9,9 @@ · 8 unmodified lines | ||
| 9 | 9 | "bun.lock", |
| 10 | 10 | "*.tsbuildinfo", |
| 11 | 11 | "**/routeTree.gen.ts", |
| 12 | − "apps/web/public/mockServiceWorker.js" | |
| 12 | + "apps/web/public/mockServiceWorker.js", | |
| 13 | + "apps/web/src/lib/vendor/qrcodegen.ts", | |
| 14 | + "*.icon/**" | |
| 13 | 15 | ], |
| 14 | 16 | "sortPackageJson": {} |
| 15 | 17 | } |
| @@ -0,0 +1,823 @@ | ||
| 1 | +# Server Auth Model Plan | |
| 2 | + | |
| 3 | +## Purpose | |
| 4 | + | |
| 5 | +Define the long-term server auth architecture for T3 Code before first-class remote environments ship. | |
| 6 | + | |
| 7 | +This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. | |
| 8 | + | |
| 9 | +This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. | |
| 10 | + | |
| 11 | +## Primary goals | |
| 12 | + | |
| 13 | +- Make auth server-wide, not WebSocket-only. | |
| 14 | +- Make insecure exposure hard to do accidentally. | |
| 15 | +- Preserve zero-login local desktop UX for desktop-managed environments. | |
| 16 | +- Support browser-native pairing and session auth. | |
| 17 | +- Leave room for native/mobile credentials later without rewriting the server boundary. | |
| 18 | +- Keep auth separate from transport and launch method. | |
| 19 | + | |
| 20 | +## Non-goals | |
| 21 | + | |
| 22 | +- Full multi-user authorization and RBAC. | |
| 23 | +- OAuth / SSO / enterprise identity. | |
| 24 | +- Passkeys or biometric UX in v1. | |
| 25 | +- Syncing auth state across environments. | |
| 26 | +- Designing the full remote environment product in this document. | |
| 27 | + | |
| 28 | +## Core decisions | |
| 29 | + | |
| 30 | +### 1. Auth is a server concern | |
| 31 | + | |
| 32 | +Every privileged surface of the T3 server must go through the same auth policy engine: | |
| 33 | + | |
| 34 | +- HTTP routes | |
| 35 | +- WebSocket upgrades | |
| 36 | +- RPC methods reached through WebSocket | |
| 37 | + | |
| 38 | +The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. | |
| 39 | + | |
| 40 | +### 2. Pairing and session are different things | |
| 41 | + | |
| 42 | +The system should distinguish: | |
| 43 | + | |
| 44 | +- bootstrap credentials | |
| 45 | +- session credentials | |
| 46 | + | |
| 47 | +Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. | |
| 48 | + | |
| 49 | +Session credentials are the durable credentials used after pairing. | |
| 50 | + | |
| 51 | +Bootstrap should never become the long-lived request credential. | |
| 52 | + | |
| 53 | +### 3. Auth and transport are separate | |
| 54 | + | |
| 55 | +Auth must not be defined by how the client reached the server. | |
| 56 | + | |
| 57 | +Examples: | |
| 58 | + | |
| 59 | +- local desktop-managed server | |
| 60 | +- LAN `ws://` | |
| 61 | +- public `wss://` | |
| 62 | +- tunneled `wss://` | |
| 63 | +- SSH-forwarded `ws://127.0.0.1:<port>` | |
| 64 | + | |
| 65 | +All of these should feed into the same auth model. | |
| 66 | + | |
| 67 | +### 4. Exposure level changes defaults | |
| 68 | + | |
| 69 | +The more exposed an environment is, the narrower the safe default should be. | |
| 70 | + | |
| 71 | +Safe default expectations: | |
| 72 | + | |
| 73 | +- local desktop-managed: auto-pair allowed | |
| 74 | +- loopback browser access: explicit bootstrap allowed | |
| 75 | +- non-loopback bind: auth required | |
| 76 | +- tunnel/public endpoint: auth required, explicit enablement required | |
| 77 | + | |
| 78 | +### 5. Browser and native clients may use different session credentials | |
| 79 | + | |
| 80 | +The auth model should support more than one session credential type even if only one ships first. | |
| 81 | + | |
| 82 | +Examples: | |
| 83 | + | |
| 84 | +- browser session cookie | |
| 85 | +- native bearer/device token | |
| 86 | + | |
| 87 | +This should be represented in the model now, even if browser cookies are the first implementation. | |
| 88 | + | |
| 89 | +## Target auth domain | |
| 90 | + | |
| 91 | +### Route classes | |
| 92 | + | |
| 93 | +Every route or transport entrypoint should be classified as one of: | |
| 94 | + | |
| 95 | +1. `public` | |
| 96 | +2. `bootstrap` | |
| 97 | +3. `authenticated` | |
| 98 | + | |
| 99 | +#### `public` | |
| 100 | + | |
| 101 | +Unauthenticated by definition. | |
| 102 | + | |
| 103 | +Should be extremely small. Examples: | |
| 104 | + | |
| 105 | +- static shell needed to render the pairing/login UI | |
| 106 | +- favicon/assets required for the pairing screen | |
| 107 | +- a minimal server health/version endpoint if needed | |
| 108 | + | |
| 109 | +#### `bootstrap` | |
| 110 | + | |
| 111 | +Used only to exchange a bootstrap credential for a session. | |
| 112 | + | |
| 113 | +Examples: | |
| 114 | + | |
| 115 | +- Initial bootstrap envelope over file descriptor at startup | |
| 116 | +- `POST /api/auth/bootstrap` | |
| 117 | +- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX | |
| 118 | + | |
| 119 | +#### `authenticated` | |
| 120 | + | |
| 121 | +Everything that reveals machine state or mutates it. | |
| 122 | + | |
| 123 | +Examples: | |
| 124 | + | |
| 125 | +- WebSocket upgrade | |
| 126 | +- orchestration snapshot and events | |
| 127 | +- terminal open/write/close | |
| 128 | +- project search and file writes | |
| 129 | +- git routes | |
| 130 | +- attachments | |
| 131 | +- project favicon lookup | |
| 132 | +- server settings | |
| 133 | + | |
| 134 | +The default stance should be: if it touches the machine, it is authenticated. | |
| 135 | + | |
| 136 | +## Credential model | |
| 137 | + | |
| 138 | +### Bootstrap credentials | |
| 139 | + | |
| 140 | +Initial credential types to model: | |
| 141 | + | |
| 142 | +- `desktop-bootstrap` | |
| 143 | +- `one-time-token` | |
| 144 | + | |
| 145 | +Possible future credential types: | |
| 146 | + | |
| 147 | +- `device-code` | |
| 148 | +- `passkey-assertion` | |
| 149 | +- `external-identity` | |
| 150 | + | |
| 151 | +#### `desktop-bootstrap` | |
| 152 | + | |
| 153 | +Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. | |
| 154 | + | |
| 155 | +Properties: | |
| 156 | + | |
| 157 | +- launcher-provided | |
| 158 | +- short-lived | |
| 159 | +- one-time or bounded-use | |
| 160 | +- never shown to the user as a reusable password | |
| 161 | + | |
| 162 | +#### `one-time-token` | |
| 163 | + | |
| 164 | +Used for explicit browser/mobile pairing flows. | |
| 165 | + | |
| 166 | +Properties: | |
| 167 | + | |
| 168 | +- short TTL | |
| 169 | +- one-time use | |
| 170 | +- safe to embed in a pairing URL fragment | |
| 171 | +- exchanged for a session credential | |
| 172 | + | |
| 173 | +### Session credentials | |
| 174 | + | |
| 175 | +Initial credential types to model: | |
| 176 | + | |
| 177 | +- `browser-session-cookie` | |
| 178 | +- `bearer-session-token` | |
| 179 | + | |
| 180 | +#### `browser-session-cookie` | |
| 181 | + | |
| 182 | +Primary browser credential. | |
| 183 | + | |
| 184 | +Properties: | |
| 185 | + | |
| 186 | +- signed | |
| 187 | +- `HttpOnly` | |
| 188 | +- bounded lifetime | |
| 189 | +- revocable by server key rotation or session invalidation | |
| 190 | + | |
| 191 | +#### `bearer-session-token` | |
| 192 | + | |
| 193 | +Reserved for native/mobile or non-browser clients. | |
| 194 | + | |
| 195 | +Properties: | |
| 196 | + | |
| 197 | +- opaque token, not a bootstrap secret | |
| 198 | +- long enough lifetime to survive reconnects | |
| 199 | +- stored in secure client storage when available | |
| 200 | + | |
| 201 | +## Auth policy model | |
| 202 | + | |
| 203 | +Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. | |
| 204 | + | |
| 205 | +### Policy examples | |
| 206 | + | |
| 207 | +#### `DesktopManagedLocalPolicy` | |
| 208 | + | |
| 209 | +Default for desktop-managed local server. | |
| 210 | + | |
| 211 | +Allowed bootstrap methods: | |
| 212 | + | |
| 213 | +- `desktop-bootstrap` | |
| 214 | + | |
| 215 | +Allowed session methods: | |
| 216 | + | |
| 217 | +- `browser-session-cookie` | |
| 218 | + | |
| 219 | +Disabled by default: | |
| 220 | + | |
| 221 | +- `one-time-token` | |
| 222 | +- `bearer-session-token` | |
| 223 | +- password login | |
| 224 | +- public pairing | |
| 225 | + | |
| 226 | +#### `LoopbackBrowserPolicy` | |
| 227 | + | |
| 228 | +Used for browser access on localhost without desktop-managed bootstrap. | |
| 229 | + | |
| 230 | +Allowed bootstrap methods: | |
| 231 | + | |
| 232 | +- `one-time-token` | |
| 233 | + | |
| 234 | +Allowed session methods: | |
| 235 | + | |
| 236 | +- `browser-session-cookie` | |
| 237 | + | |
| 238 | +#### `RemoteReachablePolicy` | |
| 239 | + | |
| 240 | +Used when binding non-loopback or using an explicit remote/tunnel workflow. | |
| 241 | + | |
| 242 | +Allowed bootstrap methods: | |
| 243 | + | |
| 244 | +- `one-time-token` | |
| 245 | +- possibly `desktop-bootstrap` when a desktop shell is brokering access | |
| 246 | + | |
| 247 | +Allowed session methods: | |
| 248 | + | |
| 249 | +- `browser-session-cookie` | |
| 250 | +- `bearer-session-token` | |
| 251 | + | |
| 252 | +#### `UnsafeNoAuthPolicy` | |
| 253 | + | |
| 254 | +Should exist only as an explicit escape hatch. | |
| 255 | + | |
| 256 | +Requirements: | |
| 257 | + | |
| 258 | +- explicit opt-in flag | |
| 259 | +- loud startup warnings | |
| 260 | +- never defaulted automatically | |
| 261 | + | |
| 262 | +## Effect-native service model | |
| 263 | + | |
| 264 | +### `ServerAuth` | |
| 265 | + | |
| 266 | +The main auth facade used by HTTP routes and WebSocket upgrade handling. | |
| 267 | + | |
| 268 | +Responsibilities: | |
| 269 | + | |
| 270 | +- classify requests | |
| 271 | +- authenticate requests | |
| 272 | +- authorize bootstrap attempts | |
| 273 | +- create sessions from bootstrap credentials | |
| 274 | +- enforce policy by environment mode | |
| 275 | + | |
| 276 | +Sketch: | |
| 277 | + | |
| 278 | +```ts | |
| 279 | +export interface ServerAuthShape { | |
| 280 | + readonly getCapabilities: Effect.Effect<AuthCapabilities>; | |
| 281 | + readonly authenticateHttpRequest: ( | |
| 282 | + request: HttpServerRequest.HttpServerRequest, | |
| 283 | + routeClass: RouteAuthClass, | |
| 284 | + ) => Effect.Effect<AuthContext, AuthError>; | |
| 285 | + readonly authenticateWebSocketUpgrade: ( | |
| 286 | + request: HttpServerRequest.HttpServerRequest, | |
| 287 | + ) => Effect.Effect<AuthContext, AuthError>; | |
| 288 | + readonly exchangeBootstrapCredential: ( | |
| 289 | + input: BootstrapExchangeInput, | |
| 290 | + ) => Effect.Effect<SessionGrant, AuthError>; | |
| 291 | +} | |
| 292 | + | |
| 293 | +export class ServerAuth extends ServiceMap.Service<ServerAuth, ServerAuthShape>()( | |
| 294 | + "t3/ServerAuth", | |
| 295 | +) {} | |
| 296 | +``` | |
| 297 | + | |
| 298 | +### `BootstrapCredentialService` | |
| 299 | + | |
| 300 | +Owns issuance, storage, validation, and consumption of bootstrap credentials. | |
| 301 | + | |
| 302 | +Responsibilities: | |
| 303 | + | |
| 304 | +- issue desktop bootstrap grants | |
| 305 | +- issue one-time pairing tokens | |
| 306 | +- validate TTL and single-use semantics | |
| 307 | +- consume bootstrap grants atomically | |
| 308 | + | |
| 309 | +Sketch: | |
| 310 | + | |
| 311 | +```ts | |
| 312 | +export interface BootstrapCredentialServiceShape { | |
| 313 | + readonly issueDesktopBootstrap: ( | |
| 314 | + input: IssueDesktopBootstrapInput, | |
| 315 | + ) => Effect.Effect<BootstrapCredential>; | |
| 316 | + readonly issueOneTimeToken: ( | |
| 317 | + input: IssueOneTimeTokenInput, | |
| 318 | + ) => Effect.Effect<BootstrapCredential>; | |
| 319 | + readonly consume: ( | |
| 320 | + presented: PresentedBootstrapCredential, | |
| 321 | + ) => Effect.Effect<ConsumedBootstrapCredential, BootstrapCredentialError>; | |
| 322 | +} | |
| 323 | +``` | |
| 324 | + | |
| 325 | +### `SessionCredentialService` | |
| 326 | + | |
| 327 | +Owns creation and validation of authenticated sessions. | |
| 328 | + | |
| 329 | +Responsibilities: | |
| 330 | + | |
| 331 | +- mint cookie sessions | |
| 332 | +- mint bearer sessions | |
| 333 | +- validate active session credentials | |
| 334 | +- revoke sessions if needed later | |
| 335 | + | |
| 336 | +Sketch: | |
| 337 | + | |
| 338 | +```ts | |
| 339 | +export interface SessionCredentialServiceShape { | |
| 340 | + readonly createBrowserSession: ( | |
| 341 | + input: CreateSessionFromBootstrapInput, | |
| 342 | + ) => Effect.Effect<BrowserSessionGrant, SessionCredentialError>; | |
| 343 | + readonly createBearerSession: ( | |
| 344 | + input: CreateSessionFromBootstrapInput, | |
| 345 | + ) => Effect.Effect<BearerSessionGrant, SessionCredentialError>; | |
| 346 | + readonly authenticateCookie: ( | |
| 347 | + request: HttpServerRequest.HttpServerRequest, | |
| 348 | + ) => Effect.Effect<AuthContext, SessionCredentialError>; | |
| 349 | + readonly authenticateBearer: ( | |
| 350 | + request: HttpServerRequest.HttpServerRequest, | |
| 351 | + ) => Effect.Effect<AuthContext, SessionCredentialError>; | |
| 352 | +} | |
| 353 | +``` | |
| 354 | + | |
| 355 | +### `ServerAuthPolicy` | |
| 356 | + | |
| 357 | +Pure policy/config service that decides which credential types are allowed. | |
| 358 | + | |
| 359 | +Responsibilities: | |
| 360 | + | |
| 361 | +- map runtime mode and bind/exposure settings to allowed auth methods | |
| 362 | +- answer whether a route can be public | |
| 363 | +- answer whether remote exposure requires auth | |
| 364 | + | |
| 365 | +This should stay mostly pure and cheap to test. | |
| 366 | + | |
| 367 | +### `ServerSecretStore` | |
| 368 | + | |
| 369 | +Owns long-lived server signing keys and secrets. | |
| 370 | + | |
| 371 | +Responsibilities: | |
| 372 | + | |
| 373 | +- get or create signing key | |
| 374 | +- rotate signing key | |
| 375 | +- abstract secure OS-backed storage vs filesystem fallback | |
| 376 | + | |
| 377 | +Important: | |
| 378 | + | |
| 379 | +- prefer platform secure storage when available | |
| 380 | +- support hardened filesystem fallback for headless/server-only environments | |
| 381 | + | |
| 382 | +### `BrowserSessionCookieCodec` | |
| 383 | + | |
| 384 | +Focused utility service for cookie encode/decode/signing behavior. | |
| 385 | + | |
| 386 | +This should not own policy. It should only own the cookie format. | |
| 387 | + | |
| 388 | +### `AuthRouteGuards` | |
| 389 | + | |
| 390 | +Thin helper layer used by routes to enforce auth consistently. | |
| 391 | + | |
| 392 | +Responsibilities: | |
| 393 | + | |
| 394 | +- require auth for HTTP route handlers | |
| 395 | +- classify route auth mode | |
| 396 | +- convert auth failures into `401` / `403` | |
| 397 | + | |
| 398 | +This prevents every route from re-implementing the same pattern. | |
| 399 | + | |
| 400 | +Integrates with `HttpRouter.middleware` to enforce auth consistently. | |
| 401 | + | |
| 402 | +## Suggested layer graph | |
| 403 | + | |
| 404 | +```text | |
| 405 | +ServerSecretStore | |
| 406 | + ├─> BootstrapCredentialService | |
| 407 | + ├─> BrowserSessionCookieCodec | |
| 408 | + └─> SessionCredentialService | |
| 409 | + | |
| 410 | +ServerAuthPolicy | |
| 411 | + ├─> BootstrapCredentialService | |
| 412 | + ├─> SessionCredentialService | |
| 413 | + └─> ServerAuth | |
| 414 | + | |
| 415 | +ServerAuth | |
| 416 | + └─> AuthRouteGuards | |
| 417 | +``` | |
| 418 | + | |
| 419 | +Layer naming should follow existing repo style: | |
| 420 | + | |
| 421 | +- `ServerSecretStoreLive` | |
| 422 | +- `BootstrapCredentialServiceLive` | |
| 423 | +- `SessionCredentialServiceLive` | |
| 424 | +- `ServerAuthPolicyLive` | |
| 425 | +- `ServerAuthLive` | |
| 426 | +- `AuthRouteGuardsLive` | |
| 427 | + | |
| 428 | +## High-level implementation examples | |
| 429 | + | |
| 430 | +### Example: WebSocket upgrade auth | |
| 431 | + | |
| 432 | +Current state: | |
| 433 | + | |
| 434 | +- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) | |
| 435 | + | |
| 436 | +Target shape: | |
| 437 | + | |
| 438 | +```ts | |
| 439 | +const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => | |
| 440 | + Effect.gen(function* () { | |
| 441 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 442 | + const serverAuth = yield* ServerAuth; | |
| 443 | + yield* serverAuth.authenticateWebSocketUpgrade(request); | |
| 444 | + return yield* httpApp; | |
| 445 | + }), | |
| 446 | +); | |
| 447 | +``` | |
| 448 | + | |
| 449 | +Then the `/ws` route becomes: | |
| 450 | + | |
| 451 | +```ts | |
| 452 | +export const websocketRpcRouteLayer = HttpRouter.add( | |
| 453 | + "GET", | |
| 454 | + "/ws", | |
| 455 | + rpcWebSocketHttpEffect.pipe( | |
| 456 | + websocketUpgradeAuth, | |
| 457 | + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), | |
| 458 | + ), | |
| 459 | +); | |
| 460 | +``` | |
| 461 | + | |
| 462 | +This keeps the route itself declarative and makes auth compose like normal HTTP middleware. | |
| 463 | + | |
| 464 | +### Example: authenticated HTTP route | |
| 465 | + | |
| 466 | +For routes like attachments or project favicon: | |
| 467 | + | |
| 468 | +```ts | |
| 469 | +const authenticatedRoute = (routeClass: RouteAuthClass) => | |
| 470 | + HttpMiddleware.make((httpApp) => | |
| 471 | + Effect.gen(function* () { | |
| 472 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 473 | + const serverAuth = yield* ServerAuth; | |
| 474 | + yield* serverAuth.authenticateHttpRequest(request, routeClass); | |
| 475 | + return yield* httpApp; | |
| 476 | + }), | |
| 477 | + ); | |
| 478 | +``` | |
| 479 | + | |
| 480 | +Then: | |
| 481 | + | |
| 482 | +```ts | |
| 483 | +export const attachmentsRouteLayer = HttpRouter.add( | |
| 484 | + "GET", | |
| 485 | + `${ATTACHMENTS_ROUTE_PREFIX}/*`, | |
| 486 | + serveAttachment.pipe( | |
| 487 | + authenticatedRoute(RouteAuthClass.Authenticated), | |
| 488 | + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), | |
| 489 | + ), | |
| 490 | +); | |
| 491 | +``` | |
| 492 | + | |
| 493 | +### Example: desktop bootstrap exchange | |
| 494 | + | |
| 495 | +The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. | |
| 496 | + | |
| 497 | +That grant is then exchanged for a browser cookie session when the renderer loads. | |
| 498 | + | |
| 499 | +Sketch: | |
| 500 | + | |
| 501 | +```ts | |
| 502 | +const pairDesktopRenderer = Effect.gen(function* () { | |
| 503 | + const bootstrapService = yield* BootstrapCredentialService; | |
| 504 | + const credential = yield* bootstrapService.issueDesktopBootstrap({ | |
| 505 | + audience: "desktop-renderer", | |
| 506 | + ttlMs: 30_000, | |
| 507 | + }); | |
| 508 | + return credential; | |
| 509 | +}); | |
| 510 | +``` | |
| 511 | + | |
| 512 | +The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. | |
| 513 | + | |
| 514 | +### Example: one-time pairing URL | |
| 515 | + | |
| 516 | +For browser-driven pairing: | |
| 517 | + | |
| 518 | +```ts | |
| 519 | +const createPairingToken = Effect.gen(function* () { | |
| 520 | + const bootstrapService = yield* BootstrapCredentialService; | |
| 521 | + return yield* bootstrapService.issueOneTimeToken({ | |
| 522 | + ttlMs: 5 * 60_000, | |
| 523 | + audience: "browser", | |
| 524 | + }); | |
| 525 | +}); | |
| 526 | +``` | |
| 527 | + | |
| 528 | +The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. | |
| 529 | + | |
| 530 | +## Sequence diagrams | |
| 531 | + | |
| 532 | +These flows are meant to anchor the auth model in concrete user journeys. | |
| 533 | + | |
| 534 | +The important invariant across all of them is: | |
| 535 | + | |
| 536 | +- access method is not the auth method | |
| 537 | +- launch method is not the auth method | |
| 538 | +- bootstrap credential is not the session credential | |
| 539 | + | |
| 540 | +### Normal desktop user | |
| 541 | + | |
| 542 | +This is the default desktop-managed local flow. | |
| 543 | + | |
| 544 | +The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. | |
| 545 | + | |
| 546 | +```text | |
| 547 | +Participants: | |
| 548 | + DesktopMain = Electron main | |
| 549 | + SecretStore = secure local secret backend | |
| 550 | + T3Server = local backend child process | |
| 551 | + Frontend = desktop renderer | |
| 552 | + | |
| 553 | +DesktopMain -> SecretStore : getOrCreate("server-signing-key") | |
| 554 | +SecretStore --> DesktopMain : signing key available | |
| 555 | + | |
| 556 | +DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) | |
| 557 | +DesktopMain -> T3Server : send desktop bootstrap envelope | |
| 558 | +note over T3Server : policy = DesktopManagedLocalPolicy | |
| 559 | +note over T3Server : allowed pairing = desktop-bootstrap only | |
| 560 | + | |
| 561 | +Frontend -> DesktopMain : request local bootstrap grant | |
| 562 | +DesktopMain --> Frontend : short-lived desktop bootstrap grant | |
| 563 | + | |
| 564 | +Frontend -> T3Server : POST /api/auth/bootstrap | |
| 565 | +T3Server -> T3Server : validate desktop bootstrap grant | |
| 566 | +T3Server -> T3Server : create browser session | |
| 567 | +T3Server --> Frontend : Set-Cookie: session=... | |
| 568 | + | |
| 569 | +Frontend -> T3Server : GET /ws + authenticated cookie | |
| 570 | +T3Server -> T3Server : validate cookie session | |
| 571 | +T3Server --> Frontend : websocket accepted | |
| 572 | +``` | |
| 573 | + | |
| 574 | +### `npx t3` user | |
| 575 | + | |
| 576 | +This is the standalone local server flow. | |
| 577 | + | |
| 578 | +There is no trusted desktop shell here, so pairing should be explicit. | |
| 579 | + | |
| 580 | +```text | |
| 581 | +Participants: | |
| 582 | + UserShell = npx t3 launcher | |
| 583 | + T3Server = standalone local server | |
| 584 | + Browser = browser tab | |
| 585 | + | |
| 586 | +UserShell -> T3Server : start server | |
| 587 | +T3Server -> T3Server : getOrCreate("server-signing-key") | |
| 588 | +note over T3Server : policy = LoopbackBrowserPolicy | |
| 589 | + | |
| 590 | +UserShell -> T3Server : issue one-time pairing token | |
| 591 | +T3Server --> UserShell : pairing URL or pairing token | |
| 592 | + | |
| 593 | +UserShell --> Browser : open /pair?token=... | |
| 594 | + | |
| 595 | +Browser -> T3Server : GET /pair?token=... | |
| 596 | +T3Server -> T3Server : validate one-time token | |
| 597 | +T3Server -> T3Server : create browser session | |
| 598 | +T3Server --> Browser : Set-Cookie: session=... | |
| 599 | +T3Server --> Browser : redirect to app | |
| 600 | + | |
| 601 | +Browser -> T3Server : GET /ws + authenticated cookie | |
| 602 | +T3Server --> Browser : websocket accepted | |
| 603 | +``` | |
| 604 | + | |
| 605 | +### Phone user with tunneled host | |
| 606 | + | |
| 607 | +This is the explicit remote access flow for a browser on another device. | |
| 608 | + | |
| 609 | +The tunnel only provides reachability. It must not imply trust. | |
| 610 | + | |
| 611 | +Recommended UX: | |
| 612 | + | |
| 613 | +- desktop shows a QR code | |
| 614 | +- desktop also shows a copyable pairing URL | |
| 615 | +- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access | |
| 616 | + | |
| 617 | +```text | |
| 618 | +Participants: | |
| 619 | + DesktopUser = user at the host machine | |
| 620 | + DesktopMain = desktop app | |
| 621 | + Tunnel = tunnel provider | |
| 622 | + T3Server = T3 server | |
| 623 | + PhoneBrowser = mobile browser | |
| 624 | + | |
| 625 | +DesktopUser -> DesktopMain : enable remote access via tunnel | |
| 626 | +DesktopMain -> T3Server : switch policy to RemoteReachablePolicy | |
| 627 | +DesktopMain -> Tunnel : publish local T3 endpoint | |
| 628 | +Tunnel --> DesktopMain : public https/wss URL | |
| 629 | + | |
| 630 | +DesktopMain -> T3Server : issue one-time pairing token | |
| 631 | +T3Server --> DesktopMain : pairing token | |
| 632 | +DesktopMain -> DesktopUser : show QR code / shareable URL | |
| 633 | + | |
| 634 | +DesktopUser -> PhoneBrowser : scan QR / open URL | |
| 635 | +PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... | |
| 636 | +Tunnel -> T3Server : forward request | |
| 637 | +T3Server -> T3Server : validate one-time token | |
| 638 | +T3Server -> T3Server : create mobile browser session | |
| 639 | +T3Server --> PhoneBrowser : Set-Cookie: session=... | |
| 640 | +T3Server --> PhoneBrowser : redirect to app | |
| 641 | + | |
| 642 | +PhoneBrowser -> Tunnel : GET /ws + authenticated cookie | |
| 643 | +Tunnel -> T3Server : forward websocket upgrade | |
| 644 | +T3Server --> PhoneBrowser : websocket accepted | |
| 645 | +``` | |
| 646 | + | |
| 647 | +### Phone user with private network | |
| 648 | + | |
| 649 | +This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. | |
| 650 | + | |
| 651 | +The auth flow should stay the same. | |
| 652 | + | |
| 653 | +```text | |
| 654 | +Participants: | |
| 655 | + DesktopUser = user at the host machine | |
| 656 | + T3Server = T3 server | |
| 657 | + PrivateNet = tailscale / private LAN | |
| 658 | + PhoneBrowser = mobile browser | |
| 659 | + | |
| 660 | +DesktopUser -> T3Server : enable private-network access | |
| 661 | +T3Server -> T3Server : switch policy to RemoteReachablePolicy | |
| 662 | +DesktopUser -> T3Server : issue one-time pairing token | |
| 663 | +T3Server --> DesktopUser : pairing URL / QR | |
| 664 | + | |
| 665 | +DesktopUser -> PhoneBrowser : open private-network URL | |
| 666 | +PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... | |
| 667 | +PrivateNet -> T3Server : route request | |
| 668 | +T3Server -> T3Server : validate one-time token | |
| 669 | +T3Server -> T3Server : create mobile browser session | |
| 670 | +T3Server --> PhoneBrowser : Set-Cookie: session=... | |
| 671 | +T3Server --> PhoneBrowser : redirect to app | |
| 672 | + | |
| 673 | +PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie | |
| 674 | +PrivateNet -> T3Server : websocket upgrade | |
| 675 | +T3Server --> PhoneBrowser : websocket accepted | |
| 676 | +``` | |
| 677 | + | |
| 678 | +### Desktop user adding new SSH hosts | |
| 679 | + | |
| 680 | +SSH should be treated as launch and reachability plumbing, not as the long-term auth model. | |
| 681 | + | |
| 682 | +The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. | |
| 683 | + | |
| 684 | +```text | |
| 685 | +Participants: | |
| 686 | + DesktopUser = local desktop user | |
| 687 | + DesktopMain = desktop app | |
| 688 | + SSH = ssh transport/session | |
| 689 | + RemoteHost = remote machine | |
| 690 | + RemoteT3 = remote T3 server | |
| 691 | + Frontend = desktop renderer | |
| 692 | + | |
| 693 | +DesktopUser -> DesktopMain : add SSH host | |
| 694 | +DesktopMain -> SSH : connect to remote host | |
| 695 | +SSH -> RemoteHost : probe environment / verify t3 availability | |
| 696 | +DesktopMain -> SSH : run remote launch command | |
| 697 | +SSH -> RemoteHost : t3 remote launch --json | |
| 698 | +RemoteHost -> RemoteT3 : start or reuse server | |
| 699 | +RemoteT3 --> RemoteHost : port + environment metadata | |
| 700 | +RemoteHost --> SSH : launch result JSON | |
| 701 | +SSH --> DesktopMain : remote server details | |
| 702 | + | |
| 703 | +DesktopMain -> SSH : establish local port forward | |
| 704 | +SSH --> DesktopMain : localhost:FORWARDED_PORT ready | |
| 705 | + | |
| 706 | +note over RemoteT3 : policy = RemoteReachablePolicy | |
| 707 | +note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here | |
| 708 | + | |
| 709 | +Frontend -> DesktopMain : request bootstrap for selected environment | |
| 710 | +DesktopMain --> Frontend : short-lived bootstrap grant | |
| 711 | + | |
| 712 | +Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port | |
| 713 | +RemoteT3 -> RemoteT3 : validate bootstrap grant | |
| 714 | +RemoteT3 -> RemoteT3 : create browser session | |
| 715 | +RemoteT3 --> Frontend : Set-Cookie: session=... | |
| 716 | + | |
| 717 | +Frontend -> RemoteT3 : GET /ws + authenticated cookie | |
| 718 | +RemoteT3 --> Frontend : websocket accepted | |
| 719 | +``` | |
| 720 | + | |
| 721 | +## Storage decisions | |
| 722 | + | |
| 723 | +### Server secrets | |
| 724 | + | |
| 725 | +Use a `ServerSecretStore` abstraction. | |
| 726 | + | |
| 727 | +Preferred order (use a layer for each, resolve on startup): | |
| 728 | + | |
| 729 | +1. OS secure storage if available | |
| 730 | +2. hardened filesystem fallback if not | |
| 731 | + | |
| 732 | +The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. | |
| 733 | + | |
| 734 | +### Client credentials | |
| 735 | + | |
| 736 | +Client-side credential persistence should prefer secure storage when available: | |
| 737 | + | |
| 738 | +- desktop: OS keychain / secure store | |
| 739 | +- mobile: platform secure storage | |
| 740 | +- browser: cookie session for browser auth | |
| 741 | + | |
| 742 | +This concern should stay in the client shell/runtime layer, not the server auth layer. | |
| 743 | + | |
| 744 | +## What to build now | |
| 745 | + | |
| 746 | +These are the parts worth building before remote environments ship: | |
| 747 | + | |
| 748 | +1. `ServerAuth` service boundary. | |
| 749 | +2. route classification and route guards. | |
| 750 | +3. `ServerSecretStore` abstraction. | |
| 751 | +4. bootstrap vs session credential split. | |
| 752 | +5. browser session cookie codec as one session method. | |
| 753 | +6. explicit auth capabilities/config surfaced in contracts. | |
| 754 | + | |
| 755 | +Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. | |
| 756 | + | |
| 757 | +## What to add as part of first remote-capable auth | |
| 758 | + | |
| 759 | +1. Browser pairing flow using one-time bootstrap token and cookie session. | |
| 760 | +2. Desktop-managed auto-bootstrap for the local desktop-managed environment. | |
| 761 | +3. Auth-required defaults for any non-loopback or explicitly published server. | |
| 762 | +4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. | |
| 763 | + | |
| 764 | +## What to defer | |
| 765 | + | |
| 766 | +- passkeys / WebAuthn | |
| 767 | +- iCloud Keychain / Face ID-specific UX | |
| 768 | +- multi-user permissions | |
| 769 | +- collaboration roles | |
| 770 | +- OAuth / SSO | |
| 771 | +- polished session management UI | |
| 772 | +- complex device approval flows | |
| 773 | + | |
| 774 | +These can all sit on top of the same bootstrap/session/service split. | |
| 775 | + | |
| 776 | +## Relationship to future remote environments | |
| 777 | + | |
| 778 | +Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. | |
| 779 | + | |
| 780 | +Keep the design focused on: | |
| 781 | + | |
| 782 | +- one T3 server | |
| 783 | +- one auth policy | |
| 784 | +- multiple credential types | |
| 785 | +- multiple future access methods | |
| 786 | + | |
| 787 | +That keeps the server auth model stable even as access methods expand later. | |
| 788 | + | |
| 789 | +## Recommended implementation order | |
| 790 | + | |
| 791 | +### Phase 1 | |
| 792 | + | |
| 793 | +- Introduce route auth classes. | |
| 794 | +- Add `ServerAuth` and `AuthRouteGuards`. | |
| 795 | +- Move existing `authToken` check behind `ServerAuth`. | |
| 796 | +- Require auth for all privileged HTTP routes as well as WebSocket. | |
| 797 | + | |
| 798 | +### Phase 2 | |
| 799 | + | |
| 800 | +- Add `ServerSecretStore` service with platform-specific layer implementations. | |
| 801 | + - `layerOSXKeychain`, `layer | |
| 802 | +- Add bootstrap/session split. | |
| 803 | +- Add browser session cookie support. | |
| 804 | +- Add one-time bootstrap exchange endpoint. | |
| 805 | + | |
| 806 | +### Phase 3 | |
| 807 | + | |
| 808 | +- Add desktop bootstrap flow on top of the same services. | |
| 809 | +- Make desktop-managed local environments default to bootstrap-only pairing. | |
| 810 | +- Surface auth capabilities in shared contracts and renderer bootstrap. | |
| 811 | + | |
| 812 | +### Phase 4 | |
| 813 | + | |
| 814 | +- Add non-browser bearer session support if mobile/native needs it. | |
| 815 | +- Add richer policy modes for remote-reachable environments. | |
| 816 | + | |
| 817 | +## Acceptance criteria | |
| 818 | + | |
| 819 | +- No privileged HTTP or WebSocket path bypasses auth policy. | |
| 820 | +- Local desktop-managed flows still avoid a visible login screen. | |
| 821 | +- Non-loopback or published environments require explicit authenticated pairing by default. | |
| 822 | +- Bootstrap and session credentials are distinct in code and in behavior. | |
| 823 | +- Auth logic is centralized in Effect services/layers rather than route-local branching. | |
| @@ -1 +1 @@ | ||
| 1 | −AGENTS.md | |
| 1 | +AGENTS.md | |
| @@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( · 22 unmodified lines | ||
| 23 | 23 | { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, |
| 24 | 24 | { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, |
| 25 | 25 | { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, |
| 26 | + { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, | |
| 26 | 27 | { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, |
| 27 | 28 | { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, |
| 28 | 29 | { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, |
| @@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged · 21 unmodified lines | ||
| 50 | 51 | - `terminal.split`: split terminal (in focused terminal context by default) |
| 51 | 52 | - `terminal.new`: create new terminal (in focused terminal context by default) |
| 52 | 53 | - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) |
| 54 | +- `commandPalette.toggle`: open or close the global command palette | |
| 53 | 55 | - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state |
| 54 | 56 | - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) |
| 55 | 57 | - `editor.openFavorite`: open current project/worktree in the last-used editor |
| @@ -49,6 +49,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) · 48 unmodified lines | ||
| 49 | 49 | |
| 50 | 50 | ## If you REALLY want to contribute still.... read this first |
| 51 | 51 | |
| 52 | +Before local development, prepare the environment and install dependencies: | |
| 53 | + | |
| 54 | +```bash | |
| 55 | +# Optional: only needed if you use mise for dev tool management. | |
| 56 | +mise install | |
| 57 | +bun install . | |
| 58 | +``` | |
| 59 | + | |
| 52 | 60 | Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. |
| 53 | 61 | |
| 54 | 62 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). |
| @@ -1,68 +1,87 @@ | ||
| 1 | −# Remote Access Setup | |
| 1 | +# Remote Access | |
| 2 | 2 | |
| 3 | −Use this when you want to open T3 Code from another device (phone, tablet, another laptop). | |
| 3 | +Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. | |
| 4 | 4 | |
| 5 | −## CLI ↔ Env option map | |
| 5 | +## Recommended Setup | |
| 6 | 6 | |
| 7 | −The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: | |
| 7 | +Use a trusted private network that meshes your devices together, such as a tailnet. | |
| 8 | 8 | |
| 9 | −| CLI flag | Env var | Notes | | |
| 10 | −| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | | |
| 11 | −| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. | | |
| 12 | −| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. | | |
| 13 | −| `--host <address>` | `T3CODE_HOST` | Bind interface/address. | | |
| 14 | −| `--base-dir <path>` | `T3CODE_HOME` | Base directory. | | |
| 15 | −| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | |
| 16 | −| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | | |
| 17 | −| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. | | |
| 18 | −| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. | | |
| 9 | +That gives you: | |
| 19 | 10 | |
| 20 | −> TIP: Use the `--help` flag to see all available options and their descriptions. | |
| 11 | +- a stable address to connect to | |
| 12 | +- transport security at the network layer | |
| 13 | +- less exposure than opening the server to the public internet | |
| 21 | 14 | |
| 22 | −## Security First | |
| 15 | +## Enabling Network Access | |
| 23 | 16 | |
| 24 | −- Always set `--auth-token` before exposing the server outside localhost. | |
| 25 | − - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`. | |
| 26 | − With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments. | |
| 27 | −- Treat the token like a password. | |
| 28 | −- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. | |
| 17 | +There are two ways to expose your server for remote connections: from the desktop app or from the CLI. | |
| 29 | 18 | |
| 30 | −## 1) Build + run server for remote access | |
| 19 | +### Option 1: Desktop App | |
| 31 | 20 | |
| 32 | −Remote access should use the built web app (not local Vite redirect mode). | |
| 21 | +If you are already running the desktop app and want to make it reachable from other devices: | |
| 22 | + | |
| 23 | +1. Open **Settings** → **Connections**. | |
| 24 | +2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. | |
| 25 | +3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). | |
| 26 | +4. Use **Create Link** to generate a pairing link you can share with another device. | |
| 27 | + | |
| 28 | +### Option 2: Headless Server (CLI) | |
| 29 | + | |
| 30 | +Use this when you want to run the server without a GUI, for example on a remote machine over SSH. | |
| 31 | + | |
| 32 | +Run the server with `t3 serve`. | |
| 33 | 33 | |
| 34 | 34 | ```bash |
| 35 | −bun run build | |
| 36 | −TOKEN="$(openssl rand -hex 24)" | |
| 37 | −bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser | |
| 35 | +npx t3 serve --host "$(tailscale ip -4)" | |
| 38 | 36 | ``` |
| 39 | 37 | |
| 40 | −Then open on your phone: | |
| 38 | +`t3 serve` starts the server without opening a browser and prints: | |
| 41 | 39 | |
| 42 | −`http://<your-machine-ip>:3773` | |
| 40 | +- a connection string | |
| 41 | +- a pairing token | |
| 42 | +- a pairing URL | |
| 43 | +- a QR code for the pairing URL | |
| 43 | 44 | |
| 44 | −Example: | |
| 45 | +From there, connect from another device in either of these ways: | |
| 45 | 46 | |
| 46 | −`http://192.168.1.42:3773` | |
| 47 | +- scan the QR code on your phone | |
| 48 | +- in the desktop app, enter the full pairing URL | |
| 49 | +- in the desktop app, enter the host and token separately | |
| 47 | 50 | |
| 48 | −Notes: | |
| 51 | +Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument. | |
| 49 | 52 | |
| 50 | −- `--host 0.0.0.0` listens on all IPv4 interfaces. | |
| 51 | −- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. | |
| 52 | −- Ensure your OS firewall allows inbound TCP on the selected port. | |
| 53 | +> Note | |
| 54 | +> The GUIs do not currently support adding projects on remote environments. | |
| 55 | +> For now, use `t3 project ...` on the server machine instead. | |
| 56 | +> Full GUI support for remote project management is coming soon. | |
| 53 | 57 | |
| 54 | −## 2) Tailnet / Tailscale access | |
| 58 | +## How Pairing Works | |
| 55 | 59 | |
| 56 | −If you use Tailscale, you can bind directly to your Tailnet address. | |
| 60 | +The remote device does not need a long-lived secret up front. | |
| 57 | 61 | |
| 58 | −```bash | |
| 59 | −TAILNET_IP="$(tailscale ip -4)" | |
| 60 | −TOKEN="$(openssl rand -hex 24)" | |
| 61 | −bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser | |
| 62 | −``` | |
| 62 | +Instead: | |
| 63 | + | |
| 64 | +1. `t3 serve` issues a one-time owner pairing token. | |
| 65 | +2. The remote device exchanges that token with the server. | |
| 66 | +3. The server creates an authenticated session for that device. | |
| 67 | + | |
| 68 | +After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device. | |
| 69 | + | |
| 70 | +## Managing Access Later | |
| 71 | + | |
| 72 | +Use `t3 auth` to manage access after the initial pairing flow. | |
| 73 | + | |
| 74 | +Typical uses: | |
| 75 | + | |
| 76 | +- issue additional pairing credentials | |
| 77 | +- inspect active sessions | |
| 78 | +- revoke old pairing links or sessions | |
| 63 | 79 | |
| 64 | −Open from any device in your tailnet: | |
| 80 | +Use `t3 auth --help` and the nested subcommand help pages for the full reference. | |
| 65 | 81 | |
| 66 | −`http://<tailnet-ip>:3773` | |
| 82 | +## Security Notes | |
| 67 | 83 | |
| 68 | −You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. | |
| 84 | +- Treat pairing URLs and pairing tokens like passwords. | |
| 85 | +- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly. | |
| 86 | +- Anyone with a valid pairing credential can create a session until that credential expires or is revoked. | |
| 87 | +- Use `t3 auth` to revoke credentials or sessions you no longer trust. | |
| @@ -1,14 +1,15 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | − "version": "0.0.15", | |
| 3 | + "version": "0.0.20", | |
| 4 | 4 | "private": true, |
| 5 | − "main": "dist-electron/main.js", | |
| 5 | + "type": "module", | |
| 6 | + "main": "dist-electron/main.cjs", | |
| 6 | 7 | "scripts": { |
| 7 | 8 | "dev": "bun run --parallel dev:bundle dev:electron", |
| 8 | 9 | "dev:bundle": "tsdown --watch", |
| 9 | − "dev:electron": "bun run scripts/dev-electron.mjs", | |
| 10 | + "dev:electron": "node scripts/dev-electron.mjs", | |
| 10 | 11 | "build": "tsdown", |
| 11 | − "start": "bun run scripts/start-electron.mjs", | |
| 12 | + "start": "node scripts/start-electron.mjs", | |
| 12 | 13 | "typecheck": "tsc --noEmit", |
| 13 | 14 | "test": "vitest run --passWithNoTests", |
| 14 | 15 | "smoke-test": "node scripts/smoke-test.mjs" |
| @@ -5,15 +5,24 @@ import { join } from "node:path"; · 4 unmodified lines | ||
| 5 | 5 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; |
| 6 | 6 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 7 | |
| 8 | −const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); | |
| 9 | −const devServerUrl = `http://localhost:${port}`; | |
| 8 | +const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | |
| 9 | +if (!devServerUrl) { | |
| 10 | + throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); | |
| 11 | +} | |
| 12 | + | |
| 13 | +const devServer = new URL(devServerUrl); | |
| 14 | +const port = Number.parseInt(devServer.port, 10); | |
| 15 | +if (!Number.isInteger(port) || port <= 0) { | |
| 16 | + throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); | |
| 17 | +} | |
| 18 | + | |
| 10 | 19 | const requiredFiles = [ |
| 11 | − "dist-electron/main.js", | |
| 12 | − "dist-electron/preload.js", | |
| 20 | + "dist-electron/main.cjs", | |
| 21 | + "dist-electron/preload.cjs", | |
| 13 | 22 | "../server/dist/bin.mjs", |
| 14 | 23 | ]; |
| 15 | 24 | const watchedDirectories = [ |
| 16 | − { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) }, | |
| 25 | + { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) }, | |
| 17 | 26 | { directory: "../server/dist", files: new Set(["bin.mjs"]) }, |
| 18 | 27 | ]; |
| 19 | 28 | const forcedShutdownTimeoutMs = 1_500; |
| @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; · 3 unmodified lines | ||
| 23 | 32 | await waitForResources({ |
| 24 | 33 | baseDir: desktopDir, |
| 25 | 34 | files: requiredFiles, |
| 35 | + tcpHost: devServer.hostname, | |
| 26 | 36 | tcpPort: port, |
| 27 | 37 | }); |
| 28 | 38 | |
| @@ -59,13 +69,10 @@ function startApp() { · 30 unmodified lines | ||
| 59 | 69 | |
| 60 | 70 | const app = spawn( |
| 61 | 71 | resolveElectronPath(), |
| 62 | − [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], | |
| 72 | + [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"], | |
| 63 | 73 | { |
| 64 | 74 | cwd: desktopDir, |
| 65 | − env: { | |
| 66 | − ...childEnv, | |
| 67 | − VITE_DEV_SERVER_URL: devServerUrl, | |
| 68 | − }, | |
| 75 | + env: childEnv, | |
| 69 | 76 | stdio: "inherit", |
| 70 | 77 | }, |
| 71 | 78 | ); |
| @@ -6,8 +6,8 @@ import { · 5 unmodified lines | ||
| 6 | 6 | cpSync, |
| 7 | 7 | existsSync, |
| 8 | 8 | mkdirSync, |
| 9 | + mkdtempSync, | |
| 9 | 10 | readFileSync, |
| 10 | − readdirSync, | |
| 11 | 11 | rmSync, |
| 12 | 12 | statSync, |
| 13 | 13 | writeFileSync, |
| @@ -18,11 +18,14 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 20 | 20 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 21 | −const APP_BUNDLE_ID = "com.t3tools.t3code"; | |
| 22 | −const LAUNCHER_VERSION = 1; | |
| 21 | +const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; | |
| 22 | +const LAUNCHER_VERSION = 2; | |
| 23 | 23 | |
| 24 | 24 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 25 | 25 | export const desktopDir = resolve(__dirname, ".."); |
| 26 | +const repoRoot = resolve(desktopDir, "..", ".."); | |
| 27 | +const defaultIconPath = join(desktopDir, "resources", "icon.icns"); | |
| 28 | +const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png"); | |
| 26 | 29 | |
| 27 | 30 | function setPlistString(plistPath, key, value) { |
| 28 | 31 | const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { |
| @@ -43,6 +46,68 @@ function setPlistString(plistPath, key, value) { · 14 unmodified lines | ||
| 43 | 46 | throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); |
| 44 | 47 | } |
| 45 | 48 | |
| 49 | +function runChecked(command, args) { | |
| 50 | + const result = spawnSync(command, args, { encoding: "utf8" }); | |
| 51 | + if (result.status === 0) { | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + | |
| 55 | + const details = [result.stdout, result.stderr].filter(Boolean).join("\n"); | |
| 56 | + throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim()); | |
| 57 | +} | |
| 58 | + | |
| 59 | +function ensureDevelopmentIconIcns(runtimeDir) { | |
| 60 | + const generatedIconPath = join(runtimeDir, "icon-dev.icns"); | |
| 61 | + mkdirSync(runtimeDir, { recursive: true }); | |
| 62 | + | |
| 63 | + if (!existsSync(developmentMacIconPngPath)) { | |
| 64 | + return defaultIconPath; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs; | |
| 68 | + if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) { | |
| 69 | + return generatedIconPath; | |
| 70 | + } | |
| 71 | + | |
| 72 | + const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-")); | |
| 73 | + const iconsetDir = join(iconsetRoot, "icon.iconset"); | |
| 74 | + mkdirSync(iconsetDir, { recursive: true }); | |
| 75 | + | |
| 76 | + try { | |
| 77 | + for (const size of [16, 32, 128, 256, 512]) { | |
| 78 | + runChecked("sips", [ | |
| 79 | + "-z", | |
| 80 | + String(size), | |
| 81 | + String(size), | |
| 82 | + developmentMacIconPngPath, | |
| 83 | + "--out", | |
| 84 | + join(iconsetDir, `icon_${size}x${size}.png`), | |
| 85 | + ]); | |
| 86 | + | |
| 87 | + const retinaSize = size * 2; | |
| 88 | + runChecked("sips", [ | |
| 89 | + "-z", | |
| 90 | + String(retinaSize), | |
| 91 | + String(retinaSize), | |
| 92 | + developmentMacIconPngPath, | |
| 93 | + "--out", | |
| 94 | + join(iconsetDir, `icon_${size}x${size}@2x.png`), | |
| 95 | + ]); | |
| 96 | + } | |
| 97 | + | |
| 98 | + runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]); | |
| 99 | + return generatedIconPath; | |
| 100 | + } catch (error) { | |
| 101 | + console.warn( | |
| 102 | + "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.", | |
| 103 | + error, | |
| 104 | + ); | |
| 105 | + return defaultIconPath; | |
| 106 | + } finally { | |
| 107 | + rmSync(iconsetRoot, { recursive: true, force: true }); | |
| 108 | + } | |
| 109 | +} | |
| 110 | + | |
| 46 | 111 | function patchMainBundleInfoPlist(appBundlePath, iconPath) { |
| 47 | 112 | const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); |
| 48 | 113 | setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); |
| @@ -55,40 +120,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { · 6 unmodified lines | ||
| 55 | 120 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); |
| 56 | 121 | } |
| 57 | 122 | |
| 58 | −function patchHelperBundleInfoPlists(appBundlePath) { | |
| 59 | − const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); | |
| 60 | − if (!existsSync(frameworksDir)) { | |
| 61 | − return; | |
| 62 | − } | |
| 63 | − | |
| 64 | − for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { | |
| 65 | − if (!entry.isDirectory() || !entry.name.endsWith(".app")) { | |
| 66 | − continue; | |
| 67 | − } | |
| 68 | − if (!entry.name.startsWith("Electron Helper")) { | |
| 69 | − continue; | |
| 70 | − } | |
| 71 | − | |
| 72 | − const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); | |
| 73 | − if (!existsSync(helperPlistPath)) { | |
| 74 | − continue; | |
| 75 | − } | |
| 76 | − | |
| 77 | − const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); | |
| 78 | − const helperName = suffix | |
| 79 | − ? `${APP_DISPLAY_NAME} Helper ${suffix}` | |
| 80 | − : `${APP_DISPLAY_NAME} Helper`; | |
| 81 | − const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); | |
| 82 | − const helperBundleId = helperIdSuffix | |
| 83 | − ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` | |
| 84 | − : `${APP_BUNDLE_ID}.helper`; | |
| 85 | − | |
| 86 | − setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); | |
| 87 | − setPlistString(helperPlistPath, "CFBundleName", helperName); | |
| 88 | − setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); | |
| 89 | − } | |
| 90 | −} | |
| 91 | − | |
| 92 | 123 | function readJson(path) { |
| 93 | 124 | try { |
| 94 | 125 | return JSON.parse(readFileSync(path, "utf8")); |
| @@ -102,7 +133,7 @@ function buildMacLauncher(electronBinaryPath) { · 7 unmodified lines | ||
| 102 | 133 | const runtimeDir = join(desktopDir, ".electron-runtime"); |
| 103 | 134 | const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); |
| 104 | 135 | const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); |
| 105 | − const iconPath = join(desktopDir, "resources", "icon.icns"); | |
| 136 | + const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; | |
| 106 | 137 | const metadataPath = join(runtimeDir, "metadata.json"); |
| 107 | 138 | |
| 108 | 139 | mkdirSync(runtimeDir, { recursive: true }); |
| @@ -126,7 +157,6 @@ function buildMacLauncher(electronBinaryPath) { · 17 unmodified lines | ||
| 126 | 157 | rmSync(targetAppBundlePath, { recursive: true, force: true }); |
| 127 | 158 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); |
| 128 | 159 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); |
| 129 | − patchHelperBundleInfoPlists(targetAppBundlePath); | |
| 130 | 160 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); |
| 131 | 161 | |
| 132 | 162 | return targetBinaryPath; |
| @@ -140,5 +170,11 @@ export function resolveElectronPath() { · 7 unmodified lines | ||
| 140 | 170 | return electronBinaryPath; |
| 141 | 171 | } |
| 142 | 172 | |
| 173 | + // Dev launches do not need a renamed app bundle badly enough to risk breaking | |
| 174 | + // Electron helper resource lookup on macOS. | |
| 175 | + if (isDevelopment) { | |
| 176 | + return electronBinaryPath; | |
| 177 | + } | |
| 178 | + | |
| 143 | 179 | return buildMacLauncher(electronBinaryPath); |
| 144 | 180 | } |
| @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines | ||
| 5 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 6 | 6 | const desktopDir = resolve(__dirname, ".."); |
| 7 | 7 | const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); |
| 8 | −const mainJs = resolve(desktopDir, "dist-electron/main.js"); | |
| 8 | +const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); | |
| 9 | 9 | |
| 10 | 10 | console.log("\nLaunching Electron smoke test..."); |
| 11 | 11 | |
| @@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; · 4 unmodified lines | ||
| 5 | 5 | const childEnv = { ...process.env }; |
| 6 | 6 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 7 | 7 | |
| 8 | −const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { | |
| 8 | +const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { | |
| 9 | 9 | stdio: "inherit", |
| 10 | 10 | cwd: desktopDir, |
| 11 | 11 | env: childEnv, |
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts"; | |
| 4 | + | |
| 5 | +describe("resolveDesktopAppStageLabel", () => { | |
| 6 | + it("uses Dev in desktop development", () => { | |
| 7 | + expect( | |
| 8 | + resolveDesktopAppStageLabel({ | |
| 9 | + isDevelopment: true, | |
| 10 | + appVersion: "0.0.17-nightly.20260414.1", | |
| 11 | + }), | |
| 12 | + ).toBe("Dev"); | |
| 13 | + }); | |
| 14 | + | |
| 15 | + it("uses Nightly for packaged nightly builds", () => { | |
| 16 | + expect( | |
| 17 | + resolveDesktopAppStageLabel({ | |
| 18 | + isDevelopment: false, | |
| 19 | + appVersion: "0.0.17-nightly.20260414.1", | |
| 20 | + }), | |
| 21 | + ).toBe("Nightly"); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("uses Alpha for packaged stable builds", () => { | |
| 25 | + expect( | |
| 26 | + resolveDesktopAppStageLabel({ | |
| 27 | + isDevelopment: false, | |
| 28 | + appVersion: "0.0.17", | |
| 29 | + }), | |
| 30 | + ).toBe("Alpha"); | |
| 31 | + }); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +describe("resolveDesktopAppBranding", () => { | |
| 35 | + it("returns a complete desktop branding payload", () => { | |
| 36 | + expect( | |
| 37 | + resolveDesktopAppBranding({ | |
| 38 | + isDevelopment: false, | |
| 39 | + appVersion: "0.0.17-nightly.20260414.1", | |
| 40 | + }), | |
| 41 | + ).toEqual({ | |
| 42 | + baseName: "T3 Code", | |
| 43 | + stageLabel: "Nightly", | |
| 44 | + displayName: "T3 Code (Nightly)", | |
| 45 | + }); | |
| 46 | + }); | |
| 47 | +}); | |
| @@ -0,0 +1,28 @@ | ||
| 1 | +import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +import { isNightlyDesktopVersion } from "./updateChannels.ts"; | |
| 4 | + | |
| 5 | +const APP_BASE_NAME = "T3 Code"; | |
| 6 | + | |
| 7 | +export function resolveDesktopAppStageLabel(input: { | |
| 8 | + readonly isDevelopment: boolean; | |
| 9 | + readonly appVersion: string; | |
| 10 | +}): DesktopAppStageLabel { | |
| 11 | + if (input.isDevelopment) { | |
| 12 | + return "Dev"; | |
| 13 | + } | |
| 14 | + | |
| 15 | + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function resolveDesktopAppBranding(input: { | |
| 19 | + readonly isDevelopment: boolean; | |
| 20 | + readonly appVersion: string; | |
| 21 | +}): DesktopAppBranding { | |
| 22 | + const stageLabel = resolveDesktopAppStageLabel(input); | |
| 23 | + return { | |
| 24 | + baseName: APP_BASE_NAME, | |
| 25 | + stageLabel, | |
| 26 | + displayName: `${APP_BASE_NAME} (${stageLabel})`, | |
| 27 | + }; | |
| 28 | +} | |
| @@ -0,0 +1,109 @@ | ||
| 1 | +import { describe, expect, it, vi } from "vitest"; | |
| 2 | + | |
| 3 | +import { resolveDesktopBackendPort } from "./backendPort.ts"; | |
| 4 | + | |
| 5 | +describe("resolveDesktopBackendPort", () => { | |
| 6 | + it("returns the starting port when it is available", async () => { | |
| 7 | + const canListenOnHost = vi.fn(async (port: number) => port === 3773); | |
| 8 | + | |
| 9 | + await expect( | |
| 10 | + resolveDesktopBackendPort({ | |
| 11 | + host: "127.0.0.1", | |
| 12 | + startPort: 3773, | |
| 13 | + canListenOnHost, | |
| 14 | + }), | |
| 15 | + ).resolves.toBe(3773); | |
| 16 | + | |
| 17 | + expect(canListenOnHost).toHaveBeenCalledTimes(1); | |
| 18 | + expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1"); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("increments sequentially until it finds an available port", async () => { | |
| 22 | + const canListenOnHost = vi.fn(async (port: number) => port === 3775); | |
| 23 | + | |
| 24 | + await expect( | |
| 25 | + resolveDesktopBackendPort({ | |
| 26 | + host: "127.0.0.1", | |
| 27 | + startPort: 3773, | |
| 28 | + canListenOnHost, | |
| 29 | + }), | |
| 30 | + ).resolves.toBe(3775); | |
| 31 | + | |
| 32 | + expect(canListenOnHost.mock.calls).toEqual([ | |
| 33 | + [3773, "127.0.0.1"], | |
| 34 | + [3774, "127.0.0.1"], | |
| 35 | + [3775, "127.0.0.1"], | |
| 36 | + ]); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { | |
| 40 | + const canListenOnHost = vi.fn(async (port: number, host: string) => { | |
| 41 | + if (port === 3773 && host === "127.0.0.1") return true; | |
| 42 | + if (port === 3773 && host === "0.0.0.0") return false; | |
| 43 | + return port === 3774; | |
| 44 | + }); | |
| 45 | + | |
| 46 | + await expect( | |
| 47 | + resolveDesktopBackendPort({ | |
| 48 | + host: "127.0.0.1", | |
| 49 | + requiredHosts: ["0.0.0.0"], | |
| 50 | + startPort: 3773, | |
| 51 | + canListenOnHost, | |
| 52 | + }), | |
| 53 | + ).resolves.toBe(3774); | |
| 54 | + | |
| 55 | + expect(canListenOnHost.mock.calls).toEqual([ | |
| 56 | + [3773, "127.0.0.1"], | |
| 57 | + [3773, "0.0.0.0"], | |
| 58 | + [3774, "127.0.0.1"], | |
| 59 | + [3774, "0.0.0.0"], | |
| 60 | + ]); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("checks overlapping hosts sequentially to avoid self-interference", async () => { | |
| 64 | + let inFlightCount = 0; | |
| 65 | + const canListenOnHost = vi.fn(async (_port: number, _host: string) => { | |
| 66 | + inFlightCount += 1; | |
| 67 | + const overlapped = inFlightCount > 1; | |
| 68 | + await Promise.resolve(); | |
| 69 | + inFlightCount -= 1; | |
| 70 | + return !overlapped; | |
| 71 | + }); | |
| 72 | + | |
| 73 | + await expect( | |
| 74 | + resolveDesktopBackendPort({ | |
| 75 | + host: "127.0.0.1", | |
| 76 | + requiredHosts: ["0.0.0.0", "::"], | |
| 77 | + startPort: 3773, | |
| 78 | + maxPort: 3773, | |
| 79 | + canListenOnHost, | |
| 80 | + }), | |
| 81 | + ).resolves.toBe(3773); | |
| 82 | + | |
| 83 | + expect(canListenOnHost.mock.calls).toEqual([ | |
| 84 | + [3773, "127.0.0.1"], | |
| 85 | + [3773, "0.0.0.0"], | |
| 86 | + [3773, "::"], | |
| 87 | + ]); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it("fails when the scan range is exhausted", async () => { | |
| 91 | + const canListenOnHost = vi.fn(async () => false); | |
| 92 | + | |
| 93 | + await expect( | |
| 94 | + resolveDesktopBackendPort({ | |
| 95 | + host: "127.0.0.1", | |
| 96 | + startPort: 65534, | |
| 97 | + maxPort: 65535, | |
| 98 | + canListenOnHost, | |
| 99 | + }), | |
| 100 | + ).rejects.toThrow( | |
| 101 | + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", | |
| 102 | + ); | |
| 103 | + | |
| 104 | + expect(canListenOnHost.mock.calls).toEqual([ | |
| 105 | + [65534, "127.0.0.1"], | |
| 106 | + [65535, "127.0.0.1"], | |
| 107 | + ]); | |
| 108 | + }); | |
| 109 | +}); | |
| @@ -0,0 +1,83 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | +import { NetService } from "@t3tools/shared/Net"; | |
| 3 | + | |
| 4 | +export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; | |
| 5 | +const MAX_TCP_PORT = 65_535; | |
| 6 | + | |
| 7 | +export interface ResolveDesktopBackendPortOptions { | |
| 8 | + readonly host: string; | |
| 9 | + readonly startPort?: number; | |
| 10 | + readonly maxPort?: number; | |
| 11 | + readonly requiredHosts?: ReadonlyArray<string>; | |
| 12 | + readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>; | |
| 13 | +} | |
| 14 | + | |
| 15 | +const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> => | |
| 16 | + Effect.service(NetService).pipe( | |
| 17 | + Effect.flatMap((net) => net.canListenOnHost(port, host)), | |
| 18 | + Effect.provide(NetService.layer), | |
| 19 | + Effect.runPromise, | |
| 20 | + ); | |
| 21 | + | |
| 22 | +const isValidPort = (port: number): boolean => | |
| 23 | + Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; | |
| 24 | + | |
| 25 | +const normalizeHosts = ( | |
| 26 | + host: string, | |
| 27 | + requiredHosts: ReadonlyArray<string>, | |
| 28 | +): ReadonlyArray<string> => | |
| 29 | + Array.from( | |
| 30 | + new Set( | |
| 31 | + [host, ...requiredHosts] | |
| 32 | + .map((candidate) => candidate.trim()) | |
| 33 | + .filter((candidate) => candidate.length > 0), | |
| 34 | + ), | |
| 35 | + ); | |
| 36 | + | |
| 37 | +async function canListenOnAllHosts( | |
| 38 | + port: number, | |
| 39 | + hosts: ReadonlyArray<string>, | |
| 40 | + canListenOnHost: (port: number, host: string) => Promise<boolean>, | |
| 41 | +): Promise<boolean> { | |
| 42 | + for (const candidateHost of hosts) { | |
| 43 | + if (!(await canListenOnHost(port, candidateHost))) { | |
| 44 | + return false; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + return true; | |
| 49 | +} | |
| 50 | + | |
| 51 | +export async function resolveDesktopBackendPort({ | |
| 52 | + host, | |
| 53 | + startPort = DEFAULT_DESKTOP_BACKEND_PORT, | |
| 54 | + maxPort = MAX_TCP_PORT, | |
| 55 | + requiredHosts = [], | |
| 56 | + canListenOnHost = defaultCanListenOnHost, | |
| 57 | +}: ResolveDesktopBackendPortOptions): Promise<number> { | |
| 58 | + if (!isValidPort(startPort)) { | |
| 59 | + throw new Error(`Invalid desktop backend start port: ${startPort}`); | |
| 60 | + } | |
| 61 | + | |
| 62 | + if (!isValidPort(maxPort)) { | |
| 63 | + throw new Error(`Invalid desktop backend max port: ${maxPort}`); | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (maxPort < startPort) { | |
| 67 | + throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); | |
| 68 | + } | |
| 69 | + | |
| 70 | + const hostsToCheck = normalizeHosts(host, requiredHosts); | |
| 71 | + | |
| 72 | + // Keep desktop startup predictable across app restarts by probing upward from | |
| 73 | + // the same preferred port instead of picking a fresh ephemeral port. | |
| 74 | + for (let port = startPort; port <= maxPort; port += 1) { | |
| 75 | + if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { | |
| 76 | + return port; | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + throw new Error( | |
| 81 | + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, | |
| 82 | + ); | |
| 83 | +} | |
| @@ -0,0 +1,114 @@ | ||
| 1 | +import { describe, expect, it, vi } from "vitest"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + BackendReadinessAbortedError, | |
| 5 | + isBackendReadinessAborted, | |
| 6 | + waitForHttpReady, | |
| 7 | +} from "./backendReadiness.ts"; | |
| 8 | + | |
| 9 | +describe("waitForHttpReady", () => { | |
| 10 | + it("returns once the backend serves the requested readiness path", async () => { | |
| 11 | + const fetchImpl = vi | |
| 12 | + .fn<typeof fetch>() | |
| 13 | + .mockResolvedValueOnce(new Response(null, { status: 503 })) | |
| 14 | + .mockResolvedValueOnce(new Response(null, { status: 200 })); | |
| 15 | + | |
| 16 | + await waitForHttpReady("http://127.0.0.1:3773", { | |
| 17 | + fetchImpl, | |
| 18 | + timeoutMs: 1_000, | |
| 19 | + intervalMs: 0, | |
| 20 | + }); | |
| 21 | + | |
| 22 | + expect(fetchImpl).toHaveBeenCalledTimes(2); | |
| 23 | + expect(fetchImpl).toHaveBeenNthCalledWith( | |
| 24 | + 1, | |
| 25 | + "http://127.0.0.1:3773/", | |
| 26 | + expect.objectContaining({ redirect: "manual" }), | |
| 27 | + ); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it("retries after a readiness request stalls past the per-request timeout", async () => { | |
| 31 | + const fetchImpl = vi | |
| 32 | + .fn<typeof fetch>() | |
| 33 | + .mockImplementationOnce( | |
| 34 | + (_input, init) => | |
| 35 | + new Promise((_resolve, reject) => { | |
| 36 | + init?.signal?.addEventListener( | |
| 37 | + "abort", | |
| 38 | + () => { | |
| 39 | + reject(new Error("request timed out")); | |
| 40 | + }, | |
| 41 | + { once: true }, | |
| 42 | + ); | |
| 43 | + }) as ReturnType<typeof fetch>, | |
| 44 | + ) | |
| 45 | + .mockResolvedValueOnce(new Response(null, { status: 200 })); | |
| 46 | + | |
| 47 | + await waitForHttpReady("http://127.0.0.1:3773", { | |
| 48 | + fetchImpl, | |
| 49 | + timeoutMs: 100, | |
| 50 | + intervalMs: 0, | |
| 51 | + requestTimeoutMs: 1, | |
| 52 | + }); | |
| 53 | + | |
| 54 | + expect(fetchImpl).toHaveBeenCalledTimes(2); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("aborts an in-flight readiness wait", async () => { | |
| 58 | + const controller = new AbortController(); | |
| 59 | + const fetchImpl = vi.fn<typeof fetch>().mockImplementation( | |
| 60 | + () => | |
| 61 | + new Promise((_resolve, reject) => { | |
| 62 | + controller.signal.addEventListener( | |
| 63 | + "abort", | |
| 64 | + () => { | |
| 65 | + reject(new BackendReadinessAbortedError()); | |
| 66 | + }, | |
| 67 | + { once: true }, | |
| 68 | + ); | |
| 69 | + }) as ReturnType<typeof fetch>, | |
| 70 | + ); | |
| 71 | + | |
| 72 | + const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { | |
| 73 | + fetchImpl, | |
| 74 | + timeoutMs: 1_000, | |
| 75 | + intervalMs: 0, | |
| 76 | + signal: controller.signal, | |
| 77 | + }); | |
| 78 | + | |
| 79 | + controller.abort(); | |
| 80 | + | |
| 81 | + await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it("recognizes aborted readiness errors", () => { | |
| 85 | + expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); | |
| 86 | + expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it("supports custom readiness predicates", async () => { | |
| 90 | + const fetchImpl = vi | |
| 91 | + .fn<typeof fetch>() | |
| 92 | + .mockResolvedValueOnce(new Response(null, { status: 200 })) | |
| 93 | + .mockResolvedValueOnce(new Response(null, { status: 204 })); | |
| 94 | + | |
| 95 | + await waitForHttpReady("http://127.0.0.1:3773", { | |
| 96 | + fetchImpl, | |
| 97 | + timeoutMs: 1_000, | |
| 98 | + intervalMs: 0, | |
| 99 | + path: "/api/healthz", | |
| 100 | + isReady: (response) => response.status === 204, | |
| 101 | + }); | |
| 102 | + | |
| 103 | + expect(fetchImpl).toHaveBeenNthCalledWith( | |
| 104 | + 1, | |
| 105 | + "http://127.0.0.1:3773/api/healthz", | |
| 106 | + expect.objectContaining({ redirect: "manual" }), | |
| 107 | + ); | |
| 108 | + expect(fetchImpl).toHaveBeenNthCalledWith( | |
| 109 | + 2, | |
| 110 | + "http://127.0.0.1:3773/api/healthz", | |
| 111 | + expect.objectContaining({ redirect: "manual" }), | |
| 112 | + ); | |
| 113 | + }); | |
| 114 | +}); | |
| @@ -0,0 +1,107 @@ | ||
| 1 | +export interface WaitForHttpReadyOptions { | |
| 2 | + readonly timeoutMs?: number; | |
| 3 | + readonly intervalMs?: number; | |
| 4 | + readonly requestTimeoutMs?: number; | |
| 5 | + readonly fetchImpl?: typeof fetch; | |
| 6 | + readonly signal?: AbortSignal; | |
| 7 | + readonly path?: string; | |
| 8 | + readonly isReady?: (response: Response) => boolean; | |
| 9 | +} | |
| 10 | + | |
| 11 | +const DEFAULT_TIMEOUT_MS = 30_000; | |
| 12 | +const DEFAULT_INTERVAL_MS = 100; | |
| 13 | +const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; | |
| 14 | + | |
| 15 | +export class BackendReadinessAbortedError extends Error { | |
| 16 | + constructor() { | |
| 17 | + super("Backend readiness wait was aborted."); | |
| 18 | + this.name = "BackendReadinessAbortedError"; | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +function delay(ms: number, signal: AbortSignal | undefined): Promise<void> { | |
| 23 | + return new Promise<void>((resolve, reject) => { | |
| 24 | + const timer = setTimeout(() => { | |
| 25 | + cleanup(); | |
| 26 | + resolve(); | |
| 27 | + }, ms); | |
| 28 | + | |
| 29 | + const onAbort = () => { | |
| 30 | + cleanup(); | |
| 31 | + reject(new BackendReadinessAbortedError()); | |
| 32 | + }; | |
| 33 | + | |
| 34 | + const cleanup = () => { | |
| 35 | + clearTimeout(timer); | |
| 36 | + signal?.removeEventListener("abort", onAbort); | |
| 37 | + }; | |
| 38 | + | |
| 39 | + if (signal?.aborted) { | |
| 40 | + cleanup(); | |
| 41 | + reject(new BackendReadinessAbortedError()); | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + | |
| 45 | + signal?.addEventListener("abort", onAbort, { once: true }); | |
| 46 | + }); | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { | |
| 50 | + return error instanceof BackendReadinessAbortedError; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export async function waitForHttpReady( | |
| 54 | + baseUrl: string, | |
| 55 | + options?: WaitForHttpReadyOptions, | |
| 56 | +): Promise<void> { | |
| 57 | + const fetchImpl = options?.fetchImpl ?? fetch; | |
| 58 | + const signal = options?.signal; | |
| 59 | + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 60 | + const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; | |
| 61 | + const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; | |
| 62 | + const readinessPath = options?.path ?? "/"; | |
| 63 | + const isReady = options?.isReady ?? ((response: Response) => response.ok); | |
| 64 | + const deadline = Date.now() + timeoutMs; | |
| 65 | + | |
| 66 | + for (;;) { | |
| 67 | + if (signal?.aborted) { | |
| 68 | + throw new BackendReadinessAbortedError(); | |
| 69 | + } | |
| 70 | + | |
| 71 | + const requestController = new AbortController(); | |
| 72 | + const requestTimeout = setTimeout(() => { | |
| 73 | + requestController.abort(); | |
| 74 | + }, requestTimeoutMs); | |
| 75 | + const abortRequest = () => { | |
| 76 | + requestController.abort(); | |
| 77 | + }; | |
| 78 | + signal?.addEventListener("abort", abortRequest, { once: true }); | |
| 79 | + | |
| 80 | + try { | |
| 81 | + const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), { | |
| 82 | + redirect: "manual", | |
| 83 | + signal: requestController.signal, | |
| 84 | + }); | |
| 85 | + if (isReady(response)) { | |
| 86 | + return; | |
| 87 | + } | |
| 88 | + } catch (error) { | |
| 89 | + if (isBackendReadinessAborted(error)) { | |
| 90 | + throw error; | |
| 91 | + } | |
| 92 | + if (signal?.aborted) { | |
| 93 | + throw new BackendReadinessAbortedError(); | |
| 94 | + } | |
| 95 | + // Retry until the backend becomes reachable or the deadline expires. | |
| 96 | + } finally { | |
| 97 | + clearTimeout(requestTimeout); | |
| 98 | + signal?.removeEventListener("abort", abortRequest); | |
| 99 | + } | |
| 100 | + | |
| 101 | + if (Date.now() >= deadline) { | |
| 102 | + throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); | |
| 103 | + } | |
| 104 | + | |
| 105 | + await delay(intervalMs, signal); | |
| 106 | + } | |
| 107 | +} | |
| @@ -0,0 +1,238 @@ | ||
| 1 | +import * as fs from "node:fs"; | |
| 2 | +import * as os from "node:os"; | |
| 3 | +import * as path from "node:path"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + EnvironmentId, | |
| 7 | + type ClientSettings, | |
| 8 | + type PersistedSavedEnvironmentRecord, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 11 | + | |
| 12 | +import { | |
| 13 | + readClientSettings, | |
| 14 | + readSavedEnvironmentRegistry, | |
| 15 | + readSavedEnvironmentSecret, | |
| 16 | + removeSavedEnvironmentSecret, | |
| 17 | + writeClientSettings, | |
| 18 | + writeSavedEnvironmentRegistry, | |
| 19 | + writeSavedEnvironmentSecret, | |
| 20 | + type DesktopSecretStorage, | |
| 21 | +} from "./clientPersistence.ts"; | |
| 22 | + | |
| 23 | +const tempDirectories: string[] = []; | |
| 24 | + | |
| 25 | +afterEach(() => { | |
| 26 | + for (const directory of tempDirectories.splice(0)) { | |
| 27 | + fs.rmSync(directory, { recursive: true, force: true }); | |
| 28 | + } | |
| 29 | +}); | |
| 30 | + | |
| 31 | +function makeTempPath(fileName: string): string { | |
| 32 | + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-")); | |
| 33 | + tempDirectories.push(directory); | |
| 34 | + return path.join(directory, fileName); | |
| 35 | +} | |
| 36 | + | |
| 37 | +function makeSecretStorage(available: boolean): DesktopSecretStorage { | |
| 38 | + return { | |
| 39 | + isEncryptionAvailable: () => available, | |
| 40 | + encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"), | |
| 41 | + decryptString: (value) => { | |
| 42 | + const decoded = value.toString("utf8"); | |
| 43 | + if (!decoded.startsWith("enc:")) { | |
| 44 | + throw new Error("invalid secret"); | |
| 45 | + } | |
| 46 | + return decoded.slice("enc:".length); | |
| 47 | + }, | |
| 48 | + }; | |
| 49 | +} | |
| 50 | + | |
| 51 | +const clientSettings: ClientSettings = { | |
| 52 | + confirmThreadArchive: true, | |
| 53 | + confirmThreadDelete: false, | |
| 54 | + diffWordWrap: true, | |
| 55 | + sidebarProjectGroupingMode: "repository_path", | |
| 56 | + sidebarProjectGroupingOverrides: { | |
| 57 | + "environment-1:/tmp/project-a": "separate", | |
| 58 | + }, | |
| 59 | + sidebarProjectSortOrder: "manual", | |
| 60 | + sidebarThreadSortOrder: "created_at", | |
| 61 | + timestampFormat: "24-hour", | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const savedRegistryRecord: PersistedSavedEnvironmentRecord = { | |
| 65 | + environmentId: EnvironmentId.make("environment-1"), | |
| 66 | + label: "Remote environment", | |
| 67 | + httpBaseUrl: "https://remote.example.com/", | |
| 68 | + wsBaseUrl: "wss://remote.example.com/", | |
| 69 | + createdAt: "2026-04-09T00:00:00.000Z", | |
| 70 | + lastConnectedAt: "2026-04-09T01:00:00.000Z", | |
| 71 | +}; | |
| 72 | + | |
| 73 | +describe("clientPersistence", () => { | |
| 74 | + it("persists and reloads client settings", () => { | |
| 75 | + const settingsPath = makeTempPath("client-settings.json"); | |
| 76 | + | |
| 77 | + writeClientSettings(settingsPath, clientSettings); | |
| 78 | + | |
| 79 | + expect(readClientSettings(settingsPath)).toEqual(clientSettings); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("persists and reloads saved environment metadata", () => { | |
| 83 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 84 | + | |
| 85 | + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 86 | + | |
| 87 | + expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it("persists encrypted saved environment secrets when encryption is available", () => { | |
| 91 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 92 | + const secretStorage = makeSecretStorage(true); | |
| 93 | + | |
| 94 | + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 95 | + | |
| 96 | + expect( | |
| 97 | + writeSavedEnvironmentSecret({ | |
| 98 | + registryPath, | |
| 99 | + environmentId: savedRegistryRecord.environmentId, | |
| 100 | + secret: "bearer-token", | |
| 101 | + secretStorage, | |
| 102 | + }), | |
| 103 | + ).toBe(true); | |
| 104 | + | |
| 105 | + expect( | |
| 106 | + readSavedEnvironmentSecret({ | |
| 107 | + registryPath, | |
| 108 | + environmentId: savedRegistryRecord.environmentId, | |
| 109 | + secretStorage, | |
| 110 | + }), | |
| 111 | + ).toBe("bearer-token"); | |
| 112 | + | |
| 113 | + expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({ | |
| 114 | + records: [ | |
| 115 | + { | |
| 116 | + ...savedRegistryRecord, | |
| 117 | + encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"), | |
| 118 | + }, | |
| 119 | + ], | |
| 120 | + }); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("preserves existing secrets when encryption is unavailable", () => { | |
| 124 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 125 | + const availableSecretStorage = makeSecretStorage(true); | |
| 126 | + | |
| 127 | + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 128 | + | |
| 129 | + writeSavedEnvironmentSecret({ | |
| 130 | + registryPath, | |
| 131 | + environmentId: savedRegistryRecord.environmentId, | |
| 132 | + secret: "bearer-token", | |
| 133 | + secretStorage: availableSecretStorage, | |
| 134 | + }); | |
| 135 | + | |
| 136 | + expect( | |
| 137 | + writeSavedEnvironmentSecret({ | |
| 138 | + registryPath, | |
| 139 | + environmentId: savedRegistryRecord.environmentId, | |
| 140 | + secret: "next-token", | |
| 141 | + secretStorage: makeSecretStorage(false), | |
| 142 | + }), | |
| 143 | + ).toBe(false); | |
| 144 | + | |
| 145 | + expect( | |
| 146 | + readSavedEnvironmentSecret({ | |
| 147 | + registryPath, | |
| 148 | + environmentId: savedRegistryRecord.environmentId, | |
| 149 | + secretStorage: availableSecretStorage, | |
| 150 | + }), | |
| 151 | + ).toBe("bearer-token"); | |
| 152 | + }); | |
| 153 | + | |
| 154 | + it("removes saved environment secrets", () => { | |
| 155 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 156 | + const secretStorage = makeSecretStorage(true); | |
| 157 | + | |
| 158 | + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 159 | + | |
| 160 | + writeSavedEnvironmentSecret({ | |
| 161 | + registryPath, | |
| 162 | + environmentId: savedRegistryRecord.environmentId, | |
| 163 | + secret: "bearer-token", | |
| 164 | + secretStorage, | |
| 165 | + }); | |
| 166 | + | |
| 167 | + removeSavedEnvironmentSecret({ | |
| 168 | + registryPath, | |
| 169 | + environmentId: savedRegistryRecord.environmentId, | |
| 170 | + }); | |
| 171 | + | |
| 172 | + expect( | |
| 173 | + readSavedEnvironmentSecret({ | |
| 174 | + registryPath, | |
| 175 | + environmentId: savedRegistryRecord.environmentId, | |
| 176 | + secretStorage, | |
| 177 | + }), | |
| 178 | + ).toBeNull(); | |
| 179 | + }); | |
| 180 | + | |
| 181 | + it("treats malformed secrets documents as empty", () => { | |
| 182 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 183 | + fs.writeFileSync(registryPath, "{}\n", "utf8"); | |
| 184 | + | |
| 185 | + expect( | |
| 186 | + readSavedEnvironmentSecret({ | |
| 187 | + registryPath, | |
| 188 | + environmentId: savedRegistryRecord.environmentId, | |
| 189 | + secretStorage: makeSecretStorage(true), | |
| 190 | + }), | |
| 191 | + ).toBeNull(); | |
| 192 | + | |
| 193 | + expect(() => | |
| 194 | + removeSavedEnvironmentSecret({ | |
| 195 | + registryPath, | |
| 196 | + environmentId: savedRegistryRecord.environmentId, | |
| 197 | + }), | |
| 198 | + ).not.toThrow(); | |
| 199 | + }); | |
| 200 | + | |
| 201 | + it("returns false when writing a secret without metadata", () => { | |
| 202 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 203 | + | |
| 204 | + expect( | |
| 205 | + writeSavedEnvironmentSecret({ | |
| 206 | + registryPath, | |
| 207 | + environmentId: savedRegistryRecord.environmentId, | |
| 208 | + secret: "bearer-token", | |
| 209 | + secretStorage: makeSecretStorage(true), | |
| 210 | + }), | |
| 211 | + ).toBe(false); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + it("preserves encrypted secrets when metadata is rewritten", () => { | |
| 215 | + const registryPath = makeTempPath("saved-environments.json"); | |
| 216 | + const secretStorage = makeSecretStorage(true); | |
| 217 | + | |
| 218 | + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 219 | + | |
| 220 | + writeSavedEnvironmentSecret({ | |
| 221 | + registryPath, | |
| 222 | + environmentId: savedRegistryRecord.environmentId, | |
| 223 | + secret: "bearer-token", | |
| 224 | + secretStorage, | |
| 225 | + }); | |
| 226 | + | |
| 227 | + writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]); | |
| 228 | + | |
| 229 | + expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]); | |
| 230 | + expect( | |
| 231 | + readSavedEnvironmentSecret({ | |
| 232 | + registryPath, | |
| 233 | + environmentId: savedRegistryRecord.environmentId, | |
| 234 | + secretStorage, | |
| 235 | + }), | |
| 236 | + ).toBe("bearer-token"); | |
| 237 | + }); | |
| 238 | +}); | |
| @@ -0,0 +1,229 @@ | ||
| 1 | +import * as FS from "node:fs"; | |
| 2 | +import * as Path from "node:path"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + ClientSettingsSchema, | |
| 6 | + type ClientSettings, | |
| 7 | + type PersistedSavedEnvironmentRecord, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import { Predicate } from "effect"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 11 | + | |
| 12 | +interface ClientSettingsDocument { | |
| 13 | + readonly settings: ClientSettings; | |
| 14 | +} | |
| 15 | + | |
| 16 | +interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord { | |
| 17 | + readonly encryptedBearerToken?: string; | |
| 18 | +} | |
| 19 | + | |
| 20 | +interface SavedEnvironmentRegistryDocument { | |
| 21 | + readonly records: readonly PersistedSavedEnvironmentStorageRecord[]; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export interface DesktopSecretStorage { | |
| 25 | + readonly isEncryptionAvailable: () => boolean; | |
| 26 | + readonly encryptString: (value: string) => Buffer; | |
| 27 | + readonly decryptString: (value: Buffer) => string; | |
| 28 | +} | |
| 29 | + | |
| 30 | +function readJsonFile<T>(filePath: string): T | null { | |
| 31 | + try { | |
| 32 | + if (!FS.existsSync(filePath)) { | |
| 33 | + return null; | |
| 34 | + } | |
| 35 | + return JSON.parse(FS.readFileSync(filePath, "utf8")) as T; | |
| 36 | + } catch { | |
| 37 | + return null; | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +function writeJsonFile(filePath: string, value: unknown): void { | |
| 42 | + const directory = Path.dirname(filePath); | |
| 43 | + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; | |
| 44 | + FS.mkdirSync(directory, { recursive: true }); | |
| 45 | + FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); | |
| 46 | + FS.renameSync(tempPath, filePath); | |
| 47 | +} | |
| 48 | + | |
| 49 | +function isPersistedSavedEnvironmentStorageRecord( | |
| 50 | + value: unknown, | |
| 51 | +): value is PersistedSavedEnvironmentStorageRecord { | |
| 52 | + return ( | |
| 53 | + Predicate.isObject(value) && | |
| 54 | + typeof value.environmentId === "string" && | |
| 55 | + typeof value.label === "string" && | |
| 56 | + typeof value.httpBaseUrl === "string" && | |
| 57 | + typeof value.wsBaseUrl === "string" && | |
| 58 | + typeof value.createdAt === "string" && | |
| 59 | + (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && | |
| 60 | + (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") | |
| 61 | + ); | |
| 62 | +} | |
| 63 | + | |
| 64 | +function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument { | |
| 65 | + const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath); | |
| 66 | + if (!Predicate.isObject(parsed)) { | |
| 67 | + return { records: [] }; | |
| 68 | + } | |
| 69 | + | |
| 70 | + return { | |
| 71 | + records: Array.isArray(parsed.records) | |
| 72 | + ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord) | |
| 73 | + : [], | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function toPersistedSavedEnvironmentRecord( | |
| 78 | + record: PersistedSavedEnvironmentStorageRecord, | |
| 79 | +): PersistedSavedEnvironmentRecord { | |
| 80 | + return { | |
| 81 | + environmentId: record.environmentId, | |
| 82 | + label: record.label, | |
| 83 | + httpBaseUrl: record.httpBaseUrl, | |
| 84 | + wsBaseUrl: record.wsBaseUrl, | |
| 85 | + createdAt: record.createdAt, | |
| 86 | + lastConnectedAt: record.lastConnectedAt, | |
| 87 | + }; | |
| 88 | +} | |
| 89 | + | |
| 90 | +export function readClientSettings(settingsPath: string): ClientSettings | null { | |
| 91 | + const raw = readJsonFile<ClientSettingsDocument>(settingsPath)?.settings; | |
| 92 | + if (!raw) { | |
| 93 | + return null; | |
| 94 | + } | |
| 95 | + try { | |
| 96 | + return Schema.decodeUnknownSync(ClientSettingsSchema)(raw); | |
| 97 | + } catch { | |
| 98 | + return null; | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +export function writeClientSettings(settingsPath: string, settings: ClientSettings): void { | |
| 103 | + writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument); | |
| 104 | +} | |
| 105 | + | |
| 106 | +export function readSavedEnvironmentRegistry( | |
| 107 | + registryPath: string, | |
| 108 | +): readonly PersistedSavedEnvironmentRecord[] { | |
| 109 | + return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) => | |
| 110 | + toPersistedSavedEnvironmentRecord(record), | |
| 111 | + ); | |
| 112 | +} | |
| 113 | + | |
| 114 | +export function writeSavedEnvironmentRegistry( | |
| 115 | + registryPath: string, | |
| 116 | + records: readonly PersistedSavedEnvironmentRecord[], | |
| 117 | +): void { | |
| 118 | + const currentDocument = readSavedEnvironmentRegistryDocument(registryPath); | |
| 119 | + const encryptedBearerTokenById = new Map( | |
| 120 | + currentDocument.records.flatMap((record) => | |
| 121 | + record.encryptedBearerToken | |
| 122 | + ? [[record.environmentId, record.encryptedBearerToken] as const] | |
| 123 | + : [], | |
| 124 | + ), | |
| 125 | + ); | |
| 126 | + writeJsonFile(registryPath, { | |
| 127 | + records: records.map((record) => { | |
| 128 | + const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); | |
| 129 | + return encryptedBearerToken | |
| 130 | + ? { | |
| 131 | + environmentId: record.environmentId, | |
| 132 | + label: record.label, | |
| 133 | + httpBaseUrl: record.httpBaseUrl, | |
| 134 | + wsBaseUrl: record.wsBaseUrl, | |
| 135 | + createdAt: record.createdAt, | |
| 136 | + lastConnectedAt: record.lastConnectedAt, | |
| 137 | + encryptedBearerToken, | |
| 138 | + } | |
| 139 | + : record; | |
| 140 | + }), | |
| 141 | + } satisfies SavedEnvironmentRegistryDocument); | |
| 142 | +} | |
| 143 | + | |
| 144 | +export function readSavedEnvironmentSecret(input: { | |
| 145 | + readonly registryPath: string; | |
| 146 | + readonly environmentId: string; | |
| 147 | + readonly secretStorage: DesktopSecretStorage; | |
| 148 | +}): string | null { | |
| 149 | + const document = readSavedEnvironmentRegistryDocument(input.registryPath); | |
| 150 | + const encoded = document.records.find( | |
| 151 | + (record) => record.environmentId === input.environmentId, | |
| 152 | + )?.encryptedBearerToken; | |
| 153 | + if (!encoded) { | |
| 154 | + return null; | |
| 155 | + } | |
| 156 | + | |
| 157 | + if (!input.secretStorage.isEncryptionAvailable()) { | |
| 158 | + return null; | |
| 159 | + } | |
| 160 | + | |
| 161 | + try { | |
| 162 | + return input.secretStorage.decryptString(Buffer.from(encoded, "base64")); | |
| 163 | + } catch { | |
| 164 | + return null; | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +export function writeSavedEnvironmentSecret(input: { | |
| 169 | + readonly registryPath: string; | |
| 170 | + readonly environmentId: string; | |
| 171 | + readonly secret: string; | |
| 172 | + readonly secretStorage: DesktopSecretStorage; | |
| 173 | +}): boolean { | |
| 174 | + const document = readSavedEnvironmentRegistryDocument(input.registryPath); | |
| 175 | + | |
| 176 | + if (!input.secretStorage.isEncryptionAvailable()) { | |
| 177 | + return false; | |
| 178 | + } | |
| 179 | + | |
| 180 | + let found = false; | |
| 181 | + | |
| 182 | + writeJsonFile(input.registryPath, { | |
| 183 | + records: document.records.map((record) => { | |
| 184 | + if (record.environmentId !== input.environmentId) { | |
| 185 | + return record; | |
| 186 | + } | |
| 187 | + | |
| 188 | + found = true; | |
| 189 | + const encryptedBearerToken = input.secretStorage | |
| 190 | + .encryptString(input.secret) | |
| 191 | + .toString("base64"); | |
| 192 | + return { | |
| 193 | + environmentId: record.environmentId, | |
| 194 | + label: record.label, | |
| 195 | + httpBaseUrl: record.httpBaseUrl, | |
| 196 | + wsBaseUrl: record.wsBaseUrl, | |
| 197 | + createdAt: record.createdAt, | |
| 198 | + lastConnectedAt: record.lastConnectedAt, | |
| 199 | + encryptedBearerToken, | |
| 200 | + } satisfies PersistedSavedEnvironmentStorageRecord; | |
| 201 | + }), | |
| 202 | + } satisfies SavedEnvironmentRegistryDocument); | |
| 203 | + return found; | |
| 204 | +} | |
| 205 | + | |
| 206 | +export function removeSavedEnvironmentSecret(input: { | |
| 207 | + readonly registryPath: string; | |
| 208 | + readonly environmentId: string; | |
| 209 | +}): void { | |
| 210 | + const document = readSavedEnvironmentRegistryDocument(input.registryPath); | |
| 211 | + if ( | |
| 212 | + !document.records.some( | |
| 213 | + (record) => | |
| 214 | + record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined, | |
| 215 | + ) | |
| 216 | + ) { | |
| 217 | + return; | |
| 218 | + } | |
| 219 | + | |
| 220 | + writeJsonFile(input.registryPath, { | |
| 221 | + records: document.records.map((record) => { | |
| 222 | + if (record.environmentId !== input.environmentId) { | |
| 223 | + return record; | |
| 224 | + } | |
| 225 | + | |
| 226 | + return toPersistedSavedEnvironmentRecord(record); | |
| 227 | + }), | |
| 228 | + } satisfies SavedEnvironmentRegistryDocument); | |
| 229 | +} | |
| @@ -11,7 +11,7 @@ vi.mock("electron", () => ({ · 10 unmodified lines | ||
| 11 | 11 | }, |
| 12 | 12 | })); |
| 13 | 13 | |
| 14 | −import { showDesktopConfirmDialog } from "./confirmDialog"; | |
| 14 | +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; | |
| 15 | 15 | |
| 16 | 16 | describe("showDesktopConfirmDialog", () => { |
| 17 | 17 | beforeEach(() => { |
| @@ -0,0 +1,147 @@ | ||
| 1 | +import * as fs from "node:fs"; | |
| 2 | +import * as os from "node:os"; | |
| 3 | +import * as path from "node:path"; | |
| 4 | + | |
| 5 | +import { afterEach, describe, expect, it } from "vitest"; | |
| 6 | + | |
| 7 | +import { | |
| 8 | + DEFAULT_DESKTOP_SETTINGS, | |
| 9 | + readDesktopSettings, | |
| 10 | + resolveDefaultDesktopSettings, | |
| 11 | + setDesktopServerExposurePreference, | |
| 12 | + setDesktopUpdateChannelPreference, | |
| 13 | + writeDesktopSettings, | |
| 14 | +} from "./desktopSettings.ts"; | |
| 15 | + | |
| 16 | +const tempDirectories: string[] = []; | |
| 17 | + | |
| 18 | +afterEach(() => { | |
| 19 | + for (const directory of tempDirectories.splice(0)) { | |
| 20 | + fs.rmSync(directory, { recursive: true, force: true }); | |
| 21 | + } | |
| 22 | +}); | |
| 23 | + | |
| 24 | +function makeSettingsPath() { | |
| 25 | + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); | |
| 26 | + tempDirectories.push(directory); | |
| 27 | + return path.join(directory, "desktop-settings.json"); | |
| 28 | +} | |
| 29 | + | |
| 30 | +describe("desktopSettings", () => { | |
| 31 | + it("returns defaults when no settings file exists", () => { | |
| 32 | + expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("defaults packaged nightly builds to the nightly update channel", () => { | |
| 36 | + expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({ | |
| 37 | + serverExposureMode: "local-only", | |
| 38 | + updateChannel: "nightly", | |
| 39 | + updateChannelConfiguredByUser: false, | |
| 40 | + }); | |
| 41 | + }); | |
| 42 | + | |
| 43 | + it("persists and reloads the configured server exposure mode", () => { | |
| 44 | + const settingsPath = makeSettingsPath(); | |
| 45 | + | |
| 46 | + writeDesktopSettings(settingsPath, { | |
| 47 | + serverExposureMode: "network-accessible", | |
| 48 | + updateChannel: "latest", | |
| 49 | + updateChannelConfiguredByUser: true, | |
| 50 | + }); | |
| 51 | + | |
| 52 | + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ | |
| 53 | + serverExposureMode: "network-accessible", | |
| 54 | + updateChannel: "latest", | |
| 55 | + updateChannelConfiguredByUser: true, | |
| 56 | + }); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("preserves the requested network-accessible preference across temporary fallback", () => { | |
| 60 | + expect( | |
| 61 | + setDesktopServerExposurePreference( | |
| 62 | + { | |
| 63 | + serverExposureMode: "local-only", | |
| 64 | + updateChannel: "latest", | |
| 65 | + updateChannelConfiguredByUser: false, | |
| 66 | + }, | |
| 67 | + "network-accessible", | |
| 68 | + ), | |
| 69 | + ).toEqual({ | |
| 70 | + serverExposureMode: "network-accessible", | |
| 71 | + updateChannel: "latest", | |
| 72 | + updateChannelConfiguredByUser: false, | |
| 73 | + }); | |
| 74 | + }); | |
| 75 | + | |
| 76 | + it("persists the requested nightly update channel", () => { | |
| 77 | + expect( | |
| 78 | + setDesktopUpdateChannelPreference( | |
| 79 | + { | |
| 80 | + serverExposureMode: "local-only", | |
| 81 | + updateChannel: "latest", | |
| 82 | + updateChannelConfiguredByUser: false, | |
| 83 | + }, | |
| 84 | + "nightly", | |
| 85 | + ), | |
| 86 | + ).toEqual({ | |
| 87 | + serverExposureMode: "local-only", | |
| 88 | + updateChannel: "nightly", | |
| 89 | + updateChannelConfiguredByUser: true, | |
| 90 | + }); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("falls back to defaults when the settings file is malformed", () => { | |
| 94 | + const settingsPath = makeSettingsPath(); | |
| 95 | + fs.writeFileSync(settingsPath, "{not-json", "utf8"); | |
| 96 | + | |
| 97 | + expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it("falls back to the nightly channel for legacy nightly settings without an update track", () => { | |
| 101 | + const settingsPath = makeSettingsPath(); | |
| 102 | + fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8"); | |
| 103 | + | |
| 104 | + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | |
| 105 | + serverExposureMode: "local-only", | |
| 106 | + updateChannel: "nightly", | |
| 107 | + updateChannelConfiguredByUser: false, | |
| 108 | + }); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it("migrates legacy implicit stable settings to nightly when running a nightly build", () => { | |
| 112 | + const settingsPath = makeSettingsPath(); | |
| 113 | + fs.writeFileSync( | |
| 114 | + settingsPath, | |
| 115 | + JSON.stringify({ | |
| 116 | + serverExposureMode: "local-only", | |
| 117 | + updateChannel: "latest", | |
| 118 | + }), | |
| 119 | + "utf8", | |
| 120 | + ); | |
| 121 | + | |
| 122 | + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | |
| 123 | + serverExposureMode: "local-only", | |
| 124 | + updateChannel: "nightly", | |
| 125 | + updateChannelConfiguredByUser: false, | |
| 126 | + }); | |
| 127 | + }); | |
| 128 | + | |
| 129 | + it("preserves an explicit stable choice on nightly builds", () => { | |
| 130 | + const settingsPath = makeSettingsPath(); | |
| 131 | + fs.writeFileSync( | |
| 132 | + settingsPath, | |
| 133 | + JSON.stringify({ | |
| 134 | + serverExposureMode: "local-only", | |
| 135 | + updateChannel: "latest", | |
| 136 | + updateChannelConfiguredByUser: true, | |
| 137 | + }), | |
| 138 | + "utf8", | |
| 139 | + ); | |
| 140 | + | |
| 141 | + expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({ | |
| 142 | + serverExposureMode: "local-only", | |
| 143 | + updateChannel: "latest", | |
| 144 | + updateChannelConfiguredByUser: true, | |
| 145 | + }); | |
| 146 | + }); | |
| 147 | +}); | |
| @@ -0,0 +1,92 @@ | ||
| 1 | +import * as FS from "node:fs"; | |
| 2 | +import * as Path from "node:path"; | |
| 3 | +import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; | |
| 4 | + | |
| 5 | +import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; | |
| 6 | + | |
| 7 | +export interface DesktopSettings { | |
| 8 | + readonly serverExposureMode: DesktopServerExposureMode; | |
| 9 | + readonly updateChannel: DesktopUpdateChannel; | |
| 10 | + readonly updateChannelConfiguredByUser: boolean; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | |
| 14 | + serverExposureMode: "local-only", | |
| 15 | + updateChannel: "latest", | |
| 16 | + updateChannelConfiguredByUser: false, | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { | |
| 20 | + return { | |
| 21 | + ...DEFAULT_DESKTOP_SETTINGS, | |
| 22 | + updateChannel: resolveDefaultDesktopUpdateChannel(appVersion), | |
| 23 | + }; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function setDesktopServerExposurePreference( | |
| 27 | + settings: DesktopSettings, | |
| 28 | + requestedMode: DesktopServerExposureMode, | |
| 29 | +): DesktopSettings { | |
| 30 | + return settings.serverExposureMode === requestedMode | |
| 31 | + ? settings | |
| 32 | + : { | |
| 33 | + ...settings, | |
| 34 | + serverExposureMode: requestedMode, | |
| 35 | + }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function setDesktopUpdateChannelPreference( | |
| 39 | + settings: DesktopSettings, | |
| 40 | + requestedChannel: DesktopUpdateChannel, | |
| 41 | +): DesktopSettings { | |
| 42 | + return { | |
| 43 | + ...settings, | |
| 44 | + updateChannel: requestedChannel, | |
| 45 | + updateChannelConfiguredByUser: true, | |
| 46 | + }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { | |
| 50 | + const defaultSettings = resolveDefaultDesktopSettings(appVersion); | |
| 51 | + | |
| 52 | + try { | |
| 53 | + if (!FS.existsSync(settingsPath)) { | |
| 54 | + return defaultSettings; | |
| 55 | + } | |
| 56 | + | |
| 57 | + const raw = FS.readFileSync(settingsPath, "utf8"); | |
| 58 | + const parsed = JSON.parse(raw) as { | |
| 59 | + readonly serverExposureMode?: unknown; | |
| 60 | + readonly updateChannel?: unknown; | |
| 61 | + readonly updateChannelConfiguredByUser?: unknown; | |
| 62 | + }; | |
| 63 | + const parsedUpdateChannel = | |
| 64 | + parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" | |
| 65 | + ? parsed.updateChannel | |
| 66 | + : null; | |
| 67 | + const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined; | |
| 68 | + const updateChannelConfiguredByUser = | |
| 69 | + parsed.updateChannelConfiguredByUser === true || | |
| 70 | + (isLegacySettings && parsedUpdateChannel === "nightly"); | |
| 71 | + | |
| 72 | + return { | |
| 73 | + serverExposureMode: | |
| 74 | + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", | |
| 75 | + updateChannel: | |
| 76 | + updateChannelConfiguredByUser && parsedUpdateChannel !== null | |
| 77 | + ? parsedUpdateChannel | |
| 78 | + : defaultSettings.updateChannel, | |
| 79 | + updateChannelConfiguredByUser, | |
| 80 | + }; | |
| 81 | + } catch { | |
| 82 | + return defaultSettings; | |
| 83 | + } | |
| 84 | +} | |
| 85 | + | |
| 86 | +export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { | |
| 87 | + const directory = Path.dirname(settingsPath); | |
| 88 | + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; | |
| 89 | + FS.mkdirSync(directory, { recursive: true }); | |
| 90 | + FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); | |
| 91 | + FS.renameSync(tempPath, settingsPath); | |
| 92 | +} | |
| @@ -7,18 +7,26 @@ import * as Path from "node:path"; · 6 unmodified lines | ||
| 7 | 7 | import { |
| 8 | 8 | app, |
| 9 | 9 | BrowserWindow, |
| 10 | + type BrowserWindowConstructorOptions, | |
| 11 | + clipboard, | |
| 10 | 12 | dialog, |
| 11 | 13 | ipcMain, |
| 12 | 14 | Menu, |
| 13 | 15 | nativeImage, |
| 14 | 16 | nativeTheme, |
| 15 | 17 | protocol, |
| 18 | + safeStorage, | |
| 16 | 19 | shell, |
| 17 | 20 | } from "electron"; |
| 18 | −import type { MenuItemConstructorOptions } from "electron"; | |
| 19 | −import * as Effect from "effect/Effect"; | |
| 21 | +import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; | |
| 20 | 22 | import type { |
| 23 | + ClientSettings, | |
| 21 | 24 | DesktopTheme, |
| 25 | + DesktopAppBranding, | |
| 26 | + DesktopServerExposureMode, | |
| 27 | + DesktopServerExposureState, | |
| 28 | + DesktopUpdateChannel, | |
| 29 | + PersistedSavedEnvironmentRecord, | |
| 22 | 30 | DesktopUpdateActionResult, |
| 23 | 31 | DesktopUpdateCheckResult, |
| 24 | 32 | DesktopUpdateState, |
| @@ -26,12 +34,32 @@ import type { · 1 unmodified line | ||
| 26 | 34 | import { autoUpdater } from "electron-updater"; |
| 27 | 35 | |
| 28 | 36 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 29 | −import { NetService } from "@t3tools/shared/Net"; | |
| 30 | 37 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 31 | 38 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 32 | −import { showDesktopConfirmDialog } from "./confirmDialog"; | |
| 33 | −import { syncShellEnvironment } from "./syncShellEnvironment"; | |
| 34 | −import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; | |
| 39 | +import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts"; | |
| 40 | +import { | |
| 41 | + DEFAULT_DESKTOP_SETTINGS, | |
| 42 | + readDesktopSettings, | |
| 43 | + setDesktopServerExposurePreference, | |
| 44 | + setDesktopUpdateChannelPreference, | |
| 45 | + writeDesktopSettings, | |
| 46 | +} from "./desktopSettings.ts"; | |
| 47 | +import { | |
| 48 | + readClientSettings, | |
| 49 | + readSavedEnvironmentRegistry, | |
| 50 | + readSavedEnvironmentSecret, | |
| 51 | + removeSavedEnvironmentSecret, | |
| 52 | + writeClientSettings, | |
| 53 | + writeSavedEnvironmentRegistry, | |
| 54 | + writeSavedEnvironmentSecret, | |
| 55 | +} from "./clientPersistence.ts"; | |
| 56 | +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; | |
| 57 | +import { showDesktopConfirmDialog } from "./confirmDialog.ts"; | |
| 58 | +import { resolveDesktopServerExposure } from "./serverExposure.ts"; | |
| 59 | +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; | |
| 60 | +import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; | |
| 61 | +import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; | |
| 62 | +import { ServerListeningDetector } from "./serverListeningDetector.ts"; | |
| 35 | 63 | import { |
| 36 | 64 | createInitialDesktopUpdateState, |
| 37 | 65 | reduceDesktopUpdateStateOnCheckFailure, |
| @@ -43,8 +71,9 @@ import { · 5 unmodified lines | ||
| 43 | 71 | reduceDesktopUpdateStateOnInstallFailure, |
| 44 | 72 | reduceDesktopUpdateStateOnNoUpdate, |
| 45 | 73 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 46 | −} from "./updateMachine"; | |
| 47 | −import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; | |
| 74 | +} from "./updateMachine.ts"; | |
| 75 | +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; | |
| 76 | +import { resolveDesktopAppBranding } from "./appBranding.ts"; | |
| 48 | 77 | |
| 49 | 78 | syncShellEnvironment(); |
| 50 | 79 | |
| @@ -56,17 +85,35 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; · 5 unmodified lines | ||
| 56 | 85 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 57 | 86 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 58 | 87 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 88 | +const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 59 | 89 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 60 | 90 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 61 | 91 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 62 | −const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | |
| 92 | +const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 93 | +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 94 | +const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 95 | +const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 96 | +const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 97 | +const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 98 | +const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 99 | +const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 100 | +const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 101 | +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 102 | +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 63 | 103 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 64 | 104 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 105 | +const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); | |
| 106 | +const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json"); | |
| 107 | +const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json"); | |
| 65 | 108 | const DESKTOP_SCHEME = "t3"; |
| 66 | 109 | const ROOT_DIR = Path.resolve(__dirname, "../../.."); |
| 67 | 110 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 68 | −const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 69 | −const APP_USER_MODEL_ID = "com.t3tools.t3code"; | |
| 111 | +const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({ | |
| 112 | + isDevelopment, | |
| 113 | + appVersion: app.getVersion(), | |
| 114 | +}); | |
| 115 | +const APP_DISPLAY_NAME = desktopAppBranding.displayName; | |
| 116 | +const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code"; | |
| 70 | 117 | const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop"; |
| 71 | 118 | const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code"; |
| 72 | 119 | const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code"; |
| @@ -80,8 +127,72 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); · 7 unmodified lines | ||
| 80 | 127 | const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json"); |
| 81 | 128 | const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; |
| 82 | 129 | const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 83 | −const DESKTOP_UPDATE_CHANNEL = "latest"; | |
| 84 | −const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; | |
| 130 | + | |
| 131 | +function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { | |
| 132 | + if (typeof rawOptions !== "object" || rawOptions === null) { | |
| 133 | + return undefined; | |
| 134 | + } | |
| 135 | + | |
| 136 | + const { initialPath } = rawOptions as { initialPath?: unknown }; | |
| 137 | + if (typeof initialPath !== "string") { | |
| 138 | + return undefined; | |
| 139 | + } | |
| 140 | + | |
| 141 | + const trimmedPath = initialPath.trim(); | |
| 142 | + if (trimmedPath.length === 0) { | |
| 143 | + return undefined; | |
| 144 | + } | |
| 145 | + | |
| 146 | + if (trimmedPath === "~") { | |
| 147 | + return OS.homedir(); | |
| 148 | + } | |
| 149 | + | |
| 150 | + if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) { | |
| 151 | + return Path.join(OS.homedir(), trimmedPath.slice(2)); | |
| 152 | + } | |
| 153 | + | |
| 154 | + return Path.resolve(trimmedPath); | |
| 155 | +} | |
| 156 | +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 157 | +const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; | |
| 158 | +const TITLEBAR_HEIGHT = 40; | |
| 159 | +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | |
| 160 | +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | |
| 161 | +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | |
| 162 | + | |
| 163 | +function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { | |
| 164 | + const normalizedItems: ContextMenuItem[] = []; | |
| 165 | + | |
| 166 | + for (const sourceItem of source) { | |
| 167 | + if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { | |
| 168 | + continue; | |
| 169 | + } | |
| 170 | + | |
| 171 | + const normalizedItem: ContextMenuItem = { | |
| 172 | + id: sourceItem.id, | |
| 173 | + label: sourceItem.label, | |
| 174 | + destructive: sourceItem.destructive === true, | |
| 175 | + disabled: sourceItem.disabled === true, | |
| 176 | + }; | |
| 177 | + | |
| 178 | + if (sourceItem.children) { | |
| 179 | + const normalizedChildren = normalizeContextMenuItems(sourceItem.children); | |
| 180 | + if (normalizedChildren.length === 0) { | |
| 181 | + continue; | |
| 182 | + } | |
| 183 | + normalizedItem.children = normalizedChildren; | |
| 184 | + } | |
| 185 | + | |
| 186 | + normalizedItems.push(normalizedItem); | |
| 187 | + } | |
| 188 | + | |
| 189 | + return normalizedItems; | |
| 190 | +} | |
| 191 | + | |
| 192 | +type WindowTitleBarOptions = Pick< | |
| 193 | + BrowserWindowConstructorOptions, | |
| 194 | + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" | |
| 195 | +>; | |
| 85 | 196 | |
| 86 | 197 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; |
| 87 | 198 | type LinuxDesktopNamedApp = Electron.App & { |
| @@ -91,8 +202,15 @@ type LinuxDesktopNamedApp = Electron.App & { · 3 unmodified lines | ||
| 91 | 202 | let mainWindow: BrowserWindow | null = null; |
| 92 | 203 | let backendProcess: ChildProcess.ChildProcess | null = null; |
| 93 | 204 | let backendPort = 0; |
| 94 | −let backendAuthToken = ""; | |
| 205 | +let backendBindHost = DESKTOP_LOOPBACK_HOST; | |
| 206 | +let backendBootstrapToken = ""; | |
| 207 | +let backendHttpUrl = ""; | |
| 95 | 208 | let backendWsUrl = ""; |
| 209 | +let backendEndpointUrl: string | null = null; | |
| 210 | +let backendAdvertisedHost: string | null = null; | |
| 211 | +let backendReadinessAbortController: AbortController | null = null; | |
| 212 | +let backendInitialWindowOpenInFlight: Promise<void> | null = null; | |
| 213 | +let backendListeningDetector: ServerListeningDetector | null = null; | |
| 96 | 214 | let restartAttempt = 0; |
| 97 | 215 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 98 | 216 | let isQuitting = false; |
| @@ -102,6 +220,8 @@ let desktopLogSink: RotatingFileSink | null = null; · 3 unmodified lines | ||
| 102 | 220 | let backendLogSink: RotatingFileSink | null = null; |
| 103 | 221 | let restoreStdIoCapture: (() => void) | null = null; |
| 104 | 222 | let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); |
| 223 | +let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); | |
| 224 | +let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; | |
| 105 | 225 | |
| 106 | 226 | let destructiveMenuIconCache: Electron.NativeImage | null | undefined; |
| 107 | 227 | const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>(); |
| @@ -111,7 +231,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ · 3 unmodified lines | ||
| 111 | 231 | runningUnderArm64Translation: app.runningUnderARM64Translation === true, |
| 112 | 232 | }); |
| 113 | 233 | const initialUpdateState = (): DesktopUpdateState => |
| 114 | − createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo); | |
| 234 | + createInitialDesktopUpdateState( | |
| 235 | + app.getVersion(), | |
| 236 | + desktopRuntimeInfo, | |
| 237 | + desktopSettings.updateChannel, | |
| 238 | + ); | |
| 115 | 239 | |
| 116 | 240 | function logTimestamp(): string { |
| 117 | 241 | return new Date().toISOString(); |
| @@ -140,17 +264,128 @@ function readPersistedBackendObservabilitySettings(): { · 22 unmodified lines | ||
| 140 | 264 | } |
| 141 | 265 | } |
| 142 | 266 | |
| 267 | +function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { | |
| 268 | + if (!rawPort) { | |
| 269 | + return undefined; | |
| 270 | + } | |
| 271 | + | |
| 272 | + const parsedPort = Number.parseInt(rawPort, 10); | |
| 273 | + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { | |
| 274 | + return undefined; | |
| 275 | + } | |
| 276 | + | |
| 277 | + return parsedPort; | |
| 278 | +} | |
| 279 | + | |
| 280 | +function resolveDesktopDevServerUrl(): string { | |
| 281 | + const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); | |
| 282 | + if (!devServerUrl) { | |
| 283 | + throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); | |
| 284 | + } | |
| 285 | + | |
| 286 | + return devServerUrl; | |
| 287 | +} | |
| 288 | + | |
| 143 | 289 | function backendChildEnv(): NodeJS.ProcessEnv { |
| 144 | 290 | const env = { ...process.env }; |
| 145 | 291 | delete env.T3CODE_PORT; |
| 146 | − delete env.T3CODE_AUTH_TOKEN; | |
| 147 | 292 | delete env.T3CODE_MODE; |
| 148 | 293 | delete env.T3CODE_NO_BROWSER; |
| 149 | 294 | delete env.T3CODE_HOST; |
| 150 | 295 | delete env.T3CODE_DESKTOP_WS_URL; |
| 296 | + delete env.T3CODE_DESKTOP_LAN_ACCESS; | |
| 297 | + delete env.T3CODE_DESKTOP_LAN_HOST; | |
| 151 | 298 | return env; |
| 152 | 299 | } |
| 153 | 300 | |
| 301 | +function getDesktopServerExposureState(): DesktopServerExposureState { | |
| 302 | + return { | |
| 303 | + mode: desktopServerExposureMode, | |
| 304 | + endpointUrl: backendEndpointUrl, | |
| 305 | + advertisedHost: backendAdvertisedHost, | |
| 306 | + }; | |
| 307 | +} | |
| 308 | + | |
| 309 | +function getDesktopSecretStorage() { | |
| 310 | + return { | |
| 311 | + isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), | |
| 312 | + encryptString: (value: string) => safeStorage.encryptString(value), | |
| 313 | + decryptString: (value: Buffer) => safeStorage.decryptString(value), | |
| 314 | + } as const; | |
| 315 | +} | |
| 316 | + | |
| 317 | +function resolveAdvertisedHostOverride(): string | undefined { | |
| 318 | + const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); | |
| 319 | + return override && override.length > 0 ? override : undefined; | |
| 320 | +} | |
| 321 | + | |
| 322 | +async function applyDesktopServerExposureMode( | |
| 323 | + mode: DesktopServerExposureMode, | |
| 324 | + options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, | |
| 325 | +): Promise<DesktopServerExposureState> { | |
| 326 | + const advertisedHostOverride = resolveAdvertisedHostOverride(); | |
| 327 | + const requestedMode = mode; | |
| 328 | + let exposure = resolveDesktopServerExposure({ | |
| 329 | + mode, | |
| 330 | + port: backendPort, | |
| 331 | + networkInterfaces: OS.networkInterfaces(), | |
| 332 | + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 333 | + }); | |
| 334 | + | |
| 335 | + if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { | |
| 336 | + if (options?.rejectIfUnavailable) { | |
| 337 | + throw new Error("No reachable network address is available for this desktop right now."); | |
| 338 | + } | |
| 339 | + exposure = resolveDesktopServerExposure({ | |
| 340 | + mode: "local-only", | |
| 341 | + port: backendPort, | |
| 342 | + networkInterfaces: OS.networkInterfaces(), | |
| 343 | + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), | |
| 344 | + }); | |
| 345 | + } | |
| 346 | + | |
| 347 | + desktopServerExposureMode = exposure.mode; | |
| 348 | + desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); | |
| 349 | + backendBindHost = exposure.bindHost; | |
| 350 | + backendHttpUrl = exposure.localHttpUrl; | |
| 351 | + backendWsUrl = exposure.localWsUrl; | |
| 352 | + backendEndpointUrl = exposure.endpointUrl; | |
| 353 | + backendAdvertisedHost = exposure.advertisedHost; | |
| 354 | + | |
| 355 | + if (options?.persist) { | |
| 356 | + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 357 | + } | |
| 358 | + | |
| 359 | + return getDesktopServerExposureState(); | |
| 360 | +} | |
| 361 | + | |
| 362 | +function relaunchDesktopApp(reason: string): void { | |
| 363 | + writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); | |
| 364 | + setImmediate(() => { | |
| 365 | + isQuitting = true; | |
| 366 | + clearUpdatePollTimer(); | |
| 367 | + cancelBackendReadinessWait(); | |
| 368 | + void stopBackendAndWaitForExit() | |
| 369 | + .catch((error) => { | |
| 370 | + writeDesktopLogHeader( | |
| 371 | + `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, | |
| 372 | + ); | |
| 373 | + }) | |
| 374 | + .finally(() => { | |
| 375 | + restoreStdIoCapture?.(); | |
| 376 | + if (isDevelopment) { | |
| 377 | + app.exit(75); | |
| 378 | + return; | |
| 379 | + } | |
| 380 | + app.relaunch({ | |
| 381 | + execPath: process.execPath, | |
| 382 | + args: process.argv.slice(1), | |
| 383 | + }); | |
| 384 | + app.exit(0); | |
| 385 | + }); | |
| 386 | + }); | |
| 387 | +} | |
| 388 | + | |
| 154 | 389 | function writeDesktopLogHeader(message: string): void { |
| 155 | 390 | if (!desktopLogSink) return; |
| 156 | 391 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); |
| @@ -198,6 +433,113 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { · 41 unmodified lines | ||
| 198 | 433 | return null; |
| 199 | 434 | } |
| 200 | 435 | |
| 436 | +async function waitForBackendHttpReady( | |
| 437 | + baseUrl: string, | |
| 438 | + options?: Parameters<typeof waitForHttpReady>[1], | |
| 439 | +): Promise<void> { | |
| 440 | + cancelBackendReadinessWait(); | |
| 441 | + const controller = new AbortController(); | |
| 442 | + backendReadinessAbortController = controller; | |
| 443 | + | |
| 444 | + try { | |
| 445 | + await waitForHttpReady(baseUrl, { | |
| 446 | + ...options, | |
| 447 | + signal: controller.signal, | |
| 448 | + }); | |
| 449 | + } finally { | |
| 450 | + if (backendReadinessAbortController === controller) { | |
| 451 | + backendReadinessAbortController = null; | |
| 452 | + } | |
| 453 | + } | |
| 454 | +} | |
| 455 | + | |
| 456 | +function cancelBackendReadinessWait(): void { | |
| 457 | + backendReadinessAbortController?.abort(); | |
| 458 | + backendReadinessAbortController = null; | |
| 459 | +} | |
| 460 | + | |
| 461 | +async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { | |
| 462 | + const httpReadyPromise = waitForBackendHttpReady(baseUrl, { | |
| 463 | + timeoutMs: 60_000, | |
| 464 | + }); | |
| 465 | + const listeningPromise = backendListeningDetector?.promise; | |
| 466 | + | |
| 467 | + if (!listeningPromise) { | |
| 468 | + await httpReadyPromise; | |
| 469 | + return "http"; | |
| 470 | + } | |
| 471 | + | |
| 472 | + return await new Promise<"listening" | "http">((resolve, reject) => { | |
| 473 | + let settled = false; | |
| 474 | + | |
| 475 | + const settleResolve = (source: "listening" | "http") => { | |
| 476 | + if (settled) { | |
| 477 | + return; | |
| 478 | + } | |
| 479 | + settled = true; | |
| 480 | + if (source === "listening") { | |
| 481 | + cancelBackendReadinessWait(); | |
| 482 | + } | |
| 483 | + resolve(source); | |
| 484 | + }; | |
| 485 | + | |
| 486 | + const settleReject = (error: unknown) => { | |
| 487 | + if (settled) { | |
| 488 | + return; | |
| 489 | + } | |
| 490 | + settled = true; | |
| 491 | + reject(error); | |
| 492 | + }; | |
| 493 | + | |
| 494 | + listeningPromise.then( | |
| 495 | + () => settleResolve("listening"), | |
| 496 | + (error) => settleReject(error), | |
| 497 | + ); | |
| 498 | + httpReadyPromise.then( | |
| 499 | + () => settleResolve("http"), | |
| 500 | + (error) => { | |
| 501 | + if (settled && isBackendReadinessAborted(error)) { | |
| 502 | + return; | |
| 503 | + } | |
| 504 | + settleReject(error); | |
| 505 | + }, | |
| 506 | + ); | |
| 507 | + }); | |
| 508 | +} | |
| 509 | + | |
| 510 | +function ensureInitialBackendWindowOpen(): void { | |
| 511 | + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null; | |
| 512 | + if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) { | |
| 513 | + return; | |
| 514 | + } | |
| 515 | + | |
| 516 | + const nextOpen = waitForBackendWindowReady(backendHttpUrl) | |
| 517 | + .then((source) => { | |
| 518 | + writeDesktopLogHeader(`bootstrap backend ready source=${source}`); | |
| 519 | + if (mainWindow ?? BrowserWindow.getAllWindows()[0]) { | |
| 520 | + return; | |
| 521 | + } | |
| 522 | + mainWindow = createWindow(); | |
| 523 | + writeDesktopLogHeader("bootstrap main window created"); | |
| 524 | + }) | |
| 525 | + .catch((error) => { | |
| 526 | + if (isBackendReadinessAborted(error)) { | |
| 527 | + return; | |
| 528 | + } | |
| 529 | + writeDesktopLogHeader( | |
| 530 | + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, | |
| 531 | + ); | |
| 532 | + console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error); | |
| 533 | + }) | |
| 534 | + .finally(() => { | |
| 535 | + if (backendInitialWindowOpenInFlight === nextOpen) { | |
| 536 | + backendInitialWindowOpenInFlight = null; | |
| 537 | + } | |
| 538 | + }); | |
| 539 | + | |
| 540 | + backendInitialWindowOpenInFlight = nextOpen; | |
| 541 | +} | |
| 542 | + | |
| 201 | 543 | function writeDesktopStreamChunk( |
| 202 | 544 | streamName: "stdout" | "stderr", |
| 203 | 545 | chunk: unknown, |
| @@ -275,14 +617,16 @@ function initializePackagedLogging(): void { · 71 unmodified lines | ||
| 275 | 617 | } |
| 276 | 618 | |
| 277 | 619 | function captureBackendOutput(child: ChildProcess.ChildProcess): void { |
| 278 | − if (!app.isPackaged || backendLogSink === null) return; | |
| 279 | − const writeChunk = (chunk: unknown): void => { | |
| 280 | − if (!backendLogSink) return; | |
| 281 | − const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); | |
| 282 | − backendLogSink.write(buffer); | |
| 620 | + const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => { | |
| 621 | + stream?.on("data", (chunk: unknown) => { | |
| 622 | + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8"); | |
| 623 | + backendLogSink?.write(buffer); | |
| 624 | + backendListeningDetector?.push(buffer); | |
| 625 | + }); | |
| 283 | 626 | }; |
| 284 | − child.stdout?.on("data", writeChunk); | |
| 285 | − child.stderr?.on("data", writeChunk); | |
| 627 | + | |
| 628 | + attachStream(child.stdout); | |
| 629 | + attachStream(child.stderr); | |
| 286 | 630 | } |
| 287 | 631 | |
| 288 | 632 | initializePackagedLogging(); |
| @@ -542,10 +886,7 @@ function dispatchMenuAction(action: string): void { · 253 unmodified lines | ||
| 542 | 886 | const send = () => { |
| 543 | 887 | if (targetWindow.isDestroyed()) return; |
| 544 | 888 | targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); |
| 545 | − if (!targetWindow.isVisible()) { | |
| 546 | − targetWindow.show(); | |
| 547 | − } | |
| 548 | − targetWindow.focus(); | |
| 889 | + revealWindow(targetWindow); | |
| 549 | 890 | }; |
| 550 | 891 | |
| 551 | 892 | if (targetWindow.webContents.isLoadingMainFrame()) { |
| @@ -557,12 +898,15 @@ function dispatchMenuAction(action: string): void { · 5 unmodified lines | ||
| 557 | 898 | } |
| 558 | 899 | |
| 559 | 900 | function handleCheckForUpdatesMenuClick(): void { |
| 901 | + const hasUpdateFeedConfig = | |
| 902 | + readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); | |
| 560 | 903 | const disabledReason = getAutoUpdateDisabledReason({ |
| 561 | 904 | isDevelopment, |
| 562 | 905 | isPackaged: app.isPackaged, |
| 563 | 906 | platform: process.platform, |
| 564 | 907 | appImage: process.env.APPIMAGE, |
| 565 | 908 | disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", |
| 909 | + hasUpdateFeedConfig, | |
| 566 | 910 | }); |
| 567 | 911 | if (disabledReason) { |
| 568 | 912 | console.info("[desktop-updater] Manual update check requested, but updates are disabled."); |
| @@ -699,6 +1043,18 @@ function resolveResourcePath(fileName: string): string | null { · 130 unmodified lines | ||
| 699 | 1043 | } |
| 700 | 1044 | |
| 701 | 1045 | function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { |
| 1046 | + if (isDevelopment && process.platform === "darwin" && ext === "png") { | |
| 1047 | + const developmentDockIconPath = Path.join( | |
| 1048 | + ROOT_DIR, | |
| 1049 | + "assets", | |
| 1050 | + "dev", | |
| 1051 | + "blueprint-macos-1024.png", | |
| 1052 | + ); | |
| 1053 | + if (FS.existsSync(developmentDockIconPath)) { | |
| 1054 | + return developmentDockIconPath; | |
| 1055 | + } | |
| 1056 | + } | |
| 1057 | + | |
| 702 | 1058 | return resolveResourcePath(`icon.${ext}`); |
| 703 | 1059 | } |
| 704 | 1060 | |
| @@ -766,6 +1122,26 @@ function clearUpdatePollTimer(): void { · 61 unmodified lines | ||
| 766 | 1122 | } |
| 767 | 1123 | } |
| 768 | 1124 | |
| 1125 | +function revealWindow(window: BrowserWindow): void { | |
| 1126 | + if (window.isDestroyed()) { | |
| 1127 | + return; | |
| 1128 | + } | |
| 1129 | + | |
| 1130 | + if (window.isMinimized()) { | |
| 1131 | + window.restore(); | |
| 1132 | + } | |
| 1133 | + | |
| 1134 | + if (!window.isVisible()) { | |
| 1135 | + window.show(); | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + if (process.platform === "darwin") { | |
| 1139 | + app.focus({ steal: true }); | |
| 1140 | + } | |
| 1141 | + | |
| 1142 | + window.focus(); | |
| 1143 | +} | |
| 1144 | + | |
| 769 | 1145 | function emitUpdateState(): void { |
| 770 | 1146 | for (const window of BrowserWindow.getAllWindows()) { |
| 771 | 1147 | if (window.isDestroyed()) continue; |
| @@ -778,7 +1154,29 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void { · 6 unmodified lines | ||
| 778 | 1154 | emitUpdateState(); |
| 779 | 1155 | } |
| 780 | 1156 | |
| 1157 | +function createBaseUpdateState( | |
| 1158 | + channel: DesktopUpdateChannel, | |
| 1159 | + enabled: boolean, | |
| 1160 | +): DesktopUpdateState { | |
| 1161 | + return { | |
| 1162 | + ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel), | |
| 1163 | + enabled, | |
| 1164 | + status: enabled ? "idle" : "disabled", | |
| 1165 | + }; | |
| 1166 | +} | |
| 1167 | + | |
| 1168 | +function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void { | |
| 1169 | + autoUpdater.channel = channel; | |
| 1170 | + autoUpdater.allowPrerelease = channel === "nightly"; | |
| 1171 | + autoUpdater.allowDowngrade = channel === "nightly"; | |
| 1172 | + console.info( | |
| 1173 | + `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`, | |
| 1174 | + ); | |
| 1175 | +} | |
| 1176 | + | |
| 781 | 1177 | function shouldEnableAutoUpdates(): boolean { |
| 1178 | + const hasUpdateFeedConfig = | |
| 1179 | + readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES); | |
| 782 | 1180 | return ( |
| 783 | 1181 | getAutoUpdateDisabledReason({ |
| 784 | 1182 | isDevelopment, |
| @@ -786,6 +1184,7 @@ function shouldEnableAutoUpdates(): boolean { · 1 unmodified line | ||
| 786 | 1184 | platform: process.platform, |
| 787 | 1185 | appImage: process.env.APPIMAGE, |
| 788 | 1186 | disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", |
| 1187 | + hasUpdateFeedConfig, | |
| 789 | 1188 | }) === null |
| 790 | 1189 | ); |
| 791 | 1190 | } |
| @@ -869,17 +1268,6 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 77 unmodified lines | ||
| 869 | 1268 | } |
| 870 | 1269 | |
| 871 | 1270 | function configureAutoUpdater(): void { |
| 872 | − const enabled = shouldEnableAutoUpdates(); | |
| 873 | − setUpdateState({ | |
| 874 | − ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo), | |
| 875 | − enabled, | |
| 876 | − status: enabled ? "idle" : "disabled", | |
| 877 | − }); | |
| 878 | − if (!enabled) { | |
| 879 | − return; | |
| 880 | − } | |
| 881 | − updaterConfigured = true; | |
| 882 | − | |
| 883 | 1271 | const githubToken = |
| 884 | 1272 | process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || ""; |
| 885 | 1273 | if (githubToken) { |
| @@ -904,12 +1292,16 @@ function configureAutoUpdater(): void { · 18 unmodified lines | ||
| 904 | 1292 | }); |
| 905 | 1293 | } |
| 906 | 1294 | |
| 1295 | + const enabled = shouldEnableAutoUpdates(); | |
| 1296 | + setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled)); | |
| 1297 | + if (!enabled) { | |
| 1298 | + return; | |
| 1299 | + } | |
| 1300 | + updaterConfigured = true; | |
| 1301 | + | |
| 907 | 1302 | autoUpdater.autoDownload = false; |
| 908 | 1303 | autoUpdater.autoInstallOnAppQuit = false; |
| 909 | − // Keep alpha branding, but force all installs onto the stable update track. | |
| 910 | − autoUpdater.channel = DESKTOP_UPDATE_CHANNEL; | |
| 911 | − autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE; | |
| 912 | − autoUpdater.allowDowngrade = false; | |
| 1304 | + applyAutoUpdaterChannel(desktopSettings.updateChannel); | |
| 913 | 1305 | autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo); |
| 914 | 1306 | let lastLoggedDownloadMilestone = -1; |
| 915 | 1307 | |
| @@ -923,6 +1315,15 @@ function configureAutoUpdater(): void { · 7 unmodified lines | ||
| 923 | 1315 | console.info("[desktop-updater] Looking for updates..."); |
| 924 | 1316 | }); |
| 925 | 1317 | autoUpdater.on("update-available", (info) => { |
| 1318 | + if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) { | |
| 1319 | + console.info( | |
| 1320 | + `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`, | |
| 1321 | + ); | |
| 1322 | + setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); | |
| 1323 | + lastLoggedDownloadMilestone = -1; | |
| 1324 | + return; | |
| 1325 | + } | |
| 1326 | + | |
| 926 | 1327 | setUpdateState( |
| 927 | 1328 | reduceDesktopUpdateStateOnUpdateAvailable( |
| 928 | 1329 | updateState, |
| @@ -1014,7 +1415,7 @@ function startBackend(): void { · 85 unmodified lines | ||
| 1014 | 1415 | return; |
| 1015 | 1416 | } |
| 1016 | 1417 | |
| 1017 | − const captureBackendLogs = app.isPackaged && backendLogSink !== null; | |
| 1418 | + const captureBackendLogs = !isDevelopment; | |
| 1018 | 1419 | const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { |
| 1019 | 1420 | cwd: resolveBackendCwd(), |
| 1020 | 1421 | // In Electron main, process.execPath points to the Electron binary. |
| @@ -1035,7 +1436,8 @@ function startBackend(): void { · 14 unmodified lines | ||
| 1035 | 1436 | noBrowser: true, |
| 1036 | 1437 | port: backendPort, |
| 1037 | 1438 | t3Home: BASE_DIR, |
| 1038 | − authToken: backendAuthToken, | |
| 1439 | + host: backendBindHost, | |
| 1440 | + desktopBootstrapToken: backendBootstrapToken, | |
| 1039 | 1441 | ...(backendObservabilitySettings.otlpTracesUrl |
| 1040 | 1442 | ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } |
| 1041 | 1443 | : {}), |
| @@ -1050,6 +1452,8 @@ function startBackend(): void { · 8 unmodified lines | ||
| 1050 | 1452 | scheduleBackendRestart("missing desktop bootstrap pipe"); |
| 1051 | 1453 | return; |
| 1052 | 1454 | } |
| 1455 | + const listeningDetector = new ServerListeningDetector(); | |
| 1456 | + backendListeningDetector = listeningDetector; | |
| 1053 | 1457 | backendProcess = child; |
| 1054 | 1458 | let backendSessionClosed = false; |
| 1055 | 1459 | const closeBackendSession = (details: string) => { |
| @@ -1068,6 +1472,10 @@ function startBackend(): void { · 12 unmodified lines | ||
| 1068 | 1472 | }); |
| 1069 | 1473 | |
| 1070 | 1474 | child.on("error", (error) => { |
| 1475 | + if (backendListeningDetector === listeningDetector) { | |
| 1476 | + listeningDetector.fail(error); | |
| 1477 | + backendListeningDetector = null; | |
| 1478 | + } | |
| 1071 | 1479 | const wasExpected = expectedBackendExitChildren.has(child); |
| 1072 | 1480 | if (backendProcess === child) { |
| 1073 | 1481 | backendProcess = null; |
| @@ -1080,6 +1488,14 @@ function startBackend(): void { · 6 unmodified lines | ||
| 1080 | 1488 | }); |
| 1081 | 1489 | |
| 1082 | 1490 | child.on("exit", (code, signal) => { |
| 1491 | + if (backendListeningDetector === listeningDetector) { | |
| 1492 | + listeningDetector.fail( | |
| 1493 | + new Error( | |
| 1494 | + `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`, | |
| 1495 | + ), | |
| 1496 | + ); | |
| 1497 | + backendListeningDetector = null; | |
| 1498 | + } | |
| 1083 | 1499 | const wasExpected = expectedBackendExitChildren.has(child); |
| 1084 | 1500 | if (backendProcess === child) { |
| 1085 | 1501 | backendProcess = null; |
| @@ -1091,9 +1507,13 @@ function startBackend(): void { · 5 unmodified lines | ||
| 1091 | 1507 | const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`; |
| 1092 | 1508 | scheduleBackendRestart(reason); |
| 1093 | 1509 | }); |
| 1510 | + | |
| 1511 | + ensureInitialBackendWindowOpen(); | |
| 1094 | 1512 | } |
| 1095 | 1513 | |
| 1096 | 1514 | function stopBackend(): void { |
| 1515 | + cancelBackendReadinessWait(); | |
| 1516 | + backendListeningDetector = null; | |
| 1097 | 1517 | if (restartTimer) { |
| 1098 | 1518 | clearTimeout(restartTimer); |
| 1099 | 1519 | restartTimer = null; |
| @@ -1115,6 +1535,7 @@ function stopBackend(): void { · 15 unmodified lines | ||
| 1115 | 1535 | } |
| 1116 | 1536 | |
| 1117 | 1537 | async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1538 | + cancelBackendReadinessWait(); | |
| 1118 | 1539 | if (restartTimer) { |
| 1119 | 1540 | clearTimeout(restartTimer); |
| 1120 | 1541 | restartTimer = null; |
| @@ -1167,21 +1588,134 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { · 46 unmodified lines | ||
| 1167 | 1588 | } |
| 1168 | 1589 | |
| 1169 | 1590 | function registerIpcHandlers(): void { |
| 1170 | − ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); | |
| 1171 | − ipcMain.on(GET_WS_URL_CHANNEL, (event) => { | |
| 1172 | − event.returnValue = backendWsUrl; | |
| 1591 | + ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL); | |
| 1592 | + ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => { | |
| 1593 | + event.returnValue = desktopAppBranding; | |
| 1594 | + }); | |
| 1595 | + | |
| 1596 | + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 1597 | + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { | |
| 1598 | + event.returnValue = { | |
| 1599 | + label: "Local environment", | |
| 1600 | + httpBaseUrl: backendHttpUrl || null, | |
| 1601 | + wsBaseUrl: backendWsUrl || null, | |
| 1602 | + bootstrapToken: backendBootstrapToken || undefined, | |
| 1603 | + } as const; | |
| 1604 | + }); | |
| 1605 | + | |
| 1606 | + ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); | |
| 1607 | + ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); | |
| 1608 | + | |
| 1609 | + ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); | |
| 1610 | + ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { | |
| 1611 | + if (typeof rawSettings !== "object" || rawSettings === null) { | |
| 1612 | + throw new Error("Invalid client settings payload."); | |
| 1613 | + } | |
| 1614 | + | |
| 1615 | + writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); | |
| 1616 | + }); | |
| 1617 | + | |
| 1618 | + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); | |
| 1619 | + ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () => | |
| 1620 | + readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH), | |
| 1621 | + ); | |
| 1622 | + | |
| 1623 | + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); | |
| 1624 | + ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => { | |
| 1625 | + if (!Array.isArray(rawRecords)) { | |
| 1626 | + throw new Error("Invalid saved environment registry payload."); | |
| 1627 | + } | |
| 1628 | + | |
| 1629 | + writeSavedEnvironmentRegistry( | |
| 1630 | + SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1631 | + rawRecords as readonly PersistedSavedEnvironmentRecord[], | |
| 1632 | + ); | |
| 1633 | + }); | |
| 1634 | + | |
| 1635 | + ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL); | |
| 1636 | + ipcMain.handle( | |
| 1637 | + GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 1638 | + async (_event, rawEnvironmentId: unknown) => { | |
| 1639 | + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { | |
| 1640 | + return null; | |
| 1641 | + } | |
| 1642 | + | |
| 1643 | + return readSavedEnvironmentSecret({ | |
| 1644 | + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1645 | + environmentId: rawEnvironmentId, | |
| 1646 | + secretStorage: getDesktopSecretStorage(), | |
| 1647 | + }); | |
| 1648 | + }, | |
| 1649 | + ); | |
| 1650 | + | |
| 1651 | + ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL); | |
| 1652 | + ipcMain.handle( | |
| 1653 | + SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 1654 | + async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => { | |
| 1655 | + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { | |
| 1656 | + throw new Error("Invalid saved environment id."); | |
| 1657 | + } | |
| 1658 | + if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) { | |
| 1659 | + throw new Error("Invalid saved environment secret."); | |
| 1660 | + } | |
| 1661 | + | |
| 1662 | + return writeSavedEnvironmentSecret({ | |
| 1663 | + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1664 | + environmentId: rawEnvironmentId, | |
| 1665 | + secret: rawSecret, | |
| 1666 | + secretStorage: getDesktopSecretStorage(), | |
| 1667 | + }); | |
| 1668 | + }, | |
| 1669 | + ); | |
| 1670 | + | |
| 1671 | + ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL); | |
| 1672 | + ipcMain.handle( | |
| 1673 | + REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, | |
| 1674 | + async (_event, rawEnvironmentId: unknown) => { | |
| 1675 | + if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) { | |
| 1676 | + return; | |
| 1677 | + } | |
| 1678 | + | |
| 1679 | + removeSavedEnvironmentSecret({ | |
| 1680 | + registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH, | |
| 1681 | + environmentId: rawEnvironmentId, | |
| 1682 | + }); | |
| 1683 | + }, | |
| 1684 | + ); | |
| 1685 | + | |
| 1686 | + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); | |
| 1687 | + ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); | |
| 1688 | + | |
| 1689 | + ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); | |
| 1690 | + ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { | |
| 1691 | + if (rawMode !== "local-only" && rawMode !== "network-accessible") { | |
| 1692 | + throw new Error("Invalid desktop server exposure input."); | |
| 1693 | + } | |
| 1694 | + | |
| 1695 | + const nextMode = rawMode as DesktopServerExposureMode; | |
| 1696 | + if (nextMode === desktopServerExposureMode) { | |
| 1697 | + return getDesktopServerExposureState(); | |
| 1698 | + } | |
| 1699 | + | |
| 1700 | + const nextState = await applyDesktopServerExposureMode(nextMode, { | |
| 1701 | + persist: true, | |
| 1702 | + rejectIfUnavailable: true, | |
| 1703 | + }); | |
| 1704 | + relaunchDesktopApp(`serverExposureMode=${nextMode}`); | |
| 1705 | + return nextState; | |
| 1173 | 1706 | }); |
| 1174 | 1707 | |
| 1175 | 1708 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1176 | − ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { | |
| 1709 | + ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { | |
| 1177 | 1710 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| 1711 | + const defaultPath = resolvePickFolderDefaultPath(rawOptions); | |
| 1712 | + const openDialogOptions: OpenDialogOptions = { | |
| 1713 | + properties: ["openDirectory", "createDirectory"], | |
| 1714 | + ...(defaultPath ? { defaultPath } : {}), | |
| 1715 | + }; | |
| 1178 | 1716 | const result = owner |
| 1179 | − ? await dialog.showOpenDialog(owner, { | |
| 1180 | − properties: ["openDirectory", "createDirectory"], | |
| 1181 | − }) | |
| 1182 | − : await dialog.showOpenDialog({ | |
| 1183 | − properties: ["openDirectory", "createDirectory"], | |
| 1184 | − }); | |
| 1717 | + ? await dialog.showOpenDialog(owner, openDialogOptions) | |
| 1718 | + : await dialog.showOpenDialog(openDialogOptions); | |
| 1185 | 1719 | if (result.canceled) return null; |
| 1186 | 1720 | return result.filePaths[0] ?? null; |
| 1187 | 1721 | }); |
| @@ -1210,14 +1744,7 @@ function registerIpcHandlers(): void { · 22 unmodified lines | ||
| 1210 | 1744 | ipcMain.handle( |
| 1211 | 1745 | CONTEXT_MENU_CHANNEL, |
| 1212 | 1746 | async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => { |
| 1213 | − const normalizedItems = items | |
| 1214 | − .filter((item) => typeof item.id === "string" && typeof item.label === "string") | |
| 1215 | − .map((item) => ({ | |
| 1216 | − id: item.id, | |
| 1217 | − label: item.label, | |
| 1218 | − destructive: item.destructive === true, | |
| 1219 | − disabled: item.disabled === true, | |
| 1220 | − })); | |
| 1747 | + const normalizedItems = normalizeContextMenuItems(items); | |
| 1221 | 1748 | if (normalizedItems.length === 0) { |
| 1222 | 1749 | return null; |
| 1223 | 1750 | } |
| @@ -1238,28 +1765,37 @@ function registerIpcHandlers(): void { · 14 unmodified lines | ||
| 1238 | 1765 | if (!window) return null; |
| 1239 | 1766 | |
| 1240 | 1767 | return new Promise<string | null>((resolve) => { |
| 1241 | − const template: MenuItemConstructorOptions[] = []; | |
| 1242 | − let hasInsertedDestructiveSeparator = false; | |
| 1243 | − for (const item of normalizedItems) { | |
| 1244 | − if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | |
| 1245 | − template.push({ type: "separator" }); | |
| 1246 | − hasInsertedDestructiveSeparator = true; | |
| 1247 | − } | |
| 1248 | − const itemOption: MenuItemConstructorOptions = { | |
| 1249 | − label: item.label, | |
| 1250 | − enabled: !item.disabled, | |
| 1251 | − click: () => resolve(item.id), | |
| 1252 | − }; | |
| 1253 | − if (item.destructive) { | |
| 1254 | − const destructiveIcon = getDestructiveMenuIcon(); | |
| 1255 | − if (destructiveIcon) { | |
| 1256 | − itemOption.icon = destructiveIcon; | |
| 1768 | + const buildTemplate = ( | |
| 1769 | + entries: readonly ContextMenuItem[], | |
| 1770 | + ): MenuItemConstructorOptions[] => { | |
| 1771 | + const template: MenuItemConstructorOptions[] = []; | |
| 1772 | + let hasInsertedDestructiveSeparator = false; | |
| 1773 | + for (const item of entries) { | |
| 1774 | + if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { | |
| 1775 | + template.push({ type: "separator" }); | |
| 1776 | + hasInsertedDestructiveSeparator = true; | |
| 1777 | + } | |
| 1778 | + const itemOption: MenuItemConstructorOptions = { | |
| 1779 | + label: item.label, | |
| 1780 | + enabled: !item.disabled, | |
| 1781 | + }; | |
| 1782 | + if (item.children && item.children.length > 0) { | |
| 1783 | + itemOption.submenu = buildTemplate(item.children); | |
| 1784 | + } else { | |
| 1785 | + itemOption.click = () => resolve(item.id); | |
| 1786 | + } | |
| 1787 | + if (item.destructive && (!item.children || item.children.length === 0)) { | |
| 1788 | + const destructiveIcon = getDestructiveMenuIcon(); | |
| 1789 | + if (destructiveIcon) { | |
| 1790 | + itemOption.icon = destructiveIcon; | |
| 1791 | + } | |
| 1257 | 1792 | } |
| 1793 | + template.push(itemOption); | |
| 1258 | 1794 | } |
| 1259 | − template.push(itemOption); | |
| 1260 | − } | |
| 1795 | + return template; | |
| 1796 | + }; | |
| 1261 | 1797 | |
| 1262 | − const menu = Menu.buildFromTemplate(template); | |
| 1798 | + const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems)); | |
| 1263 | 1799 | menu.popup({ |
| 1264 | 1800 | window, |
| 1265 | 1801 | ...popupPosition, |
| @@ -1287,6 +1823,43 @@ function registerIpcHandlers(): void { · 21 unmodified lines | ||
| 1287 | 1823 | ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); |
| 1288 | 1824 | ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); |
| 1289 | 1825 | |
| 1826 | + ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL); | |
| 1827 | + ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => { | |
| 1828 | + if (rawChannel !== "latest" && rawChannel !== "nightly") { | |
| 1829 | + throw new Error("Invalid desktop update channel input."); | |
| 1830 | + } | |
| 1831 | + if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) { | |
| 1832 | + throw new Error("Cannot change update tracks while an update action is in progress."); | |
| 1833 | + } | |
| 1834 | + | |
| 1835 | + const nextChannel = rawChannel as DesktopUpdateChannel; | |
| 1836 | + | |
| 1837 | + desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel); | |
| 1838 | + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); | |
| 1839 | + | |
| 1840 | + if (nextChannel === updateState.channel) { | |
| 1841 | + return updateState; | |
| 1842 | + } | |
| 1843 | + | |
| 1844 | + const enabled = shouldEnableAutoUpdates(); | |
| 1845 | + setUpdateState(createBaseUpdateState(nextChannel, enabled)); | |
| 1846 | + | |
| 1847 | + if (!enabled || !updaterConfigured) { | |
| 1848 | + return updateState; | |
| 1849 | + } | |
| 1850 | + | |
| 1851 | + applyAutoUpdaterChannel(nextChannel); | |
| 1852 | + const allowDowngrade = autoUpdater.allowDowngrade; | |
| 1853 | + // An explicit channel switch should allow the immediate nightly->stable rollback path. | |
| 1854 | + autoUpdater.allowDowngrade = true; | |
| 1855 | + try { | |
| 1856 | + await checkForUpdates("channel-change"); | |
| 1857 | + } finally { | |
| 1858 | + autoUpdater.allowDowngrade = allowDowngrade; | |
| 1859 | + } | |
| 1860 | + return updateState; | |
| 1861 | + }); | |
| 1862 | + | |
| 1290 | 1863 | ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL); |
| 1291 | 1864 | ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => { |
| 1292 | 1865 | const result = await downloadAvailableUpdate(); |
| @@ -1337,6 +1910,50 @@ function getIconOption(): { icon: string } | Record<string, never> { · 44 unmodified lines | ||
| 1337 | 1910 | return iconPath ? { icon: iconPath } : {}; |
| 1338 | 1911 | } |
| 1339 | 1912 | |
| 1913 | +function getInitialWindowBackgroundColor(): string { | |
| 1914 | + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; | |
| 1915 | +} | |
| 1916 | + | |
| 1917 | +function getWindowTitleBarOptions(): WindowTitleBarOptions { | |
| 1918 | + if (process.platform === "darwin") { | |
| 1919 | + return { | |
| 1920 | + titleBarStyle: "hiddenInset", | |
| 1921 | + trafficLightPosition: { x: 16, y: 18 }, | |
| 1922 | + }; | |
| 1923 | + } | |
| 1924 | + | |
| 1925 | + return { | |
| 1926 | + titleBarStyle: "hidden", | |
| 1927 | + titleBarOverlay: { | |
| 1928 | + color: TITLEBAR_COLOR, | |
| 1929 | + height: TITLEBAR_HEIGHT, | |
| 1930 | + symbolColor: nativeTheme.shouldUseDarkColors | |
| 1931 | + ? TITLEBAR_DARK_SYMBOL_COLOR | |
| 1932 | + : TITLEBAR_LIGHT_SYMBOL_COLOR, | |
| 1933 | + }, | |
| 1934 | + }; | |
| 1935 | +} | |
| 1936 | + | |
| 1937 | +function syncWindowAppearance(window: BrowserWindow): void { | |
| 1938 | + if (window.isDestroyed()) { | |
| 1939 | + return; | |
| 1940 | + } | |
| 1941 | + | |
| 1942 | + window.setBackgroundColor(getInitialWindowBackgroundColor()); | |
| 1943 | + const { titleBarOverlay } = getWindowTitleBarOptions(); | |
| 1944 | + if (typeof titleBarOverlay === "object") { | |
| 1945 | + window.setTitleBarOverlay(titleBarOverlay); | |
| 1946 | + } | |
| 1947 | +} | |
| 1948 | + | |
| 1949 | +function syncAllWindowAppearance(): void { | |
| 1950 | + for (const window of BrowserWindow.getAllWindows()) { | |
| 1951 | + syncWindowAppearance(window); | |
| 1952 | + } | |
| 1953 | +} | |
| 1954 | + | |
| 1955 | +nativeTheme.on("updated", syncAllWindowAppearance); | |
| 1956 | + | |
| 1340 | 1957 | function createWindow(): BrowserWindow { |
| 1341 | 1958 | const window = new BrowserWindow({ |
| 1342 | 1959 | width: 1100, |
| @@ -1345,12 +1962,12 @@ function createWindow(): BrowserWindow { · 2 unmodified lines | ||
| 1345 | 1962 | minHeight: 620, |
| 1346 | 1963 | show: false, |
| 1347 | 1964 | autoHideMenuBar: true, |
| 1965 | + backgroundColor: getInitialWindowBackgroundColor(), | |
| 1348 | 1966 | ...getIconOption(), |
| 1349 | 1967 | title: APP_DISPLAY_NAME, |
| 1350 | − titleBarStyle: "hiddenInset", | |
| 1351 | − trafficLightPosition: { x: 16, y: 18 }, | |
| 1968 | + ...getWindowTitleBarOptions(), | |
| 1352 | 1969 | webPreferences: { |
| 1353 | − preload: Path.join(__dirname, "preload.js"), | |
| 1970 | + preload: Path.join(__dirname, "preload.cjs"), | |
| 1354 | 1971 | contextIsolation: true, |
| 1355 | 1972 | nodeIntegration: false, |
| 1356 | 1973 | sandbox: true, |
| @@ -1375,6 +1992,22 @@ function createWindow(): BrowserWindow { · 18 unmodified lines | ||
| 1375 | 1992 | menuTemplate.push({ type: "separator" }); |
| 1376 | 1993 | } |
| 1377 | 1994 | |
| 1995 | + const externalUrl = getSafeExternalUrl(params.linkURL); | |
| 1996 | + if (externalUrl) { | |
| 1997 | + menuTemplate.push( | |
| 1998 | + { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) }, | |
| 1999 | + { type: "separator" }, | |
| 2000 | + ); | |
| 2001 | + } | |
| 2002 | + | |
| 2003 | + if (params.mediaType === "image") { | |
| 2004 | + menuTemplate.push({ | |
| 2005 | + label: "Copy Image", | |
| 2006 | + click: () => window.webContents.copyImageAt(params.x, params.y), | |
| 2007 | + }); | |
| 2008 | + menuTemplate.push({ type: "separator" }); | |
| 2009 | + } | |
| 2010 | + | |
| 1378 | 2011 | menuTemplate.push( |
| 1379 | 2012 | { role: "cut", enabled: params.editFlags.canCut }, |
| 1380 | 2013 | { role: "copy", enabled: params.editFlags.canCopy }, |
| @@ -1401,15 +2034,23 @@ function createWindow(): BrowserWindow { · 20 unmodified lines | ||
| 1401 | 2034 | window.setTitle(APP_DISPLAY_NAME); |
| 1402 | 2035 | emitUpdateState(); |
| 1403 | 2036 | }); |
| 1404 | − window.once("ready-to-show", () => { | |
| 1405 | − window.show(); | |
| 1406 | − }); | |
| 2037 | + | |
| 2038 | + let initialRevealScheduled = false; | |
| 2039 | + const revealInitialWindow = () => { | |
| 2040 | + if (initialRevealScheduled) { | |
| 2041 | + return; | |
| 2042 | + } | |
| 2043 | + initialRevealScheduled = true; | |
| 2044 | + revealWindow(window); | |
| 2045 | + }; | |
| 2046 | + | |
| 2047 | + window.once("ready-to-show", revealInitialWindow); | |
| 1407 | 2048 | |
| 1408 | 2049 | if (isDevelopment) { |
| 1409 | − void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); | |
| 2050 | + void window.loadURL(resolveDesktopDevServerUrl()); | |
| 1410 | 2051 | window.webContents.openDevTools({ mode: "detach" }); |
| 1411 | 2052 | } else { |
| 1412 | − void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); | |
| 2053 | + void window.loadURL(backendHttpUrl); | |
| 1413 | 2054 | } |
| 1414 | 2055 | |
| 1415 | 2056 | window.on("closed", () => { |
| @@ -1430,23 +2071,71 @@ configureAppIdentity(); · 14 unmodified lines | ||
| 1430 | 2071 | |
| 1431 | 2072 | async function bootstrap(): Promise<void> { |
| 1432 | 2073 | writeDesktopLogHeader("bootstrap start"); |
| 1433 | − backendPort = await Effect.service(NetService).pipe( | |
| 1434 | − Effect.flatMap((net) => net.reserveLoopbackPort()), | |
| 1435 | − Effect.provide(NetService.layer), | |
| 1436 | − Effect.runPromise, | |
| 2074 | + const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); | |
| 2075 | + if (isDevelopment && configuredBackendPort === undefined) { | |
| 2076 | + throw new Error("T3CODE_PORT is required in desktop development."); | |
| 2077 | + } | |
| 2078 | + | |
| 2079 | + backendPort = | |
| 2080 | + configuredBackendPort ?? | |
| 2081 | + (await resolveDesktopBackendPort({ | |
| 2082 | + host: DESKTOP_LOOPBACK_HOST, | |
| 2083 | + startPort: DEFAULT_DESKTOP_BACKEND_PORT, | |
| 2084 | + requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, | |
| 2085 | + })); | |
| 2086 | + writeDesktopLogHeader( | |
| 2087 | + configuredBackendPort === undefined | |
| 2088 | + ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}` | |
| 2089 | + : `using configured backend port port=${backendPort}`, | |
| 2090 | + ); | |
| 2091 | + backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); | |
| 2092 | + if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { | |
| 2093 | + writeDesktopLogHeader( | |
| 2094 | + `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, | |
| 2095 | + ); | |
| 2096 | + } | |
| 2097 | + const serverExposureState = await applyDesktopServerExposureMode( | |
| 2098 | + desktopSettings.serverExposureMode, | |
| 2099 | + { | |
| 2100 | + persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, | |
| 2101 | + }, | |
| 1437 | 2102 | ); |
| 1438 | − writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); | |
| 1439 | − backendAuthToken = Crypto.randomBytes(24).toString("hex"); | |
| 1440 | − const baseUrl = `ws://127.0.0.1:${backendPort}`; | |
| 1441 | − backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; | |
| 1442 | − writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); | |
| 2103 | + writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); | |
| 2104 | + if (serverExposureState.endpointUrl) { | |
| 2105 | + writeDesktopLogHeader( | |
| 2106 | + `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, | |
| 2107 | + ); | |
| 2108 | + } else if (desktopSettings.serverExposureMode === "network-accessible") { | |
| 2109 | + writeDesktopLogHeader( | |
| 2110 | + "bootstrap fell back to local-only because no advertised network host was available", | |
| 2111 | + ); | |
| 2112 | + } | |
| 1443 | 2113 | |
| 1444 | 2114 | registerIpcHandlers(); |
| 1445 | 2115 | writeDesktopLogHeader("bootstrap ipc handlers registered"); |
| 1446 | 2116 | startBackend(); |
| 1447 | 2117 | writeDesktopLogHeader("bootstrap backend start requested"); |
| 1448 | − mainWindow = createWindow(); | |
| 1449 | − writeDesktopLogHeader("bootstrap main window created"); | |
| 2118 | + | |
| 2119 | + if (isDevelopment) { | |
| 2120 | + mainWindow = createWindow(); | |
| 2121 | + writeDesktopLogHeader("bootstrap main window created"); | |
| 2122 | + void waitForBackendHttpReady(backendHttpUrl) | |
| 2123 | + .then(() => { | |
| 2124 | + writeDesktopLogHeader("bootstrap backend ready"); | |
| 2125 | + }) | |
| 2126 | + .catch((error) => { | |
| 2127 | + if (isBackendReadinessAborted(error)) { | |
| 2128 | + return; | |
| 2129 | + } | |
| 2130 | + writeDesktopLogHeader( | |
| 2131 | + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, | |
| 2132 | + ); | |
| 2133 | + console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); | |
| 2134 | + }); | |
| 2135 | + return; | |
| 2136 | + } | |
| 2137 | + | |
| 2138 | + ensureInitialBackendWindowOpen(); | |
| 1450 | 2139 | } |
| 1451 | 2140 | |
| 1452 | 2141 | app.on("before-quit", () => { |
| @@ -1454,6 +2143,7 @@ app.on("before-quit", () => { · 1 unmodified line | ||
| 1454 | 2143 | updateInstallInFlight = false; |
| 1455 | 2144 | writeDesktopLogHeader("before-quit received"); |
| 1456 | 2145 | clearUpdatePollTimer(); |
| 2146 | + cancelBackendReadinessWait(); | |
| 1457 | 2147 | stopBackend(); |
| 1458 | 2148 | restoreStdIoCapture?.(); |
| 1459 | 2149 | }); |
| @@ -1467,13 +2157,23 @@ app · 7 unmodified lines | ||
| 1467 | 2157 | registerDesktopProtocol(); |
| 1468 | 2158 | configureAutoUpdater(); |
| 1469 | 2159 | void bootstrap().catch((error) => { |
| 2160 | + if (isBackendReadinessAborted(error) && isQuitting) { | |
| 2161 | + return; | |
| 2162 | + } | |
| 1470 | 2163 | handleFatalStartupError("bootstrap", error); |
| 1471 | 2164 | }); |
| 1472 | 2165 | |
| 1473 | 2166 | app.on("activate", () => { |
| 1474 | − if (BrowserWindow.getAllWindows().length === 0) { | |
| 2167 | + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 2168 | + if (existingWindow) { | |
| 2169 | + revealWindow(existingWindow); | |
| 2170 | + return; | |
| 2171 | + } | |
| 2172 | + if (isDevelopment) { | |
| 1475 | 2173 | mainWindow = createWindow(); |
| 2174 | + return; | |
| 1476 | 2175 | } |
| 2176 | + ensureInitialBackendWindowOpen(); | |
| 1477 | 2177 | }); |
| 1478 | 2178 | }) |
| 1479 | 2179 | .catch((error) => { |
| @@ -1492,6 +2192,7 @@ if (process.platform !== "win32") { · 12 unmodified lines | ||
| 1492 | 2192 | isQuitting = true; |
| 1493 | 2193 | writeDesktopLogHeader("SIGINT received"); |
| 1494 | 2194 | clearUpdatePollTimer(); |
| 2195 | + cancelBackendReadinessWait(); | |
| 1495 | 2196 | stopBackend(); |
| 1496 | 2197 | restoreStdIoCapture?.(); |
| 1497 | 2198 | app.quit(); |
| @@ -9,17 +9,51 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; · 8 unmodified lines | ||
| 9 | 9 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 10 | 10 | const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 11 | 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | +const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; | |
| 12 | 13 | const UPDATE_CHECK_CHANNEL = "desktop:update-check"; |
| 13 | 14 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 14 | 15 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 15 | −const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | |
| 16 | +const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | |
| 17 | +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; | |
| 18 | +const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | |
| 19 | +const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; | |
| 20 | +const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; | |
| 21 | +const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry"; | |
| 22 | +const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; | |
| 23 | +const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; | |
| 24 | +const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; | |
| 25 | +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; | |
| 26 | +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; | |
| 16 | 27 | |
| 17 | 28 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 18 | − getWsUrl: () => { | |
| 19 | − const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); | |
| 20 | − return typeof result === "string" ? result : null; | |
| 29 | + getAppBranding: () => { | |
| 30 | + const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL); | |
| 31 | + if (typeof result !== "object" || result === null) { | |
| 32 | + return null; | |
| 33 | + } | |
| 34 | + return result as ReturnType<DesktopBridge["getAppBranding"]>; | |
| 21 | 35 | }, |
| 22 | − pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | |
| 36 | + getLocalEnvironmentBootstrap: () => { | |
| 37 | + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); | |
| 38 | + if (typeof result !== "object" || result === null) { | |
| 39 | + return null; | |
| 40 | + } | |
| 41 | + return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>; | |
| 42 | + }, | |
| 43 | + getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), | |
| 44 | + setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), | |
| 45 | + getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), | |
| 46 | + setSavedEnvironmentRegistry: (records) => | |
| 47 | + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records), | |
| 48 | + getSavedEnvironmentSecret: (environmentId) => | |
| 49 | + ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | |
| 50 | + setSavedEnvironmentSecret: (environmentId, secret) => | |
| 51 | + ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), | |
| 52 | + removeSavedEnvironmentSecret: (environmentId) => | |
| 53 | + ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), | |
| 54 | + getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), | |
| 55 | + setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), | |
| 56 | + pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), | |
| 23 | 57 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 24 | 58 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), |
| 25 | 59 | showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), |
| @@ -36,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 10 unmodified lines | ||
| 36 | 70 | }; |
| 37 | 71 | }, |
| 38 | 72 | getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL), |
| 73 | + setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel), | |
| 39 | 74 | checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL), |
| 40 | 75 | downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL), |
| 41 | 76 | installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | |
| 3 | −import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; | |
| 3 | +import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; | |
| 4 | 4 | |
| 5 | 5 | describe("resolveDesktopRuntimeInfo", () => { |
| 6 | 6 | it("detects Rosetta-translated Intel builds on Apple Silicon", () => { |
| @@ -0,0 +1,139 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; | |
| 4 | + | |
| 5 | +describe("resolveLanAdvertisedHost", () => { | |
| 6 | + it("prefers an explicit host override", () => { | |
| 7 | + expect( | |
| 8 | + resolveLanAdvertisedHost( | |
| 9 | + { | |
| 10 | + en0: [ | |
| 11 | + { | |
| 12 | + address: "192.168.1.44", | |
| 13 | + family: "IPv4", | |
| 14 | + internal: false, | |
| 15 | + netmask: "255.255.255.0", | |
| 16 | + cidr: "192.168.1.44/24", | |
| 17 | + mac: "00:00:00:00:00:00", | |
| 18 | + }, | |
| 19 | + ], | |
| 20 | + }, | |
| 21 | + "10.0.0.9", | |
| 22 | + ), | |
| 23 | + ).toBe("10.0.0.9"); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it("returns the first usable non-internal IPv4 address", () => { | |
| 27 | + expect( | |
| 28 | + resolveLanAdvertisedHost( | |
| 29 | + { | |
| 30 | + lo0: [ | |
| 31 | + { | |
| 32 | + address: "127.0.0.1", | |
| 33 | + family: "IPv4", | |
| 34 | + internal: true, | |
| 35 | + netmask: "255.0.0.0", | |
| 36 | + cidr: "127.0.0.1/8", | |
| 37 | + mac: "00:00:00:00:00:00", | |
| 38 | + }, | |
| 39 | + ], | |
| 40 | + en0: [ | |
| 41 | + { | |
| 42 | + address: "192.168.1.44", | |
| 43 | + family: "IPv4", | |
| 44 | + internal: false, | |
| 45 | + netmask: "255.255.255.0", | |
| 46 | + cidr: "192.168.1.44/24", | |
| 47 | + mac: "00:00:00:00:00:00", | |
| 48 | + }, | |
| 49 | + ], | |
| 50 | + }, | |
| 51 | + undefined, | |
| 52 | + ), | |
| 53 | + ).toBe("192.168.1.44"); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it("returns null when no usable network address is available", () => { | |
| 57 | + expect( | |
| 58 | + resolveLanAdvertisedHost( | |
| 59 | + { | |
| 60 | + lo0: [ | |
| 61 | + { | |
| 62 | + address: "127.0.0.1", | |
| 63 | + family: "IPv4", | |
| 64 | + internal: true, | |
| 65 | + netmask: "255.0.0.0", | |
| 66 | + cidr: "127.0.0.1/8", | |
| 67 | + mac: "00:00:00:00:00:00", | |
| 68 | + }, | |
| 69 | + ], | |
| 70 | + }, | |
| 71 | + undefined, | |
| 72 | + ), | |
| 73 | + ).toBeNull(); | |
| 74 | + }); | |
| 75 | +}); | |
| 76 | + | |
| 77 | +describe("resolveDesktopServerExposure", () => { | |
| 78 | + it("keeps the desktop server loopback-only when local-only mode is selected", () => { | |
| 79 | + expect( | |
| 80 | + resolveDesktopServerExposure({ | |
| 81 | + mode: "local-only", | |
| 82 | + port: 3773, | |
| 83 | + networkInterfaces: {}, | |
| 84 | + }), | |
| 85 | + ).toEqual({ | |
| 86 | + mode: "local-only", | |
| 87 | + bindHost: "127.0.0.1", | |
| 88 | + localHttpUrl: "http://127.0.0.1:3773", | |
| 89 | + localWsUrl: "ws://127.0.0.1:3773", | |
| 90 | + endpointUrl: null, | |
| 91 | + advertisedHost: null, | |
| 92 | + }); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it("binds to all interfaces in network-accessible mode", () => { | |
| 96 | + expect( | |
| 97 | + resolveDesktopServerExposure({ | |
| 98 | + mode: "network-accessible", | |
| 99 | + port: 3773, | |
| 100 | + networkInterfaces: { | |
| 101 | + en0: [ | |
| 102 | + { | |
| 103 | + address: "192.168.1.44", | |
| 104 | + family: "IPv4", | |
| 105 | + internal: false, | |
| 106 | + netmask: "255.255.255.0", | |
| 107 | + cidr: "192.168.1.44/24", | |
| 108 | + mac: "00:00:00:00:00:00", | |
| 109 | + }, | |
| 110 | + ], | |
| 111 | + }, | |
| 112 | + }), | |
| 113 | + ).toEqual({ | |
| 114 | + mode: "network-accessible", | |
| 115 | + bindHost: "0.0.0.0", | |
| 116 | + localHttpUrl: "http://127.0.0.1:3773", | |
| 117 | + localWsUrl: "ws://127.0.0.1:3773", | |
| 118 | + endpointUrl: "http://192.168.1.44:3773", | |
| 119 | + advertisedHost: "192.168.1.44", | |
| 120 | + }); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("stays network-accessible even when no LAN address is currently detectable", () => { | |
| 124 | + expect( | |
| 125 | + resolveDesktopServerExposure({ | |
| 126 | + mode: "network-accessible", | |
| 127 | + port: 3773, | |
| 128 | + networkInterfaces: {}, | |
| 129 | + }), | |
| 130 | + ).toEqual({ | |
| 131 | + mode: "network-accessible", | |
| 132 | + bindHost: "0.0.0.0", | |
| 133 | + localHttpUrl: "http://127.0.0.1:3773", | |
| 134 | + localWsUrl: "ws://127.0.0.1:3773", | |
| 135 | + endpointUrl: null, | |
| 136 | + advertisedHost: null, | |
| 137 | + }); | |
| 138 | + }); | |
| 139 | +}); | |
| @@ -0,0 +1,80 @@ | ||
| 1 | +import type { NetworkInterfaceInfo } from "node:os"; | |
| 2 | +import type { DesktopServerExposureMode } from "@t3tools/contracts"; | |
| 3 | + | |
| 4 | +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | |
| 5 | +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; | |
| 6 | + | |
| 7 | +export interface DesktopServerExposure { | |
| 8 | + readonly mode: DesktopServerExposureMode; | |
| 9 | + readonly bindHost: string; | |
| 10 | + readonly localHttpUrl: string; | |
| 11 | + readonly localWsUrl: string; | |
| 12 | + readonly endpointUrl: string | null; | |
| 13 | + readonly advertisedHost: string | null; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const normalizeOptionalHost = (value: string | undefined): string | undefined => { | |
| 17 | + const normalized = value?.trim(); | |
| 18 | + return normalized && normalized.length > 0 ? normalized : undefined; | |
| 19 | +}; | |
| 20 | + | |
| 21 | +const isUsableLanIpv4Address = (address: string): boolean => | |
| 22 | + !address.startsWith("127.") && !address.startsWith("169.254."); | |
| 23 | + | |
| 24 | +export function resolveLanAdvertisedHost( | |
| 25 | + networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>, | |
| 26 | + explicitHost: string | undefined, | |
| 27 | +): string | null { | |
| 28 | + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); | |
| 29 | + if (normalizedExplicitHost) { | |
| 30 | + return normalizedExplicitHost; | |
| 31 | + } | |
| 32 | + | |
| 33 | + for (const interfaceAddresses of Object.values(networkInterfaces)) { | |
| 34 | + if (!interfaceAddresses) continue; | |
| 35 | + | |
| 36 | + for (const address of interfaceAddresses) { | |
| 37 | + if (address.internal) continue; | |
| 38 | + if (address.family !== "IPv4") continue; | |
| 39 | + if (!isUsableLanIpv4Address(address.address)) continue; | |
| 40 | + return address.address; | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + return null; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function resolveDesktopServerExposure(input: { | |
| 48 | + readonly mode: DesktopServerExposureMode; | |
| 49 | + readonly port: number; | |
| 50 | + readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>; | |
| 51 | + readonly advertisedHostOverride?: string; | |
| 52 | +}): DesktopServerExposure { | |
| 53 | + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 54 | + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; | |
| 55 | + | |
| 56 | + if (input.mode === "local-only") { | |
| 57 | + return { | |
| 58 | + mode: input.mode, | |
| 59 | + bindHost: DESKTOP_LOOPBACK_HOST, | |
| 60 | + localHttpUrl, | |
| 61 | + localWsUrl, | |
| 62 | + endpointUrl: null, | |
| 63 | + advertisedHost: null, | |
| 64 | + }; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const advertisedHost = resolveLanAdvertisedHost( | |
| 68 | + input.networkInterfaces, | |
| 69 | + input.advertisedHostOverride, | |
| 70 | + ); | |
| 71 | + | |
| 72 | + return { | |
| 73 | + mode: input.mode, | |
| 74 | + bindHost: DESKTOP_LAN_BIND_HOST, | |
| 75 | + localHttpUrl, | |
| 76 | + localWsUrl, | |
| 77 | + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, | |
| 78 | + advertisedHost, | |
| 79 | + }; | |
| 80 | +} | |
| @@ -0,0 +1,31 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { ServerListeningDetector } from "./serverListeningDetector.ts"; | |
| 4 | + | |
| 5 | +describe("ServerListeningDetector", () => { | |
| 6 | + it("resolves when the server logs the listening line", async () => { | |
| 7 | + const detector = new ServerListeningDetector(); | |
| 8 | + | |
| 9 | + detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n"); | |
| 10 | + | |
| 11 | + await expect(detector.promise).resolves.toBeUndefined(); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("resolves when the listening line arrives across multiple chunks", async () => { | |
| 15 | + const detector = new ServerListeningDetector(); | |
| 16 | + | |
| 17 | + detector.push("[01:23:30.571] INFO (#148): Listen"); | |
| 18 | + detector.push("ing on http://0.0.0.0:7011\n"); | |
| 19 | + | |
| 20 | + await expect(detector.promise).resolves.toBeUndefined(); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it("rejects when the server exits before logging readiness", async () => { | |
| 24 | + const detector = new ServerListeningDetector(); | |
| 25 | + const error = new Error("server exited"); | |
| 26 | + | |
| 27 | + detector.fail(error); | |
| 28 | + | |
| 29 | + await expect(detector.promise).rejects.toBe(error); | |
| 30 | + }); | |
| 31 | +}); | |
| @@ -0,0 +1,56 @@ | ||
| 1 | +const LISTENING_LOG_FRAGMENT = "Listening on http://"; | |
| 2 | +const MAX_BUFFER_CHARS = 8_192; | |
| 3 | + | |
| 4 | +export class ServerListeningDetector { | |
| 5 | + private buffer = ""; | |
| 6 | + private settled = false; | |
| 7 | + private readonly resolvePromise: () => void; | |
| 8 | + private readonly rejectPromise: (error: unknown) => void; | |
| 9 | + readonly promise: Promise<void>; | |
| 10 | + | |
| 11 | + constructor() { | |
| 12 | + let resolvePromise: (() => void) | null = null; | |
| 13 | + let rejectPromise: ((error: unknown) => void) | null = null; | |
| 14 | + | |
| 15 | + this.promise = new Promise<void>((resolve, reject) => { | |
| 16 | + resolvePromise = resolve; | |
| 17 | + rejectPromise = reject; | |
| 18 | + }); | |
| 19 | + | |
| 20 | + this.resolvePromise = () => { | |
| 21 | + if (this.settled) { | |
| 22 | + return; | |
| 23 | + } | |
| 24 | + this.settled = true; | |
| 25 | + resolvePromise?.(); | |
| 26 | + }; | |
| 27 | + this.rejectPromise = (error) => { | |
| 28 | + if (this.settled) { | |
| 29 | + return; | |
| 30 | + } | |
| 31 | + this.settled = true; | |
| 32 | + rejectPromise?.(error); | |
| 33 | + }; | |
| 34 | + } | |
| 35 | + | |
| 36 | + push(chunk: unknown): void { | |
| 37 | + if (this.settled) { | |
| 38 | + return; | |
| 39 | + } | |
| 40 | + | |
| 41 | + const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); | |
| 42 | + this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`; | |
| 43 | + if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) { | |
| 44 | + this.resolvePromise(); | |
| 45 | + return; | |
| 46 | + } | |
| 47 | + | |
| 48 | + if (this.buffer.length > MAX_BUFFER_CHARS) { | |
| 49 | + this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + fail(error: unknown): void { | |
| 54 | + this.rejectPromise(error); | |
| 55 | + } | |
| 56 | +} | |
| @@ -1,16 +1,17 @@ | ||
| 1 | 1 | import { describe, expect, it, vi } from "vitest"; |
| 2 | 2 | |
| 3 | −import { syncShellEnvironment } from "./syncShellEnvironment"; | |
| 3 | +import { syncShellEnvironment } from "./syncShellEnvironment.ts"; | |
| 4 | 4 | |
| 5 | 5 | describe("syncShellEnvironment", () => { |
| 6 | 6 | it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => { |
| 7 | 7 | const env: NodeJS.ProcessEnv = { |
| 8 | 8 | SHELL: "/bin/zsh", |
| 9 | − PATH: "/usr/bin", | |
| 9 | + PATH: "/Users/test/.local/bin:/usr/bin", | |
| 10 | 10 | }; |
| 11 | 11 | const readEnvironment = vi.fn(() => ({ |
| 12 | 12 | PATH: "/opt/homebrew/bin:/usr/bin", |
| 13 | 13 | SSH_AUTH_SOCK: "/tmp/secretive.sock", |
| 14 | + HOMEBREW_PREFIX: "/opt/homebrew", | |
| 14 | 15 | })); |
| 15 | 16 | |
| 16 | 17 | syncShellEnvironment(env, { |
| @@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => { · 1 unmodified line | ||
| 18 | 19 | readEnvironment, |
| 19 | 20 | }); |
| 20 | 21 | |
| 21 | − expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); | |
| 22 | − expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); | |
| 22 | + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ | |
| 23 | + "PATH", | |
| 24 | + "SSH_AUTH_SOCK", | |
| 25 | + "HOMEBREW_PREFIX", | |
| 26 | + "HOMEBREW_CELLAR", | |
| 27 | + "HOMEBREW_REPOSITORY", | |
| 28 | + "XDG_CONFIG_HOME", | |
| 29 | + "XDG_DATA_HOME", | |
| 30 | + ]); | |
| 31 | + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); | |
| 23 | 32 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); |
| 33 | + expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew"); | |
| 24 | 34 | }); |
| 25 | 35 | |
| 26 | 36 | it("preserves an inherited SSH_AUTH_SOCK value", () => { |
| @@ -77,12 +87,68 @@ describe("syncShellEnvironment", () => { · 50 unmodified lines | ||
| 77 | 87 | readEnvironment, |
| 78 | 88 | }); |
| 79 | 89 | |
| 80 | − expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]); | |
| 90 | + expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [ | |
| 91 | + "PATH", | |
| 92 | + "SSH_AUTH_SOCK", | |
| 93 | + "HOMEBREW_PREFIX", | |
| 94 | + "HOMEBREW_CELLAR", | |
| 95 | + "HOMEBREW_REPOSITORY", | |
| 96 | + "XDG_CONFIG_HOME", | |
| 97 | + "XDG_DATA_HOME", | |
| 98 | + ]); | |
| 81 | 99 | expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin"); |
| 82 | 100 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); |
| 83 | 101 | }); |
| 84 | 102 | |
| 85 | − it("does nothing outside macOS and linux", () => { | |
| 103 | + it("falls back to launchctl PATH on macOS when shell probing does not return one", () => { | |
| 104 | + const env: NodeJS.ProcessEnv = { | |
| 105 | + SHELL: "/opt/homebrew/bin/nu", | |
| 106 | + PATH: "/usr/bin", | |
| 107 | + }; | |
| 108 | + const readEnvironment = vi | |
| 109 | + .fn() | |
| 110 | + .mockImplementationOnce(() => { | |
| 111 | + throw new Error("unknown flag"); | |
| 112 | + }) | |
| 113 | + .mockImplementationOnce(() => ({})); | |
| 114 | + const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin"); | |
| 115 | + const logWarning = vi.fn(); | |
| 116 | + | |
| 117 | + syncShellEnvironment(env, { | |
| 118 | + platform: "darwin", | |
| 119 | + readEnvironment, | |
| 120 | + readLaunchctlPath, | |
| 121 | + userShell: "/bin/zsh", | |
| 122 | + logWarning, | |
| 123 | + }); | |
| 124 | + | |
| 125 | + expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [ | |
| 126 | + "PATH", | |
| 127 | + "SSH_AUTH_SOCK", | |
| 128 | + "HOMEBREW_PREFIX", | |
| 129 | + "HOMEBREW_CELLAR", | |
| 130 | + "HOMEBREW_REPOSITORY", | |
| 131 | + "XDG_CONFIG_HOME", | |
| 132 | + "XDG_DATA_HOME", | |
| 133 | + ]); | |
| 134 | + expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [ | |
| 135 | + "PATH", | |
| 136 | + "SSH_AUTH_SOCK", | |
| 137 | + "HOMEBREW_PREFIX", | |
| 138 | + "HOMEBREW_CELLAR", | |
| 139 | + "HOMEBREW_REPOSITORY", | |
| 140 | + "XDG_CONFIG_HOME", | |
| 141 | + "XDG_DATA_HOME", | |
| 142 | + ]); | |
| 143 | + expect(readLaunchctlPath).toHaveBeenCalledTimes(1); | |
| 144 | + expect(logWarning).toHaveBeenCalledWith( | |
| 145 | + "Failed to read login shell environment from /opt/homebrew/bin/nu.", | |
| 146 | + expect.any(Error), | |
| 147 | + ); | |
| 148 | + expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it("does nothing on unsupported platforms", () => { | |
| 86 | 152 | const env: NodeJS.ProcessEnv = { |
| 87 | 153 | SHELL: "C:/Program Files/Git/bin/bash.exe", |
| 88 | 154 | PATH: "C:\\Windows\\System32", |
| @@ -94,7 +160,7 @@ describe("syncShellEnvironment", () => { · 5 unmodified lines | ||
| 94 | 160 | })); |
| 95 | 161 | |
| 96 | 162 | syncShellEnvironment(env, { |
| 97 | − platform: "win32", | |
| 163 | + platform: "freebsd", | |
| 98 | 164 | readEnvironment, |
| 99 | 165 | }); |
| 100 | 166 | |
| @@ -102,4 +168,122 @@ describe("syncShellEnvironment", () => { · 1 unmodified line | ||
| 102 | 168 | expect(env.PATH).toBe("C:\\Windows\\System32"); |
| 103 | 169 | expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); |
| 104 | 170 | }); |
| 171 | + | |
| 172 | + it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { | |
| 173 | + const env: NodeJS.ProcessEnv = { | |
| 174 | + PATH: "C:\\Windows\\System32", | |
| 175 | + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 176 | + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 177 | + USERPROFILE: "C:\\Users\\testuser", | |
| 178 | + }; | |
| 179 | + const readWindowsEnvironment = vi.fn(() => ({ | |
| 180 | + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", | |
| 181 | + })); | |
| 182 | + const isWindowsCommandAvailable = vi.fn(() => true); | |
| 183 | + | |
| 184 | + syncShellEnvironment(env, { | |
| 185 | + platform: "win32", | |
| 186 | + readWindowsEnvironment, | |
| 187 | + isWindowsCommandAvailable, | |
| 188 | + }); | |
| 189 | + | |
| 190 | + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); | |
| 191 | + expect(env.PATH).toBe( | |
| 192 | + [ | |
| 193 | + "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 194 | + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 195 | + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 196 | + "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 197 | + "C:\\Users\\testuser\\.bun\\bin", | |
| 198 | + "C:\\Users\\testuser\\scoop\\shims", | |
| 199 | + "C:\\Custom\\Bin", | |
| 200 | + "C:\\Windows\\System32", | |
| 201 | + ].join(";"), | |
| 202 | + ); | |
| 203 | + expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); | |
| 204 | + }); | |
| 205 | + | |
| 206 | + it("loads the PowerShell profile on Windows when node is not available", () => { | |
| 207 | + const env: NodeJS.ProcessEnv = { | |
| 208 | + PATH: "C:\\Windows\\System32", | |
| 209 | + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 210 | + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", | |
| 211 | + USERPROFILE: "C:\\Users\\testuser", | |
| 212 | + }; | |
| 213 | + const readWindowsEnvironment = vi.fn( | |
| 214 | + (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => | |
| 215 | + options?.loadProfile | |
| 216 | + ? { | |
| 217 | + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", | |
| 218 | + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", | |
| 219 | + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 220 | + } | |
| 221 | + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, | |
| 222 | + ); | |
| 223 | + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); | |
| 224 | + | |
| 225 | + syncShellEnvironment(env, { | |
| 226 | + platform: "win32", | |
| 227 | + readWindowsEnvironment, | |
| 228 | + isWindowsCommandAvailable, | |
| 229 | + }); | |
| 230 | + | |
| 231 | + expect(env.PATH).toBe( | |
| 232 | + [ | |
| 233 | + "C:\\Profile\\Node", | |
| 234 | + "C:\\Windows\\System32", | |
| 235 | + "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 236 | + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", | |
| 237 | + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", | |
| 238 | + "C:\\Users\\testuser\\AppData\\Local\\pnpm", | |
| 239 | + "C:\\Users\\testuser\\.bun\\bin", | |
| 240 | + "C:\\Users\\testuser\\scoop\\shims", | |
| 241 | + "C:\\Custom\\Bin", | |
| 242 | + ].join(";"), | |
| 243 | + ); | |
| 244 | + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); | |
| 245 | + expect(env.FNM_MULTISHELL_PATH).toBe( | |
| 246 | + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", | |
| 247 | + ); | |
| 248 | + expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); | |
| 249 | + expect(readWindowsEnvironment).toHaveBeenNthCalledWith( | |
| 250 | + 2, | |
| 251 | + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], | |
| 252 | + { loadProfile: true }, | |
| 253 | + ); | |
| 254 | + }); | |
| 255 | + | |
| 256 | + it("preserves baseline Windows env when the profile probe fails", () => { | |
| 257 | + const env: NodeJS.ProcessEnv = { | |
| 258 | + PATH: "C:\\Windows\\System32", | |
| 259 | + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", | |
| 260 | + USERPROFILE: "C:\\Users\\testuser", | |
| 261 | + }; | |
| 262 | + const readWindowsEnvironment = vi.fn( | |
| 263 | + (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => { | |
| 264 | + if (options?.loadProfile) { | |
| 265 | + throw new Error("profile load failed"); | |
| 266 | + } | |
| 267 | + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; | |
| 268 | + }, | |
| 269 | + ); | |
| 270 | + const isWindowsCommandAvailable = vi.fn(() => false); | |
| 271 | + | |
| 272 | + syncShellEnvironment(env, { | |
| 273 | + platform: "win32", | |
| 274 | + readWindowsEnvironment, | |
| 275 | + isWindowsCommandAvailable, | |
| 276 | + }); | |
| 277 | + | |
| 278 | + expect(env.PATH).toBe( | |
| 279 | + [ | |
| 280 | + "C:\\Users\\testuser\\AppData\\Roaming\\npm", | |
| 281 | + "C:\\Users\\testuser\\.bun\\bin", | |
| 282 | + "C:\\Users\\testuser\\scoop\\shims", | |
| 283 | + "C:\\Custom\\Bin", | |
| 284 | + "C:\\Windows\\System32", | |
| 285 | + ].join(";"), | |
| 286 | + ); | |
| 287 | + expect(env.SSH_AUTH_SOCK).toBeUndefined(); | |
| 288 | + }); | |
| 105 | 289 | }); |
| @@ -1,36 +1,109 @@ | ||
| 1 | 1 | import { |
| 2 | + listLoginShellCandidates, | |
| 3 | + mergePathEntries, | |
| 4 | + readPathFromLaunchctl, | |
| 2 | 5 | readEnvironmentFromLoginShell, |
| 3 | − resolveLoginShell, | |
| 6 | + resolveWindowsEnvironment, | |
| 7 | +} from "@t3tools/shared/shell"; | |
| 8 | +import type { | |
| 9 | + CommandAvailabilityOptions, | |
| 4 | 10 | ShellEnvironmentReader, |
| 11 | + WindowsShellEnvironmentReader, | |
| 5 | 12 | } from "@t3tools/shared/shell"; |
| 6 | 13 | |
| 14 | +type WindowsCommandAvailabilityChecker = ( | |
| 15 | + command: string, | |
| 16 | + options?: CommandAvailabilityOptions, | |
| 17 | +) => boolean; | |
| 18 | + | |
| 19 | +const LOGIN_SHELL_ENV_NAMES = [ | |
| 20 | + "PATH", | |
| 21 | + "SSH_AUTH_SOCK", | |
| 22 | + "HOMEBREW_PREFIX", | |
| 23 | + "HOMEBREW_CELLAR", | |
| 24 | + "HOMEBREW_REPOSITORY", | |
| 25 | + "XDG_CONFIG_HOME", | |
| 26 | + "XDG_DATA_HOME", | |
| 27 | +] as const; | |
| 28 | + | |
| 29 | +function logShellEnvironmentWarning(message: string, error?: unknown): void { | |
| 30 | + console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? "")); | |
| 31 | +} | |
| 32 | + | |
| 7 | 33 | export function syncShellEnvironment( |
| 8 | 34 | env: NodeJS.ProcessEnv = process.env, |
| 9 | 35 | options: { |
| 10 | 36 | platform?: NodeJS.Platform; |
| 11 | 37 | readEnvironment?: ShellEnvironmentReader; |
| 38 | + readWindowsEnvironment?: WindowsShellEnvironmentReader; | |
| 39 | + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; | |
| 40 | + readLaunchctlPath?: typeof readPathFromLaunchctl; | |
| 41 | + userShell?: string; | |
| 42 | + logWarning?: (message: string, error?: unknown) => void; | |
| 12 | 43 | } = {}, |
| 13 | 44 | ): void { |
| 14 | 45 | const platform = options.platform ?? process.platform; |
| 15 | − if (platform !== "darwin" && platform !== "linux") return; | |
| 46 | + | |
| 47 | + const logWarning = options.logWarning ?? logShellEnvironmentWarning; | |
| 48 | + const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell; | |
| 49 | + const shellEnvironment: Partial<Record<string, string>> = {}; | |
| 16 | 50 | |
| 17 | 51 | try { |
| 18 | − const shell = resolveLoginShell(platform, env.SHELL); | |
| 19 | − if (!shell) return; | |
| 52 | + if (platform === "win32") { | |
| 53 | + const repairedEnvironment = resolveWindowsEnvironment(env, { | |
| 54 | + ...(options.readWindowsEnvironment | |
| 55 | + ? { readEnvironment: options.readWindowsEnvironment } | |
| 56 | + : {}), | |
| 57 | + ...(options.isWindowsCommandAvailable | |
| 58 | + ? { commandAvailable: options.isWindowsCommandAvailable } | |
| 59 | + : {}), | |
| 60 | + }); | |
| 61 | + for (const [key, value] of Object.entries(repairedEnvironment)) { | |
| 62 | + if (value !== undefined) { | |
| 63 | + env[key] = value; | |
| 64 | + } | |
| 65 | + } | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + | |
| 69 | + if (platform !== "darwin" && platform !== "linux") return; | |
| 20 | 70 | |
| 21 | − const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [ | |
| 22 | − "PATH", | |
| 23 | − "SSH_AUTH_SOCK", | |
| 24 | − ]); | |
| 71 | + for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) { | |
| 72 | + try { | |
| 73 | + Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES)); | |
| 74 | + if (shellEnvironment.PATH) { | |
| 75 | + break; | |
| 76 | + } | |
| 77 | + } catch (error) { | |
| 78 | + logWarning(`Failed to read login shell environment from ${shell}.`, error); | |
| 79 | + } | |
| 80 | + } | |
| 25 | 81 | |
| 26 | − if (shellEnvironment.PATH) { | |
| 27 | − env.PATH = shellEnvironment.PATH; | |
| 82 | + const launchctlPath = | |
| 83 | + platform === "darwin" && !shellEnvironment.PATH | |
| 84 | + ? (options.readLaunchctlPath ?? readPathFromLaunchctl)() | |
| 85 | + : undefined; | |
| 86 | + const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform); | |
| 87 | + if (mergedPath) { | |
| 88 | + env.PATH = mergedPath; | |
| 28 | 89 | } |
| 29 | 90 | |
| 30 | 91 | if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) { |
| 31 | 92 | env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK; |
| 32 | 93 | } |
| 33 | − } catch { | |
| 34 | − // Keep inherited environment if shell lookup fails. | |
| 94 | + | |
| 95 | + for (const name of [ | |
| 96 | + "HOMEBREW_PREFIX", | |
| 97 | + "HOMEBREW_CELLAR", | |
| 98 | + "HOMEBREW_REPOSITORY", | |
| 99 | + "XDG_CONFIG_HOME", | |
| 100 | + "XDG_DATA_HOME", | |
| 101 | + ] as const) { | |
| 102 | + if (!env[name] && shellEnvironment[name]) { | |
| 103 | + env[name] = shellEnvironment[name]; | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } catch (error) { | |
| 107 | + logWarning("Failed to synchronize the desktop shell environment.", error); | |
| 35 | 108 | } |
| 36 | 109 | } |
| @@ -0,0 +1,41 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + doesVersionMatchDesktopUpdateChannel, | |
| 5 | + isNightlyDesktopVersion, | |
| 6 | + resolveDefaultDesktopUpdateChannel, | |
| 7 | +} from "./updateChannels.ts"; | |
| 8 | + | |
| 9 | +describe("isNightlyDesktopVersion", () => { | |
| 10 | + it("detects packaged nightly versions", () => { | |
| 11 | + expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("does not flag stable versions as nightly", () => { | |
| 15 | + expect(isNightlyDesktopVersion("0.0.17")).toBe(false); | |
| 16 | + }); | |
| 17 | +}); | |
| 18 | + | |
| 19 | +describe("resolveDefaultDesktopUpdateChannel", () => { | |
| 20 | + it("defaults stable builds to latest", () => { | |
| 21 | + expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest"); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("defaults nightly builds to nightly", () => { | |
| 25 | + expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly"); | |
| 26 | + }); | |
| 27 | +}); | |
| 28 | + | |
| 29 | +describe("doesVersionMatchDesktopUpdateChannel", () => { | |
| 30 | + it("accepts nightly releases on the nightly channel", () => { | |
| 31 | + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("rejects stable releases on the nightly channel", () => { | |
| 35 | + expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it("rejects nightly releases on the stable channel", () => { | |
| 39 | + expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false); | |
| 40 | + }); | |
| 41 | +}); | |
| @@ -0,0 +1,18 @@ | ||
| 1 | +import type { DesktopUpdateChannel } from "@t3tools/contracts"; | |
| 2 | + | |
| 3 | +const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/; | |
| 4 | + | |
| 5 | +export function isNightlyDesktopVersion(version: string): boolean { | |
| 6 | + return NIGHTLY_VERSION_PATTERN.test(version); | |
| 7 | +} | |
| 8 | + | |
| 9 | +export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel { | |
| 10 | + return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest"; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function doesVersionMatchDesktopUpdateChannel( | |
| 14 | + version: string, | |
| 15 | + channel: DesktopUpdateChannel, | |
| 16 | +): boolean { | |
| 17 | + return resolveDefaultDesktopUpdateChannel(version) === channel; | |
| 18 | +} | |
| @@ -11,7 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | reduceDesktopUpdateStateOnInstallFailure, |
| 12 | 12 | reduceDesktopUpdateStateOnNoUpdate, |
| 13 | 13 | reduceDesktopUpdateStateOnUpdateAvailable, |
| 14 | −} from "./updateMachine"; | |
| 14 | +} from "./updateMachine.ts"; | |
| 15 | 15 | |
| 16 | 16 | const runtimeInfo = { |
| 17 | 17 | hostArch: "x64", |
| @@ -23,7 +23,7 @@ describe("updateMachine", () => { · 5 unmodified lines | ||
| 23 | 23 | it("clears transient errors when a check starts", () => { |
| 24 | 24 | const state = reduceDesktopUpdateStateOnCheckStart( |
| 25 | 25 | { |
| 26 | − ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 26 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 27 | 27 | enabled: true, |
| 28 | 28 | status: "error", |
| 29 | 29 | message: "network", |
| @@ -42,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines | ||
| 42 | 42 | it("records a check failure without exposing an action", () => { |
| 43 | 43 | const state = reduceDesktopUpdateStateOnCheckFailure( |
| 44 | 44 | { |
| 45 | − ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 45 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 46 | 46 | enabled: true, |
| 47 | 47 | status: "checking", |
| 48 | 48 | }, |
| @@ -58,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines | ||
| 58 | 58 | it("preserves available version on download failure for retry", () => { |
| 59 | 59 | const state = reduceDesktopUpdateStateOnDownloadFailure( |
| 60 | 60 | { |
| 61 | − ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 61 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 62 | 62 | enabled: true, |
| 63 | 63 | status: "downloading", |
| 64 | 64 | availableVersion: "1.1.0", |
| @@ -76,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines | ||
| 76 | 76 | it("transitions to downloaded and then preserves install retry state", () => { |
| 77 | 77 | const downloaded = reduceDesktopUpdateStateOnDownloadComplete( |
| 78 | 78 | { |
| 79 | − ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 79 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 80 | 80 | enabled: true, |
| 81 | 81 | status: "downloading", |
| 82 | 82 | availableVersion: "1.1.0", |
| @@ -98,7 +98,7 @@ describe("updateMachine", () => { · 15 unmodified lines | ||
| 98 | 98 | it("clears stale download state when no update is available", () => { |
| 99 | 99 | const state = reduceDesktopUpdateStateOnNoUpdate( |
| 100 | 100 | { |
| 101 | − ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 101 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 102 | 102 | enabled: true, |
| 103 | 103 | status: "error", |
| 104 | 104 | availableVersion: "1.1.0", |
| @@ -120,7 +120,7 @@ describe("updateMachine", () => { · 15 unmodified lines | ||
| 120 | 120 | it("tracks available, download start, and progress cleanly", () => { |
| 121 | 121 | const available = reduceDesktopUpdateStateOnUpdateAvailable( |
| 122 | 122 | { |
| 123 | − ...createInitialDesktopUpdateState("1.0.0", runtimeInfo), | |
| 123 | + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), | |
| 124 | 124 | enabled: true, |
| 125 | 125 | status: "checking", |
| 126 | 126 | }, |
| @@ -131,6 +131,7 @@ describe("updateMachine", () => { · 4 unmodified lines | ||
| 131 | 131 | const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); |
| 132 | 132 | |
| 133 | 133 | expect(available.status).toBe("available"); |
| 134 | + expect(available.channel).toBe("latest"); | |
| 134 | 135 | expect(downloading.status).toBe("downloading"); |
| 135 | 136 | expect(downloading.downloadPercent).toBe(0); |
| 136 | 137 | expect(progress.downloadPercent).toBe(55.5); |
| @@ -1,14 +1,20 @@ | ||
| 1 | −import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts"; | |
| 1 | +import type { | |
| 2 | + DesktopRuntimeInfo, | |
| 3 | + DesktopUpdateChannel, | |
| 4 | + DesktopUpdateState, | |
| 5 | +} from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | −import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState"; | |
| 7 | +import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts"; | |
| 4 | 8 | |
| 5 | 9 | export function createInitialDesktopUpdateState( |
| 6 | 10 | currentVersion: string, |
| 7 | 11 | runtimeInfo: DesktopRuntimeInfo, |
| 12 | + channel: DesktopUpdateChannel, | |
| 8 | 13 | ): DesktopUpdateState { |
| 9 | 14 | return { |
| 10 | 15 | enabled: false, |
| 11 | 16 | status: "disabled", |
| 17 | + channel, | |
| 12 | 18 | currentVersion, |
| 13 | 19 | hostArch: runtimeInfo.hostArch, |
| 14 | 20 | appArch: runtimeInfo.appArch, |
| @@ -6,11 +6,12 @@ import { · 5 unmodified lines | ||
| 6 | 6 | getAutoUpdateDisabledReason, |
| 7 | 7 | nextStatusAfterDownloadFailure, |
| 8 | 8 | shouldBroadcastDownloadProgress, |
| 9 | −} from "./updateState"; | |
| 9 | +} from "./updateState.ts"; | |
| 10 | 10 | |
| 11 | 11 | const baseState: DesktopUpdateState = { |
| 12 | 12 | enabled: true, |
| 13 | 13 | status: "idle", |
| 14 | + channel: "latest", | |
| 14 | 15 | currentVersion: "1.0.0", |
| 15 | 16 | hostArch: "x64", |
| 16 | 17 | appArch: "x64", |
| @@ -71,10 +72,37 @@ describe("getAutoUpdateDisabledReason", () => { · 54 unmodified lines | ||
| 71 | 72 | platform: "darwin", |
| 72 | 73 | appImage: undefined, |
| 73 | 74 | disabledByEnv: false, |
| 75 | + hasUpdateFeedConfig: true, | |
| 74 | 76 | }), |
| 75 | 77 | ).toContain("packaged production builds"); |
| 76 | 78 | }); |
| 77 | 79 | |
| 80 | + it("reports packaged local builds without an update feed as disabled", () => { | |
| 81 | + expect( | |
| 82 | + getAutoUpdateDisabledReason({ | |
| 83 | + isDevelopment: false, | |
| 84 | + isPackaged: true, | |
| 85 | + platform: "darwin", | |
| 86 | + appImage: undefined, | |
| 87 | + disabledByEnv: false, | |
| 88 | + hasUpdateFeedConfig: false, | |
| 89 | + }), | |
| 90 | + ).toContain("no update feed"); | |
| 91 | + }); | |
| 92 | + | |
| 93 | + it("allows packaged builds with an update feed", () => { | |
| 94 | + expect( | |
| 95 | + getAutoUpdateDisabledReason({ | |
| 96 | + isDevelopment: false, | |
| 97 | + isPackaged: true, | |
| 98 | + platform: "darwin", | |
| 99 | + appImage: undefined, | |
| 100 | + disabledByEnv: false, | |
| 101 | + hasUpdateFeedConfig: true, | |
| 102 | + }), | |
| 103 | + ).toBeNull(); | |
| 104 | + }); | |
| 105 | + | |
| 78 | 106 | it("reports env-disabled auto updates", () => { |
| 79 | 107 | expect( |
| 80 | 108 | getAutoUpdateDisabledReason({ |
| @@ -83,6 +111,7 @@ describe("getAutoUpdateDisabledReason", () => { · 2 unmodified lines | ||
| 83 | 111 | platform: "darwin", |
| 84 | 112 | appImage: undefined, |
| 85 | 113 | disabledByEnv: true, |
| 114 | + hasUpdateFeedConfig: true, | |
| 86 | 115 | }), |
| 87 | 116 | ).toContain("T3CODE_DISABLE_AUTO_UPDATE"); |
| 88 | 117 | }); |
| @@ -95,6 +124,7 @@ describe("getAutoUpdateDisabledReason", () => { · 6 unmodified lines | ||
| 95 | 124 | platform: "linux", |
| 96 | 125 | appImage: undefined, |
| 97 | 126 | disabledByEnv: false, |
| 127 | + hasUpdateFeedConfig: true, | |
| 98 | 128 | }), |
| 99 | 129 | ).toContain("AppImage"); |
| 100 | 130 | }); |
| @@ -34,7 +34,11 @@ export function getAutoUpdateDisabledReason(args: { · 33 unmodified lines | ||
| 34 | 34 | platform: NodeJS.Platform; |
| 35 | 35 | appImage?: string | undefined; |
| 36 | 36 | disabledByEnv: boolean; |
| 37 | + hasUpdateFeedConfig: boolean; | |
| 37 | 38 | }): string | null { |
| 39 | + if (!args.hasUpdateFeedConfig) { | |
| 40 | + return "Automatic updates are not available because no update feed is configured."; | |
| 41 | + } | |
| 38 | 42 | if (args.isDevelopment || !args.isPackaged) { |
| 39 | 43 | return "Automatic updates are only available in packaged production builds."; |
| 40 | 44 | } |
| @@ -3,7 +3,7 @@ · 2 unmodified lines | ||
| 3 | 3 | "compilerOptions": { |
| 4 | 4 | "composite": true, |
| 5 | 5 | "types": ["node", "electron"], |
| 6 | − "lib": ["ES2023", "DOM", "esnext.disposable"] | |
| 6 | + "lib": ["ESNext", "DOM", "esnext.disposable"] | |
| 7 | 7 | }, |
| 8 | 8 | "include": ["src", "tsdown.config.ts"] |
| 9 | 9 | } |
| @@ -4,7 +4,7 @@ const shared = { · 3 unmodified lines | ||
| 4 | 4 | format: "cjs" as const, |
| 5 | 5 | outDir: "dist-electron", |
| 6 | 6 | sourcemap: true, |
| 7 | − outExtensions: () => ({ js: ".js" }), | |
| 7 | + outExtensions: () => ({ js: ".cjs" }), | |
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | 10 | export default defineConfig([ |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | import Layout from "../layouts/Layout.astro"; |
| 3 | +import { RELEASES_URL } from "../lib/releases"; | |
| 3 | 4 | --- |
| 4 | 5 | |
| 5 | 6 | <Layout title="Download — T3 Code" description="Download T3 Code for macOS, Windows, or Linux."> |
| @@ -17,11 +18,11 @@ import Layout from "../layouts/Layout.astro"; · 11 unmodified lines | ||
| 17 | 18 | <h2 class="platform-name">macOS</h2> |
| 18 | 19 | </div> |
| 19 | 20 | <div class="download-cards"> |
| 20 | − <a class="download-card" data-asset="arm64.dmg" href="#"> | |
| 21 | + <a class="download-card" data-asset="arm64.dmg" href={RELEASES_URL}> | |
| 21 | 22 | <span class="card-arch">Apple Silicon (arm64)</span> |
| 22 | 23 | <span class="card-format">.dmg</span> |
| 23 | 24 | </a> |
| 24 | − <a class="download-card" data-asset="x64.dmg" href="#"> | |
| 25 | + <a class="download-card" data-asset="x64.dmg" href={RELEASES_URL}> | |
| 25 | 26 | <span class="card-arch">Intel (x64)</span> |
| 26 | 27 | <span class="card-format">.dmg</span> |
| 27 | 28 | </a> |
| @@ -35,7 +36,7 @@ import Layout from "../layouts/Layout.astro"; · 7 unmodified lines | ||
| 35 | 36 | <h2 class="platform-name">Windows</h2> |
| 36 | 37 | </div> |
| 37 | 38 | <div class="download-cards"> |
| 38 | − <a class="download-card" data-asset="x64.exe" href="#"> | |
| 39 | + <a class="download-card" data-asset="x64.exe" href={RELEASES_URL}> | |
| 39 | 40 | <span class="card-arch">Windows 10, 11</span> |
| 40 | 41 | <span class="card-format">.exe</span> |
| 41 | 42 | </a> |
| @@ -49,7 +50,7 @@ import Layout from "../layouts/Layout.astro"; · 7 unmodified lines | ||
| 49 | 50 | <h2 class="platform-name">Linux</h2> |
| 50 | 51 | </div> |
| 51 | 52 | <div class="download-cards"> |
| 52 | − <a class="download-card" data-asset="x86_64.AppImage" href="#"> | |
| 53 | + <a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}> | |
| 53 | 54 | <span class="card-arch">x86_64</span> |
| 54 | 55 | <span class="card-format">AppImage</span> |
| 55 | 56 | </a> |
| @@ -26,6 +26,7 @@ import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore · 25 unmodified lines | ||
| 26 | 26 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; |
| 27 | 27 | import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; |
| 28 | 28 | import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; |
| 29 | +import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; | |
| 29 | 30 | import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; |
| 30 | 31 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 31 | 32 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; |
| @@ -45,10 +46,11 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; · 13 unmodified lines | ||
| 45 | 46 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 46 | 47 | import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; |
| 47 | 48 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| 49 | +import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; | |
| 48 | 50 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 49 | 51 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 50 | 52 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| 51 | −import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; | |
| 53 | +import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; | |
| 52 | 54 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; |
| 53 | 55 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; |
| 54 | 56 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; |
| @@ -297,7 +299,7 @@ export const makeOrchestrationIntegrationHarness = ( · 242 unmodified lines | ||
| 297 | 299 | ProjectionPendingApprovalRepositoryLive, |
| 298 | 300 | checkpointStoreLayer, |
| 299 | 301 | providerLayer, |
| 300 | − RuntimeReceiptBusLive, | |
| 302 | + RuntimeReceiptBusTest, | |
| 301 | 303 | ); |
| 302 | 304 | const serverSettingsLayer = ServerSettingsService.layerTest(); |
| 303 | 305 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| @@ -320,6 +322,22 @@ export const makeOrchestrationIntegrationHarness = ( · 16 unmodified lines | ||
| 320 | 322 | ); |
| 321 | 323 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 322 | 324 | Layer.provideMerge(runtimeServicesLayer), |
| 325 | + Layer.provideMerge( | |
| 326 | + Layer.succeed(GitStatusBroadcaster, { | |
| 327 | + getStatus: () => Effect.die("getStatus should not be called in this test"), | |
| 328 | + refreshLocalStatus: () => | |
| 329 | + Effect.succeed({ | |
| 330 | + isRepo: true, | |
| 331 | + hasOriginRemote: false, | |
| 332 | + isDefaultBranch: true, | |
| 333 | + branch: "main", | |
| 334 | + hasWorkingTreeChanges: false, | |
| 335 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 336 | + }), | |
| 337 | + refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), | |
| 338 | + streamStatus: () => Stream.empty, | |
| 339 | + }), | |
| 340 | + ), | |
| 323 | 341 | Layer.provideMerge( |
| 324 | 342 | WorkspaceEntriesLive.pipe( |
| 325 | 343 | Layer.provide(WorkspacePathsLive), |
| @@ -338,6 +356,7 @@ export const makeOrchestrationIntegrationHarness = ( · 12 unmodified lines | ||
| 338 | 356 | Layer.provideMerge(runtimeServicesLayer), |
| 339 | 357 | Layer.provideMerge(orchestrationReactorLayer), |
| 340 | 358 | Layer.provide(persistenceLayer), |
| 359 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 341 | 360 | Layer.provideMerge(ServerSettingsService.layerTest()), |
| 342 | 361 | Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), |
| 343 | 362 | Layer.provideMerge(NodeServices.layer), |
| @@ -376,7 +395,7 @@ export const makeOrchestrationIntegrationHarness = ( · 32 unmodified lines | ||
| 376 | 395 | runtime.runPromise(reactor.start().pipe(Scope.provide(scope))), |
| 377 | 396 | ).pipe(Effect.orDie); |
| 378 | 397 | const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]); |
| 379 | − yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => | |
| 398 | + yield* Stream.runForEach(runtimeReceiptBus.streamEventsForTest, (receipt) => | |
| 380 | 399 | Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), |
| 381 | 400 | ).pipe(Effect.forkIn(scope)); |
| 382 | 401 | yield* Effect.sleep(10); |
| @@ -419,7 +438,7 @@ export const makeOrchestrationIntegrationHarness = ( · 36 unmodified lines | ||
| 419 | 438 | ) => |
| 420 | 439 | waitFor( |
| 421 | 440 | pendingApprovalRepository |
| 422 | − .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) | |
| 441 | + .getByRequestId({ requestId: ApprovalRequestId.make(requestId) }) | |
| 423 | 442 | .pipe( |
| 424 | 443 | Effect.map((row) => |
| 425 | 444 | Option.match(row, { |
| @@ -289,7 +289,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 288 unmodified lines | ||
| 289 | 289 | |
| 290 | 290 | state.turnCount += 1; |
| 291 | 291 | const turnCount = state.turnCount; |
| 292 | − const turnId = TurnId.makeUnsafe(`turn-${turnCount}`); | |
| 292 | + const turnId = TurnId.make(`turn-${turnCount}`); | |
| 293 | 293 | |
| 294 | 294 | const response = state.queuedResponses.shift(); |
| 295 | 295 | if (!response) { |
| @@ -307,7 +307,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 11 unmodified lines | ||
| 307 | 307 | ...(fixtureEvent as Record<string, unknown>), |
| 308 | 308 | eventId: randomUUID(), |
| 309 | 309 | provider, |
| 310 | − sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)), | |
| 310 | + sessionId: RuntimeSessionId.make(String(input.threadId)), | |
| 311 | 311 | createdAt: nowIso(), |
| 312 | 312 | }; |
| 313 | 313 | rawEvent.threadId = state.snapshot.threadId; |
| @@ -363,7 +363,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 49 unmodified lines | ||
| 363 | 363 | if (deferredTurnCompletedEvents.length === 0) { |
| 364 | 364 | yield* emit({ |
| 365 | 365 | type: "turn.completed", |
| 366 | − eventId: EventId.makeUnsafe(randomUUID()), | |
| 366 | + eventId: EventId.make(randomUUID()), | |
| 367 | 367 | provider, |
| 368 | 368 | createdAt: nowIso(), |
| 369 | 369 | threadId: state.snapshot.threadId, |
| @@ -5,14 +5,14 @@ const PROVIDER = "codex" as const; · 4 unmodified lines | ||
| 5 | 5 | const SESSION_ID = "fixture-session"; |
| 6 | 6 | const THREAD_ID = "fixture-thread"; |
| 7 | 7 | const TURN_ID = "fixture-turn"; |
| 8 | −const REQUEST_ID = RuntimeRequestId.makeUnsafe("req-1"); | |
| 8 | +const REQUEST_ID = RuntimeRequestId.make("req-1"); | |
| 9 | 9 | |
| 10 | 10 | function baseEvent( |
| 11 | 11 | eventId: string, |
| 12 | 12 | createdAt: string, |
| 13 | 13 | ): Pick<LegacyProviderRuntimeEvent, "eventId" | "provider" | "sessionId" | "createdAt"> { |
| 14 | 14 | return { |
| 15 | − eventId: EventId.makeUnsafe(eventId), | |
| 15 | + eventId: EventId.make(eventId), | |
| 16 | 16 | provider: PROVIDER, |
| 17 | 17 | sessionId: SESSION_ID, |
| 18 | 18 | createdAt, |
| @@ -30,14 +30,13 @@ import type { · 29 unmodified lines | ||
| 30 | 30 | } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; |
| 31 | 31 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 32 | 32 | |
| 33 | −const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 34 | −const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 35 | −const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 36 | −const asApprovalRequestId = (value: string): ApprovalRequestId => | |
| 37 | − ApprovalRequestId.makeUnsafe(value); | |
| 33 | +const asMessageId = (value: string): MessageId => MessageId.make(value); | |
| 34 | +const asProjectId = (value: string): ProjectId => ProjectId.make(value); | |
| 35 | +const asEventId = (value: string): EventId => EventId.make(value); | |
| 36 | +const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); | |
| 38 | 37 | |
| 39 | 38 | const PROJECT_ID = asProjectId("project-1"); |
| 40 | −const THREAD_ID = ThreadId.makeUnsafe("thread-1"); | |
| 39 | +const THREAD_ID = ThreadId.make("thread-1"); | |
| 41 | 40 | const FIXTURE_TURN_ID = "fixture-turn"; |
| 42 | 41 | const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1"); |
| 43 | 42 | type IntegrationProvider = ProviderKind; |
| @@ -112,7 +111,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => · 68 unmodified lines | ||
| 112 | 111 | |
| 113 | 112 | yield* harness.engine.dispatch({ |
| 114 | 113 | type: "project.create", |
| 115 | − commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 114 | + commandId: CommandId.make("cmd-project-create"), | |
| 116 | 115 | projectId: PROJECT_ID, |
| 117 | 116 | title: "Integration Project", |
| 118 | 117 | workspaceRoot: harness.workspaceDir, |
| @@ -125,7 +124,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => · 6 unmodified lines | ||
| 125 | 124 | |
| 126 | 125 | yield* harness.engine.dispatch({ |
| 127 | 126 | type: "thread.create", |
| 128 | − commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 127 | + commandId: CommandId.make("cmd-thread-create"), | |
| 129 | 128 | threadId: THREAD_ID, |
| 130 | 129 | projectId: PROJECT_ID, |
| 131 | 130 | title: "Integration Thread", |
| @@ -150,7 +149,7 @@ const startTurn = (input: { · 18 unmodified lines | ||
| 150 | 149 | }) => |
| 151 | 150 | input.harness.engine.dispatch({ |
| 152 | 151 | type: "thread.turn.start", |
| 153 | − commandId: CommandId.makeUnsafe(input.commandId), | |
| 152 | + commandId: CommandId.make(input.commandId), | |
| 154 | 153 | threadId: THREAD_ID, |
| 155 | 154 | message: { |
| 156 | 155 | messageId: asMessageId(input.messageId), |
| @@ -261,7 +260,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 104 unmodified lines | ||
| 261 | 260 | |
| 262 | 261 | yield* harness.engine.dispatch({ |
| 263 | 262 | type: "project.create", |
| 264 | − commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"), | |
| 263 | + commandId: CommandId.make("cmd-project-create-real-codex"), | |
| 265 | 264 | projectId: PROJECT_ID, |
| 266 | 265 | title: "Integration Project", |
| 267 | 266 | workspaceRoot: harness.workspaceDir, |
| @@ -274,7 +273,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 6 unmodified lines | ||
| 274 | 273 | |
| 275 | 274 | yield* harness.engine.dispatch({ |
| 276 | 275 | type: "thread.create", |
| 277 | − commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"), | |
| 276 | + commandId: CommandId.make("cmd-thread-create-real-codex"), | |
| 278 | 277 | threadId: THREAD_ID, |
| 279 | 278 | projectId: PROJECT_ID, |
| 280 | 279 | title: "Integration Thread", |
| @@ -291,7 +290,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 10 unmodified lines | ||
| 291 | 290 | |
| 292 | 291 | yield* harness.engine.dispatch({ |
| 293 | 292 | type: "thread.turn.start", |
| 294 | − commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"), | |
| 293 | + commandId: CommandId.make("cmd-turn-start-real-codex-1"), | |
| 295 | 294 | threadId: THREAD_ID, |
| 296 | 295 | message: { |
| 297 | 296 | messageId: asMessageId("msg-real-codex-1"), |
| @@ -318,7 +317,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 20 unmodified lines | ||
| 318 | 317 | |
| 319 | 318 | yield* harness.engine.dispatch({ |
| 320 | 319 | type: "thread.turn.start", |
| 321 | − commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"), | |
| 320 | + commandId: CommandId.make("cmd-turn-start-real-codex-2"), | |
| 322 | 321 | threadId: THREAD_ID, |
| 323 | 322 | message: { |
| 324 | 323 | messageId: asMessageId("msg-real-codex-2"), |
| @@ -583,7 +582,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons · 258 unmodified lines | ||
| 583 | 582 | |
| 584 | 583 | yield* harness.engine.dispatch({ |
| 585 | 584 | type: "thread.approval.respond", |
| 586 | − commandId: CommandId.makeUnsafe("cmd-approval-respond"), | |
| 585 | + commandId: CommandId.make("cmd-approval-respond"), | |
| 587 | 586 | threadId: THREAD_ID, |
| 588 | 587 | requestId: APPROVAL_REQUEST_ID, |
| 589 | 588 | decision: "accept", |
| @@ -815,7 +814,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 225 unmodified lines | ||
| 815 | 814 | |
| 816 | 815 | yield* harness.engine.dispatch({ |
| 817 | 816 | type: "thread.checkpoint.revert", |
| 818 | − commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"), | |
| 817 | + commandId: CommandId.make("cmd-checkpoint-revert"), | |
| 819 | 818 | threadId: THREAD_ID, |
| 820 | 819 | turnCount: 1, |
| 821 | 820 | createdAt: nowIso(), |
| @@ -875,7 +874,7 @@ it.live( · 53 unmodified lines | ||
| 875 | 874 | |
| 876 | 875 | yield* harness.engine.dispatch({ |
| 877 | 876 | type: "thread.checkpoint.revert", |
| 878 | − commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"), | |
| 877 | + commandId: CommandId.make("cmd-checkpoint-revert-no-session"), | |
| 879 | 878 | threadId: THREAD_ID, |
| 880 | 879 | turnCount: 0, |
| 881 | 880 | createdAt: nowIso(), |
| @@ -1119,7 +1118,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => · 237 unmodified lines | ||
| 1119 | 1118 | |
| 1120 | 1119 | yield* harness.engine.dispatch({ |
| 1121 | 1120 | type: "thread.approval.respond", |
| 1122 | − commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"), | |
| 1121 | + commandId: CommandId.make("cmd-claude-approval-respond"), | |
| 1123 | 1122 | threadId: THREAD_ID, |
| 1124 | 1123 | requestId: APPROVAL_REQUEST_ID, |
| 1125 | 1124 | decision: "accept", |
| @@ -1192,7 +1191,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => · 66 unmodified lines | ||
| 1192 | 1191 | |
| 1193 | 1192 | yield* harness.engine.dispatch({ |
| 1194 | 1193 | type: "thread.turn.interrupt", |
| 1195 | − commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"), | |
| 1194 | + commandId: CommandId.make("cmd-turn-interrupt-claude"), | |
| 1196 | 1195 | threadId: THREAD_ID, |
| 1197 | 1196 | createdAt: nowIso(), |
| 1198 | 1197 | }); |
| @@ -1309,7 +1308,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", · 110 unmodified lines | ||
| 1309 | 1308 | |
| 1310 | 1309 | yield* harness.engine.dispatch({ |
| 1311 | 1310 | type: "thread.checkpoint.revert", |
| 1312 | − commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"), | |
| 1311 | + commandId: CommandId.make("cmd-checkpoint-revert-claude"), | |
| 1313 | 1312 | threadId: THREAD_ID, |
| 1314 | 1313 | turnCount: 1, |
| 1315 | 1314 | createdAt: nowIso(), |
| @@ -86,6 +86,7 @@ const collectEventsDuring = <A, E, R>( · 85 unmodified lines | ||
| 86 | 86 | Effect.forkScoped, |
| 87 | 87 | ); |
| 88 | 88 | |
| 89 | + yield* Effect.sleep("50 millis"); | |
| 89 | 90 | yield* action; |
| 90 | 91 | |
| 91 | 92 | return yield* Effect.forEach( |
| @@ -104,7 +105,6 @@ const runTurn = (input: { · 12 unmodified lines | ||
| 104 | 105 | }) => |
| 105 | 106 | Effect.gen(function* () { |
| 106 | 107 | yield* input.harness.queueTurnResponse(input.threadId, input.response); |
| 107 | − | |
| 108 | 108 | return yield* collectEventsDuring( |
| 109 | 109 | input.provider.streamEvents, |
| 110 | 110 | input.response.events.length, |
| @@ -116,21 +116,18 @@ const runTurn = (input: { · 5 unmodified lines | ||
| 116 | 116 | ); |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | −it.effect("replays typed runtime fixture events", () => | |
| 119 | +it.live("replays typed runtime fixture events", () => | |
| 120 | 120 | Effect.gen(function* () { |
| 121 | 121 | const fixture = yield* makeIntegrationFixture; |
| 122 | 122 | |
| 123 | 123 | yield* Effect.gen(function* () { |
| 124 | 124 | const provider = yield* ProviderService; |
| 125 | − const session = yield* provider.startSession( | |
| 126 | − ThreadId.makeUnsafe("thread-integration-typed"), | |
| 127 | − { | |
| 128 | − threadId: ThreadId.makeUnsafe("thread-integration-typed"), | |
| 129 | − provider: "codex", | |
| 130 | − cwd: fixture.cwd, | |
| 131 | − runtimeMode: "full-access", | |
| 132 | − }, | |
| 133 | − ); | |
| 125 | + const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), { | |
| 126 | + threadId: ThreadId.make("thread-integration-typed"), | |
| 127 | + provider: "codex", | |
| 128 | + cwd: fixture.cwd, | |
| 129 | + runtimeMode: "full-access", | |
| 130 | + }); | |
| 134 | 131 | assert.equal((session.threadId ?? "").length > 0, true); |
| 135 | 132 | |
| 136 | 133 | const observedEvents = yield* runTurn({ |
| @@ -149,7 +146,7 @@ it.effect("replays typed runtime fixture events", () => · 12 unmodified lines | ||
| 149 | 146 | }).pipe(Effect.provide(NodeServices.layer)), |
| 150 | 147 | ); |
| 151 | 148 | |
| 152 | −it.effect("replays file-changing fixture turn events", () => | |
| 149 | +it.live("replays file-changing fixture turn events", () => | |
| 153 | 150 | Effect.gen(function* () { |
| 154 | 151 | const fixture = yield* makeIntegrationFixture; |
| 155 | 152 | const { join } = yield* Path.Path; |
| @@ -157,15 +154,12 @@ it.effect("replays file-changing fixture turn events", () => · 1 unmodified line | ||
| 157 | 154 | |
| 158 | 155 | yield* Effect.gen(function* () { |
| 159 | 156 | const provider = yield* ProviderService; |
| 160 | − const session = yield* provider.startSession( | |
| 161 | − ThreadId.makeUnsafe("thread-integration-tools"), | |
| 162 | − { | |
| 163 | − threadId: ThreadId.makeUnsafe("thread-integration-tools"), | |
| 164 | − provider: "codex", | |
| 165 | − cwd: fixture.cwd, | |
| 166 | − runtimeMode: "full-access", | |
| 167 | − }, | |
| 168 | − ); | |
| 157 | + const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), { | |
| 158 | + threadId: ThreadId.make("thread-integration-tools"), | |
| 159 | + provider: "codex", | |
| 160 | + cwd: fixture.cwd, | |
| 161 | + runtimeMode: "full-access", | |
| 162 | + }); | |
| 169 | 163 | assert.equal((session.threadId ?? "").length > 0, true); |
| 170 | 164 | |
| 171 | 165 | const observedEvents = yield* runTurn({ |
| @@ -188,7 +182,7 @@ it.effect("replays file-changing fixture turn events", () => · 16 unmodified lines | ||
| 188 | 182 | }).pipe(Effect.provide(NodeServices.layer)), |
| 189 | 183 | ); |
| 190 | 184 | |
| 191 | −it.effect("runs multi-turn tool/approval flow", () => | |
| 185 | +it.live("runs multi-turn tool/approval flow", () => | |
| 192 | 186 | Effect.gen(function* () { |
| 193 | 187 | const fixture = yield* makeIntegrationFixture; |
| 194 | 188 | const { join } = yield* Path.Path; |
| @@ -196,15 +190,12 @@ it.effect("runs multi-turn tool/approval flow", () => · 1 unmodified line | ||
| 196 | 190 | |
| 197 | 191 | yield* Effect.gen(function* () { |
| 198 | 192 | const provider = yield* ProviderService; |
| 199 | − const session = yield* provider.startSession( | |
| 200 | − ThreadId.makeUnsafe("thread-integration-multi"), | |
| 201 | − { | |
| 202 | − threadId: ThreadId.makeUnsafe("thread-integration-multi"), | |
| 203 | − provider: "codex", | |
| 204 | − cwd: fixture.cwd, | |
| 205 | − runtimeMode: "full-access", | |
| 206 | − }, | |
| 207 | − ); | |
| 193 | + const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), { | |
| 194 | + threadId: ThreadId.make("thread-integration-multi"), | |
| 195 | + provider: "codex", | |
| 196 | + cwd: fixture.cwd, | |
| 197 | + runtimeMode: "full-access", | |
| 198 | + }); | |
| 208 | 199 | assert.equal((session.threadId ?? "").length > 0, true); |
| 209 | 200 | |
| 210 | 201 | const firstTurnEvents = yield* runTurn({ |
| @@ -242,7 +233,7 @@ it.effect("runs multi-turn tool/approval flow", () => · 31 unmodified lines | ||
| 242 | 233 | }).pipe(Effect.provide(NodeServices.layer)), |
| 243 | 234 | ); |
| 244 | 235 | |
| 245 | −it.effect("rolls back provider conversation state only", () => | |
| 236 | +it.live("rolls back provider conversation state only", () => | |
| 246 | 237 | Effect.gen(function* () { |
| 247 | 238 | const fixture = yield* makeIntegrationFixture; |
| 248 | 239 | const { join } = yield* Path.Path; |
| @@ -250,15 +241,12 @@ it.effect("rolls back provider conversation state only", () => · 1 unmodified line | ||
| 250 | 241 | |
| 251 | 242 | yield* Effect.gen(function* () { |
| 252 | 243 | const provider = yield* ProviderService; |
| 253 | − const session = yield* provider.startSession( | |
| 254 | − ThreadId.makeUnsafe("thread-integration-rollback"), | |
| 255 | − { | |
| 256 | − threadId: ThreadId.makeUnsafe("thread-integration-rollback"), | |
| 257 | − provider: "codex", | |
| 258 | − cwd: fixture.cwd, | |
| 259 | − runtimeMode: "full-access", | |
| 260 | − }, | |
| 261 | − ); | |
| 244 | + const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), { | |
| 245 | + threadId: ThreadId.make("thread-integration-rollback"), | |
| 246 | + provider: "codex", | |
| 247 | + cwd: fixture.cwd, | |
| 248 | + runtimeMode: "full-access", | |
| 249 | + }); | |
| 262 | 250 | assert.equal((session.threadId ?? "").length > 0, true); |
| 263 | 251 | |
| 264 | 252 | yield* runTurn({ |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "t3", |
| 3 | − "version": "0.0.15", | |
| 3 | + "version": "0.0.20", | |
| 4 | 4 | "license": "MIT", |
| 5 | 5 | "repository": { |
| 6 | 6 | "type": "git", |
| @@ -15,15 +15,17 @@ · 8 unmodified lines | ||
| 15 | 15 | ], |
| 16 | 16 | "type": "module", |
| 17 | 17 | "scripts": { |
| 18 | − "dev": "bun run src/bin.ts", | |
| 18 | + "dev": "node --watch src/bin.ts", | |
| 19 | 19 | "build": "node scripts/cli.ts build", |
| 20 | + "build:bundle": "tsdown", | |
| 20 | 21 | "start": "node dist/bin.mjs", |
| 21 | 22 | "prepare": "effect-language-service patch", |
| 22 | 23 | "typecheck": "tsc --noEmit", |
| 23 | − "test": "vitest run" | |
| 24 | + "test": "vitest run", | |
| 25 | + "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts" | |
| 24 | 26 | }, |
| 25 | 27 | "dependencies": { |
| 26 | − "@anthropic-ai/claude-agent-sdk": "^0.2.77", | |
| 28 | + "@anthropic-ai/claude-agent-sdk": "^0.2.111", | |
| 27 | 29 | "@effect/platform-bun": "catalog:", |
| 28 | 30 | "@effect/platform-node": "catalog:", |
| 29 | 31 | "@effect/sql-sqlite-bun": "catalog:", |
| @@ -14,6 +14,22 @@ import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog · 13 unmodified lines | ||
| 14 | 14 | import rootPackageJson from "../../../package.json" with { type: "json" }; |
| 15 | 15 | import serverPackageJson from "../package.json" with { type: "json" }; |
| 16 | 16 | |
| 17 | +interface PackageJson { | |
| 18 | + name: string; | |
| 19 | + repository: { | |
| 20 | + type: string; | |
| 21 | + url: string; | |
| 22 | + directory: string; | |
| 23 | + }; | |
| 24 | + bin: Record<string, string>; | |
| 25 | + type: string; | |
| 26 | + version: string; | |
| 27 | + engines: Record<string, string>; | |
| 28 | + files: string[]; | |
| 29 | + dependencies: Record<string, string>; | |
| 30 | + overrides: Record<string, string>; | |
| 31 | +} | |
| 32 | + | |
| 17 | 33 | class CliError extends Data.TaggedError("CliError")<{ |
| 18 | 34 | readonly message: string; |
| 19 | 35 | readonly cause?: unknown; |
| @@ -131,13 +147,13 @@ const buildCmd = Command.make( · 111 unmodified lines | ||
| 131 | 147 | |
| 132 | 148 | yield* Effect.log("[cli] Running tsdown..."); |
| 133 | 149 | yield* runCommand( |
| 134 | − ChildProcess.make({ | |
| 150 | + ChildProcess.make(process.execPath, ["--run", "build:bundle"], { | |
| 135 | 151 | cwd: serverDir, |
| 136 | 152 | stdout: config.verbose ? "inherit" : "ignore", |
| 137 | 153 | stderr: "inherit", |
| 138 | − // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd). | |
| 154 | + // Windows needs shell mode to resolve `.cmd` shims on PATH. | |
| 139 | 155 | shell: process.platform === "win32", |
| 140 | − })`bun tsdown`, | |
| 156 | + }), | |
| 141 | 157 | ); |
| 142 | 158 | |
| 143 | 159 | const webDist = path.join(repoRoot, "apps/web/dist"); |
| @@ -177,7 +193,7 @@ const publishCmd = Command.make( · 33 unmodified lines | ||
| 177 | 193 | const backupPath = `${packageJsonPath}.bak`; |
| 178 | 194 | |
| 179 | 195 | // Assert build assets exist |
| 180 | − for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) { | |
| 196 | + for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { | |
| 181 | 197 | const abs = path.join(serverDir, relPath); |
| 182 | 198 | if (!(yield* fs.exists(abs))) { |
| 183 | 199 | return yield* new CliError({ |
| @@ -187,12 +203,10 @@ const publishCmd = Command.make( · 3 unmodified lines | ||
| 187 | 203 | } |
| 188 | 204 | |
| 189 | 205 | yield* Effect.acquireUseRelease( |
| 190 | − // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts | |
| 206 | + // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts | |
| 191 | 207 | Effect.gen(function* () { |
| 192 | − // Resolve catalog dependencies before any file mutations. If this throws, | |
| 193 | − // acquire fails and no release hook runs, so filesystem must still be untouched. | |
| 194 | 208 | const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); |
| 195 | − const pkg = { | |
| 209 | + const pkg: PackageJson = { | |
| 196 | 210 | name: serverPackageJson.name, |
| 197 | 211 | repository: serverPackageJson.repository, |
| 198 | 212 | bin: serverPackageJson.bin, |
| @@ -200,19 +214,22 @@ const publishCmd = Command.make( · 1 unmodified line | ||
| 200 | 214 | version, |
| 201 | 215 | engines: serverPackageJson.engines, |
| 202 | 216 | files: serverPackageJson.files, |
| 203 | − dependencies: serverPackageJson.dependencies as Record<string, unknown>, | |
| 217 | + dependencies: resolveCatalogDependencies( | |
| 218 | + serverPackageJson.dependencies, | |
| 219 | + rootPackageJson.workspaces.catalog, | |
| 220 | + "apps/server", | |
| 221 | + ), | |
| 222 | + overrides: resolveCatalogDependencies( | |
| 223 | + rootPackageJson.overrides, | |
| 224 | + rootPackageJson.workspaces.catalog, | |
| 225 | + "apps/server", | |
| 226 | + ), | |
| 204 | 227 | }; |
| 205 | 228 | |
| 206 | − pkg.dependencies = resolveCatalogDependencies( | |
| 207 | − pkg.dependencies, | |
| 208 | − rootPackageJson.workspaces.catalog, | |
| 209 | − "apps/server dependencies", | |
| 210 | − ); | |
| 211 | − | |
| 212 | 229 | const original = yield* fs.readFileString(packageJsonPath); |
| 213 | 230 | yield* fs.writeFileString(backupPath, original); |
| 214 | 231 | yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); |
| 215 | − yield* Effect.log("[cli] Resolved package.json for publish"); | |
| 232 | + yield* Effect.log("[cli] Prepared package.json for publish"); | |
| 216 | 233 | |
| 217 | 234 | const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); |
| 218 | 235 | return { iconBackups }; |
| @@ -0,0 +1,111 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Effect, Layer } from "effect"; | |
| 4 | + | |
| 5 | +import type { ServerConfigShape } from "../../config.ts"; | |
| 6 | +import { ServerConfig } from "../../config.ts"; | |
| 7 | +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 8 | +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 9 | +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 10 | +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 11 | +import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; | |
| 12 | +import { makeAuthControlPlane } from "./AuthControlPlane.ts"; | |
| 13 | +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 14 | + | |
| 15 | +const makeServerConfigLayer = ( | |
| 16 | + overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 17 | +) => | |
| 18 | + Layer.effect( | |
| 19 | + ServerConfig, | |
| 20 | + Effect.gen(function* () { | |
| 21 | + const config = yield* ServerConfig; | |
| 22 | + return { | |
| 23 | + ...config, | |
| 24 | + ...overrides, | |
| 25 | + } satisfies ServerConfigShape; | |
| 26 | + }), | |
| 27 | + ).pipe( | |
| 28 | + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), | |
| 29 | + ); | |
| 30 | + | |
| 31 | +const makeAuthControlPlaneLayer = ( | |
| 32 | + overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 33 | +) => | |
| 34 | + Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe( | |
| 35 | + Layer.provideMerge(BootstrapCredentialServiceLive), | |
| 36 | + Layer.provideMerge(SessionCredentialServiceLive), | |
| 37 | + Layer.provideMerge(ServerSecretStoreLive), | |
| 38 | + Layer.provideMerge(SqlitePersistenceMemory), | |
| 39 | + Layer.provide(makeServerConfigLayer(overrides)), | |
| 40 | + ); | |
| 41 | + | |
| 42 | +it.layer(NodeServices.layer)("AuthControlPlane", (it) => { | |
| 43 | + it.effect("creates, lists, and revokes client pairing links", () => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const authControlPlane = yield* AuthControlPlane; | |
| 46 | + | |
| 47 | + const created = yield* authControlPlane.createPairingLink({ | |
| 48 | + role: "client", | |
| 49 | + subject: "one-time-token", | |
| 50 | + label: "CI phone", | |
| 51 | + }); | |
| 52 | + const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 53 | + const revoked = yield* authControlPlane.revokePairingLink(created.id); | |
| 54 | + const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 55 | + | |
| 56 | + expect(created.role).toBe("client"); | |
| 57 | + expect(created.credential.length).toBeGreaterThan(0); | |
| 58 | + expect(listedBeforeRevoke).toHaveLength(1); | |
| 59 | + expect(listedBeforeRevoke[0]?.id).toBe(created.id); | |
| 60 | + expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); | |
| 61 | + expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); | |
| 62 | + expect(revoked).toBe(true); | |
| 63 | + expect(listedAfterRevoke).toHaveLength(0); | |
| 64 | + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 65 | + ); | |
| 66 | + | |
| 67 | + it.effect("issues bearer sessions and lists them without exposing raw tokens", () => | |
| 68 | + Effect.gen(function* () { | |
| 69 | + const authControlPlane = yield* AuthControlPlane; | |
| 70 | + const sessionCredentials = yield* SessionCredentialService; | |
| 71 | + | |
| 72 | + const issued = yield* authControlPlane.issueSession({ | |
| 73 | + label: "deploy-bot", | |
| 74 | + }); | |
| 75 | + const verified = yield* sessionCredentials.verify(issued.token); | |
| 76 | + const listedBeforeRevoke = yield* authControlPlane.listSessions(); | |
| 77 | + const revoked = yield* authControlPlane.revokeSession(issued.sessionId); | |
| 78 | + const listedAfterRevoke = yield* authControlPlane.listSessions(); | |
| 79 | + | |
| 80 | + expect(issued.method).toBe("bearer-session-token"); | |
| 81 | + expect(issued.role).toBe("owner"); | |
| 82 | + expect(issued.client.deviceType).toBe("bot"); | |
| 83 | + expect(issued.client.label).toBe("deploy-bot"); | |
| 84 | + expect(verified.sessionId).toBe(issued.sessionId); | |
| 85 | + expect(verified.role).toBe("owner"); | |
| 86 | + expect(verified.method).toBe("bearer-session-token"); | |
| 87 | + expect(listedBeforeRevoke).toHaveLength(1); | |
| 88 | + expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); | |
| 89 | + expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false); | |
| 90 | + expect(revoked).toBe(true); | |
| 91 | + expect(listedAfterRevoke).toHaveLength(0); | |
| 92 | + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 93 | + ); | |
| 94 | + | |
| 95 | + it.effect("surfaces lastConnectedAt through the listed session view", () => | |
| 96 | + Effect.gen(function* () { | |
| 97 | + const authControlPlane = yield* AuthControlPlane; | |
| 98 | + const sessionCredentials = yield* SessionCredentialService; | |
| 99 | + | |
| 100 | + const issued = yield* authControlPlane.issueSession({ | |
| 101 | + label: "remote-ipad", | |
| 102 | + }); | |
| 103 | + const beforeConnect = yield* authControlPlane.listSessions(); | |
| 104 | + yield* sessionCredentials.markConnected(issued.sessionId); | |
| 105 | + const afterConnect = yield* authControlPlane.listSessions(); | |
| 106 | + | |
| 107 | + expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 108 | + expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 109 | + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), | |
| 110 | + ); | |
| 111 | +}); | |
| @@ -0,0 +1,178 @@ | ||
| 1 | +import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | +import { DateTime, Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 5 | +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 6 | +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 7 | +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 8 | +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 9 | +import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts"; | |
| 10 | +import { | |
| 11 | + AuthControlPlane, | |
| 12 | + AuthControlPlaneError, | |
| 13 | + DEFAULT_SESSION_SUBJECT, | |
| 14 | +} from "../Services/AuthControlPlane.ts"; | |
| 15 | +import type { | |
| 16 | + AuthControlPlaneShape, | |
| 17 | + IssuedBearerSession, | |
| 18 | + IssuedPairingLink, | |
| 19 | +} from "../Services/AuthControlPlane.ts"; | |
| 20 | + | |
| 21 | +const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { | |
| 22 | + if (left.role !== right.role) { | |
| 23 | + return left.role === "owner" ? -1 : 1; | |
| 24 | + } | |
| 25 | + if (left.connected !== right.connected) { | |
| 26 | + return left.connected ? -1 : 1; | |
| 27 | + } | |
| 28 | + return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; | |
| 29 | +}; | |
| 30 | + | |
| 31 | +const toAuthControlPlaneError = | |
| 32 | + (message: string) => | |
| 33 | + (cause: unknown): AuthControlPlaneError => | |
| 34 | + new AuthControlPlaneError({ | |
| 35 | + message, | |
| 36 | + cause, | |
| 37 | + }); | |
| 38 | + | |
| 39 | +export const makeAuthControlPlane = Effect.gen(function* () { | |
| 40 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 41 | + const sessions = yield* SessionCredentialService; | |
| 42 | + | |
| 43 | + const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const createdAt = yield* DateTime.now; | |
| 46 | + const issued = yield* bootstrapCredentials.issueOneTimeToken({ | |
| 47 | + role: input?.role ?? "client", | |
| 48 | + subject: input?.subject ?? "one-time-token", | |
| 49 | + ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 50 | + ...(input?.label ? { label: input.label } : {}), | |
| 51 | + }); | |
| 52 | + return { | |
| 53 | + id: issued.id, | |
| 54 | + credential: issued.credential, | |
| 55 | + role: input?.role ?? "client", | |
| 56 | + subject: input?.subject ?? "one-time-token", | |
| 57 | + ...(issued.label ? { label: issued.label } : {}), | |
| 58 | + createdAt: DateTime.toUtc(createdAt), | |
| 59 | + expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 60 | + } satisfies IssuedPairingLink; | |
| 61 | + }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link."))); | |
| 62 | + | |
| 63 | + const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) => | |
| 64 | + bootstrapCredentials.listActive().pipe( | |
| 65 | + Effect.map((pairingLinks) => | |
| 66 | + pairingLinks | |
| 67 | + .filter((pairingLink) => (input?.role ? pairingLink.role === input.role : true)) | |
| 68 | + .filter((pairingLink) => !input?.excludeSubjects?.includes(pairingLink.subject)) | |
| 69 | + .map((pairingLink) => | |
| 70 | + pairingLink.label | |
| 71 | + ? ({ | |
| 72 | + id: pairingLink.id, | |
| 73 | + credential: pairingLink.credential, | |
| 74 | + role: pairingLink.role, | |
| 75 | + subject: pairingLink.subject, | |
| 76 | + label: pairingLink.label, | |
| 77 | + createdAt: pairingLink.createdAt, | |
| 78 | + expiresAt: pairingLink.expiresAt, | |
| 79 | + } satisfies AuthPairingLink) | |
| 80 | + : ({ | |
| 81 | + id: pairingLink.id, | |
| 82 | + credential: pairingLink.credential, | |
| 83 | + role: pairingLink.role, | |
| 84 | + subject: pairingLink.subject, | |
| 85 | + createdAt: pairingLink.createdAt, | |
| 86 | + expiresAt: pairingLink.expiresAt, | |
| 87 | + } satisfies AuthPairingLink), | |
| 88 | + ) | |
| 89 | + .toSorted( | |
| 90 | + (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, | |
| 91 | + ), | |
| 92 | + ), | |
| 93 | + Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")), | |
| 94 | + ); | |
| 95 | + | |
| 96 | + const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) => | |
| 97 | + bootstrapCredentials | |
| 98 | + .revoke(id) | |
| 99 | + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link."))); | |
| 100 | + | |
| 101 | + const issueSession: AuthControlPlaneShape["issueSession"] = (input) => | |
| 102 | + sessions | |
| 103 | + .issue({ | |
| 104 | + subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 105 | + method: "bearer-session-token", | |
| 106 | + role: input?.role ?? "owner", | |
| 107 | + client: { | |
| 108 | + ...(input?.label ? { label: input.label } : {}), | |
| 109 | + deviceType: "bot", | |
| 110 | + }, | |
| 111 | + ...(input?.ttl ? { ttl: input.ttl } : {}), | |
| 112 | + }) | |
| 113 | + .pipe( | |
| 114 | + Effect.flatMap((issued) => { | |
| 115 | + if (issued.method !== "bearer-session-token") { | |
| 116 | + return Effect.fail( | |
| 117 | + new AuthControlPlaneError({ | |
| 118 | + message: "CLI session issuance produced an unexpected session method.", | |
| 119 | + }), | |
| 120 | + ); | |
| 121 | + } | |
| 122 | + | |
| 123 | + return Effect.succeed({ | |
| 124 | + sessionId: issued.sessionId, | |
| 125 | + token: issued.token, | |
| 126 | + method: "bearer-session-token" as const, | |
| 127 | + role: issued.role, | |
| 128 | + subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | |
| 129 | + client: issued.client, | |
| 130 | + expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 131 | + } satisfies IssuedBearerSession); | |
| 132 | + }), | |
| 133 | + Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")), | |
| 134 | + ); | |
| 135 | + | |
| 136 | + const listSessions: AuthControlPlaneShape["listSessions"] = () => | |
| 137 | + sessions.listActive().pipe( | |
| 138 | + Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), | |
| 139 | + Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")), | |
| 140 | + ); | |
| 141 | + | |
| 142 | + const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) => | |
| 143 | + sessions | |
| 144 | + .revoke(sessionId) | |
| 145 | + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session."))); | |
| 146 | + | |
| 147 | + const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = ( | |
| 148 | + sessionId, | |
| 149 | + ) => | |
| 150 | + sessions | |
| 151 | + .revokeAllExcept(sessionId) | |
| 152 | + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions."))); | |
| 153 | + | |
| 154 | + return { | |
| 155 | + createPairingLink, | |
| 156 | + listPairingLinks, | |
| 157 | + revokePairingLink, | |
| 158 | + issueSession, | |
| 159 | + listSessions, | |
| 160 | + revokeSession, | |
| 161 | + revokeOtherSessionsExcept, | |
| 162 | + } satisfies AuthControlPlaneShape; | |
| 163 | +}); | |
| 164 | + | |
| 165 | +export const AuthCoreLive = Layer.mergeAll( | |
| 166 | + BootstrapCredentialServiceLive, | |
| 167 | + SessionCredentialServiceLive, | |
| 168 | +); | |
| 169 | + | |
| 170 | +export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive); | |
| 171 | + | |
| 172 | +export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive)); | |
| 173 | + | |
| 174 | +export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane); | |
| 175 | + | |
| 176 | +export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe( | |
| 177 | + Layer.provideMerge(AuthRuntimeLive), | |
| 178 | +); | |
| @@ -0,0 +1,151 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Duration, Effect, Layer } from "effect"; | |
| 4 | +import { TestClock } from "effect/testing"; | |
| 5 | + | |
| 6 | +import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | +import { ServerConfig } from "../../config.ts"; | |
| 8 | +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 9 | +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 10 | +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; | |
| 11 | + | |
| 12 | +const makeServerConfigLayer = ( | |
| 13 | + overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 14 | +) => | |
| 15 | + Layer.effect( | |
| 16 | + ServerConfig, | |
| 17 | + Effect.gen(function* () { | |
| 18 | + const config = yield* ServerConfig; | |
| 19 | + return { | |
| 20 | + ...config, | |
| 21 | + ...overrides, | |
| 22 | + } satisfies ServerConfigShape; | |
| 23 | + }), | |
| 24 | + ).pipe( | |
| 25 | + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), | |
| 26 | + ); | |
| 27 | + | |
| 28 | +const makeBootstrapCredentialLayer = ( | |
| 29 | + overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 30 | +) => | |
| 31 | + BootstrapCredentialServiceLive.pipe( | |
| 32 | + Layer.provide(SqlitePersistenceMemory), | |
| 33 | + Layer.provide(makeServerConfigLayer(overrides)), | |
| 34 | + ); | |
| 35 | + | |
| 36 | +it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { | |
| 37 | + it.effect("issues pairing tokens in a short manual-entry format", () => | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 40 | + const issued = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 41 | + | |
| 42 | + expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); | |
| 43 | + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 44 | + ); | |
| 45 | + | |
| 46 | + it.effect("issues one-time bootstrap tokens that can only be consumed once", () => | |
| 47 | + Effect.gen(function* () { | |
| 48 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 49 | + const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); | |
| 50 | + const first = yield* bootstrapCredentials.consume(issued.credential); | |
| 51 | + const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); | |
| 52 | + | |
| 53 | + expect(first.method).toBe("one-time-token"); | |
| 54 | + expect(first.role).toBe("client"); | |
| 55 | + expect(first.subject).toBe("one-time-token"); | |
| 56 | + expect(first.label).toBe("Julius iPhone"); | |
| 57 | + expect(issued.label).toBe("Julius iPhone"); | |
| 58 | + expect(second._tag).toBe("BootstrapCredentialError"); | |
| 59 | + expect(second.message).toContain("Unknown bootstrap credential"); | |
| 60 | + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 61 | + ); | |
| 62 | + | |
| 63 | + it.effect("atomically consumes a one-time token when multiple requests race", () => | |
| 64 | + Effect.gen(function* () { | |
| 65 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 66 | + const token = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 67 | + const results = yield* Effect.all( | |
| 68 | + Array.from({ length: 8 }, () => | |
| 69 | + Effect.result(bootstrapCredentials.consume(token.credential)), | |
| 70 | + ), | |
| 71 | + { | |
| 72 | + concurrency: "unbounded", | |
| 73 | + }, | |
| 74 | + ); | |
| 75 | + | |
| 76 | + const successes = results.filter((result) => result._tag === "Success"); | |
| 77 | + const failures = results.filter((result) => result._tag === "Failure"); | |
| 78 | + | |
| 79 | + expect(successes).toHaveLength(1); | |
| 80 | + expect(failures).toHaveLength(7); | |
| 81 | + for (const failure of failures) { | |
| 82 | + expect(failure.failure._tag).toBe("BootstrapCredentialError"); | |
| 83 | + expect(failure.failure.message).toContain("Unknown bootstrap credential"); | |
| 84 | + } | |
| 85 | + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 86 | + ); | |
| 87 | + | |
| 88 | + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => | |
| 89 | + Effect.gen(function* () { | |
| 90 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 91 | + const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); | |
| 92 | + const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 93 | + | |
| 94 | + expect(first.method).toBe("desktop-bootstrap"); | |
| 95 | + expect(first.role).toBe("owner"); | |
| 96 | + expect(first.subject).toBe("desktop-bootstrap"); | |
| 97 | + expect(second._tag).toBe("BootstrapCredentialError"); | |
| 98 | + expect(second.status).toBe(401); | |
| 99 | + }).pipe( | |
| 100 | + Effect.provide( | |
| 101 | + makeBootstrapCredentialLayer({ | |
| 102 | + desktopBootstrapToken: "desktop-bootstrap-token", | |
| 103 | + }), | |
| 104 | + ), | |
| 105 | + ), | |
| 106 | + ); | |
| 107 | + | |
| 108 | + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => | |
| 109 | + Effect.gen(function* () { | |
| 110 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 111 | + | |
| 112 | + yield* TestClock.adjust(Duration.minutes(6)); | |
| 113 | + const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | |
| 114 | + | |
| 115 | + expect(expired._tag).toBe("BootstrapCredentialError"); | |
| 116 | + expect(expired.status).toBe(401); | |
| 117 | + expect(expired.message).toContain("Bootstrap credential expired"); | |
| 118 | + }).pipe( | |
| 119 | + Effect.provide( | |
| 120 | + Layer.merge( | |
| 121 | + makeBootstrapCredentialLayer({ | |
| 122 | + desktopBootstrapToken: "desktop-bootstrap-token", | |
| 123 | + }), | |
| 124 | + TestClock.layer(), | |
| 125 | + ), | |
| 126 | + ), | |
| 127 | + ), | |
| 128 | + ); | |
| 129 | + | |
| 130 | + it.effect("lists and revokes active pairing links", () => | |
| 131 | + Effect.gen(function* () { | |
| 132 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 133 | + const first = yield* bootstrapCredentials.issueOneTimeToken(); | |
| 134 | + const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" }); | |
| 135 | + | |
| 136 | + const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); | |
| 137 | + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); | |
| 138 | + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 139 | + | |
| 140 | + const revoked = yield* bootstrapCredentials.revoke(first.id); | |
| 141 | + const activeAfterRevoke = yield* bootstrapCredentials.listActive(); | |
| 142 | + const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); | |
| 143 | + | |
| 144 | + expect(revoked).toBe(true); | |
| 145 | + expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); | |
| 146 | + expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); | |
| 147 | + expect(revokedConsume.message).toContain("no longer available"); | |
| 148 | + expect(revokedConsume.status).toBe(401); | |
| 149 | + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), | |
| 150 | + ); | |
| 151 | +}); | |
| @@ -0,0 +1,296 @@ | ||
| 1 | +import type { AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | +import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect"; | |
| 3 | +import { Option } from "effect"; | |
| 4 | + | |
| 5 | +import { ServerConfig } from "../../config.ts"; | |
| 6 | +import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; | |
| 7 | +import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; | |
| 8 | +import { | |
| 9 | + BootstrapCredentialError, | |
| 10 | + BootstrapCredentialService, | |
| 11 | + type BootstrapCredentialChange, | |
| 12 | + type BootstrapCredentialServiceShape, | |
| 13 | + type BootstrapGrant, | |
| 14 | + type IssuedBootstrapCredential, | |
| 15 | +} from "../Services/BootstrapCredentialService.ts"; | |
| 16 | + | |
| 17 | +interface StoredBootstrapGrant extends BootstrapGrant { | |
| 18 | + readonly remainingUses: number | "unbounded"; | |
| 19 | +} | |
| 20 | + | |
| 21 | +type ConsumeResult = | |
| 22 | + | { | |
| 23 | + readonly _tag: "error"; | |
| 24 | + readonly reason: "not-found" | "expired"; | |
| 25 | + readonly error: BootstrapCredentialError; | |
| 26 | + } | |
| 27 | + | { | |
| 28 | + readonly _tag: "success"; | |
| 29 | + readonly grant: BootstrapGrant; | |
| 30 | + }; | |
| 31 | + | |
| 32 | +const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); | |
| 33 | +const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; | |
| 34 | +const PAIRING_TOKEN_LENGTH = 12; | |
| 35 | + | |
| 36 | +const generatePairingToken = (): string => { | |
| 37 | + const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH)); | |
| 38 | + | |
| 39 | + return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join(""); | |
| 40 | +}; | |
| 41 | + | |
| 42 | +export const makeBootstrapCredentialService = Effect.gen(function* () { | |
| 43 | + const config = yield* ServerConfig; | |
| 44 | + const pairingLinks = yield* AuthPairingLinkRepository; | |
| 45 | + const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>()); | |
| 46 | + const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>(); | |
| 47 | + | |
| 48 | + const invalidBootstrapCredentialError = (message: string) => | |
| 49 | + new BootstrapCredentialError({ | |
| 50 | + message, | |
| 51 | + status: 401, | |
| 52 | + }); | |
| 53 | + | |
| 54 | + const internalBootstrapCredentialError = (message: string, cause: unknown) => | |
| 55 | + new BootstrapCredentialError({ | |
| 56 | + message, | |
| 57 | + status: 500, | |
| 58 | + cause, | |
| 59 | + }); | |
| 60 | + | |
| 61 | + const seedGrant = (credential: string, grant: StoredBootstrapGrant) => | |
| 62 | + Ref.update(seededGrantsRef, (current) => { | |
| 63 | + const next = new Map(current); | |
| 64 | + next.set(credential, grant); | |
| 65 | + return next; | |
| 66 | + }); | |
| 67 | + | |
| 68 | + const emitUpsert = (pairingLink: AuthPairingLink) => | |
| 69 | + PubSub.publish(changesPubSub, { | |
| 70 | + type: "pairingLinkUpserted", | |
| 71 | + pairingLink, | |
| 72 | + }).pipe(Effect.asVoid); | |
| 73 | + | |
| 74 | + const emitRemoved = (id: string) => | |
| 75 | + PubSub.publish(changesPubSub, { | |
| 76 | + type: "pairingLinkRemoved", | |
| 77 | + id, | |
| 78 | + }).pipe(Effect.asVoid); | |
| 79 | + | |
| 80 | + if (config.desktopBootstrapToken) { | |
| 81 | + const now = yield* DateTime.now; | |
| 82 | + yield* seedGrant(config.desktopBootstrapToken, { | |
| 83 | + method: "desktop-bootstrap", | |
| 84 | + role: "owner", | |
| 85 | + subject: "desktop-bootstrap", | |
| 86 | + expiresAt: DateTime.add(now, { | |
| 87 | + milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), | |
| 88 | + }), | |
| 89 | + remainingUses: 1, | |
| 90 | + }); | |
| 91 | + } | |
| 92 | + | |
| 93 | + const toBootstrapCredentialError = (message: string) => (cause: unknown) => | |
| 94 | + internalBootstrapCredentialError(message, cause); | |
| 95 | + | |
| 96 | + const listActive: BootstrapCredentialServiceShape["listActive"] = () => | |
| 97 | + Effect.gen(function* () { | |
| 98 | + const now = yield* DateTime.now; | |
| 99 | + const rows = yield* pairingLinks.listActive({ now }); | |
| 100 | + | |
| 101 | + return rows.map((row) => | |
| 102 | + row.label | |
| 103 | + ? ({ | |
| 104 | + id: row.id, | |
| 105 | + credential: row.credential, | |
| 106 | + role: row.role, | |
| 107 | + subject: row.subject, | |
| 108 | + label: row.label, | |
| 109 | + createdAt: row.createdAt, | |
| 110 | + expiresAt: row.expiresAt, | |
| 111 | + } satisfies AuthPairingLink) | |
| 112 | + : ({ | |
| 113 | + id: row.id, | |
| 114 | + credential: row.credential, | |
| 115 | + role: row.role, | |
| 116 | + subject: row.subject, | |
| 117 | + createdAt: row.createdAt, | |
| 118 | + expiresAt: row.expiresAt, | |
| 119 | + } satisfies AuthPairingLink), | |
| 120 | + ); | |
| 121 | + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links."))); | |
| 122 | + | |
| 123 | + const revoke: BootstrapCredentialServiceShape["revoke"] = (id) => | |
| 124 | + Effect.gen(function* () { | |
| 125 | + const revokedAt = yield* DateTime.now; | |
| 126 | + const revoked = yield* pairingLinks.revoke({ | |
| 127 | + id, | |
| 128 | + revokedAt, | |
| 129 | + }); | |
| 130 | + if (revoked) { | |
| 131 | + yield* emitRemoved(id); | |
| 132 | + } | |
| 133 | + return revoked; | |
| 134 | + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link."))); | |
| 135 | + | |
| 136 | + const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => | |
| 137 | + Effect.gen(function* () { | |
| 138 | + const id = crypto.randomUUID(); | |
| 139 | + const credential = generatePairingToken(); | |
| 140 | + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; | |
| 141 | + const now = yield* DateTime.now; | |
| 142 | + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); | |
| 143 | + const issued: IssuedBootstrapCredential = { | |
| 144 | + id, | |
| 145 | + credential, | |
| 146 | + ...(input?.label ? { label: input.label } : {}), | |
| 147 | + expiresAt, | |
| 148 | + }; | |
| 149 | + yield* pairingLinks.create({ | |
| 150 | + id, | |
| 151 | + credential, | |
| 152 | + method: "one-time-token", | |
| 153 | + role: input?.role ?? "client", | |
| 154 | + subject: input?.subject ?? "one-time-token", | |
| 155 | + label: input?.label ?? null, | |
| 156 | + createdAt: now, | |
| 157 | + expiresAt: expiresAt, | |
| 158 | + }); | |
| 159 | + yield* emitUpsert({ | |
| 160 | + id, | |
| 161 | + credential, | |
| 162 | + role: input?.role ?? "client", | |
| 163 | + subject: input?.subject ?? "one-time-token", | |
| 164 | + ...(input?.label ? { label: input.label } : {}), | |
| 165 | + createdAt: now, | |
| 166 | + expiresAt, | |
| 167 | + }); | |
| 168 | + return issued; | |
| 169 | + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential."))); | |
| 170 | + | |
| 171 | + const consume: BootstrapCredentialServiceShape["consume"] = (credential) => | |
| 172 | + Effect.gen(function* () { | |
| 173 | + const now = yield* DateTime.now; | |
| 174 | + const seededResult: ConsumeResult = yield* Ref.modify( | |
| 175 | + seededGrantsRef, | |
| 176 | + (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => { | |
| 177 | + const grant = current.get(credential); | |
| 178 | + if (!grant) { | |
| 179 | + return [ | |
| 180 | + { | |
| 181 | + _tag: "error", | |
| 182 | + reason: "not-found", | |
| 183 | + error: invalidBootstrapCredentialError("Unknown bootstrap credential."), | |
| 184 | + }, | |
| 185 | + current, | |
| 186 | + ]; | |
| 187 | + } | |
| 188 | + | |
| 189 | + const next = new Map(current); | |
| 190 | + if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { | |
| 191 | + next.delete(credential); | |
| 192 | + return [ | |
| 193 | + { | |
| 194 | + _tag: "error", | |
| 195 | + reason: "expired", | |
| 196 | + error: invalidBootstrapCredentialError("Bootstrap credential expired."), | |
| 197 | + }, | |
| 198 | + next, | |
| 199 | + ]; | |
| 200 | + } | |
| 201 | + | |
| 202 | + const remainingUses = grant.remainingUses; | |
| 203 | + if (typeof remainingUses === "number") { | |
| 204 | + if (remainingUses <= 1) { | |
| 205 | + next.delete(credential); | |
| 206 | + } else { | |
| 207 | + next.set(credential, { | |
| 208 | + ...grant, | |
| 209 | + remainingUses: remainingUses - 1, | |
| 210 | + }); | |
| 211 | + } | |
| 212 | + } | |
| 213 | + | |
| 214 | + return [ | |
| 215 | + { | |
| 216 | + _tag: "success", | |
| 217 | + grant: { | |
| 218 | + method: grant.method, | |
| 219 | + role: grant.role, | |
| 220 | + subject: grant.subject, | |
| 221 | + ...(grant.label ? { label: grant.label } : {}), | |
| 222 | + expiresAt: grant.expiresAt, | |
| 223 | + } satisfies BootstrapGrant, | |
| 224 | + }, | |
| 225 | + next, | |
| 226 | + ]; | |
| 227 | + }, | |
| 228 | + ); | |
| 229 | + | |
| 230 | + if (seededResult._tag === "success") { | |
| 231 | + return seededResult.grant; | |
| 232 | + } | |
| 233 | + if (seededResult.reason !== "not-found") { | |
| 234 | + return yield* seededResult.error; | |
| 235 | + } | |
| 236 | + | |
| 237 | + const consumed = yield* pairingLinks.consumeAvailable({ | |
| 238 | + credential, | |
| 239 | + consumedAt: now, | |
| 240 | + now, | |
| 241 | + }); | |
| 242 | + | |
| 243 | + if (Option.isSome(consumed)) { | |
| 244 | + yield* emitRemoved(consumed.value.id); | |
| 245 | + return { | |
| 246 | + method: consumed.value.method, | |
| 247 | + role: consumed.value.role, | |
| 248 | + subject: consumed.value.subject, | |
| 249 | + ...(consumed.value.label ? { label: consumed.value.label } : {}), | |
| 250 | + expiresAt: consumed.value.expiresAt, | |
| 251 | + } satisfies BootstrapGrant; | |
| 252 | + } | |
| 253 | + | |
| 254 | + const matching = yield* pairingLinks.getByCredential({ credential }); | |
| 255 | + if (Option.isNone(matching)) { | |
| 256 | + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 257 | + } | |
| 258 | + | |
| 259 | + if (matching.value.revokedAt !== null) { | |
| 260 | + return yield* invalidBootstrapCredentialError( | |
| 261 | + "Bootstrap credential is no longer available.", | |
| 262 | + ); | |
| 263 | + } | |
| 264 | + | |
| 265 | + if (matching.value.consumedAt !== null) { | |
| 266 | + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | |
| 267 | + } | |
| 268 | + | |
| 269 | + if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { | |
| 270 | + return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | |
| 271 | + } | |
| 272 | + | |
| 273 | + return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | |
| 274 | + }).pipe( | |
| 275 | + Effect.mapError((cause) => | |
| 276 | + cause instanceof BootstrapCredentialError | |
| 277 | + ? cause | |
| 278 | + : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), | |
| 279 | + ), | |
| 280 | + ); | |
| 281 | + | |
| 282 | + return { | |
| 283 | + issueOneTimeToken, | |
| 284 | + listActive, | |
| 285 | + get streamChanges() { | |
| 286 | + return Stream.fromPubSub(changesPubSub); | |
| 287 | + }, | |
| 288 | + revoke, | |
| 289 | + consume, | |
| 290 | + } satisfies BootstrapCredentialServiceShape; | |
| 291 | +}); | |
| 292 | + | |
| 293 | +export const BootstrapCredentialServiceLive = Layer.effect( | |
| 294 | + BootstrapCredentialService, | |
| 295 | + makeBootstrapCredentialService, | |
| 296 | +).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive)); | |
| @@ -0,0 +1,182 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Effect, Layer } from "effect"; | |
| 4 | + | |
| 5 | +import type { ServerConfigShape } from "../../config.ts"; | |
| 6 | +import { ServerConfig } from "../../config.ts"; | |
| 7 | +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 8 | +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; | |
| 9 | +import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; | |
| 10 | +import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts"; | |
| 11 | +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 12 | + | |
| 13 | +const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 14 | + Layer.effect( | |
| 15 | + ServerConfig, | |
| 16 | + Effect.gen(function* () { | |
| 17 | + const config = yield* ServerConfig; | |
| 18 | + return { | |
| 19 | + ...config, | |
| 20 | + ...overrides, | |
| 21 | + } satisfies ServerConfigShape; | |
| 22 | + }), | |
| 23 | + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); | |
| 24 | + | |
| 25 | +const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 26 | + ServerAuthLive.pipe( | |
| 27 | + Layer.provide(SqlitePersistenceMemory), | |
| 28 | + Layer.provide(ServerSecretStoreLive), | |
| 29 | + Layer.provide(makeServerConfigLayer(overrides)), | |
| 30 | + ); | |
| 31 | + | |
| 32 | +const makeCookieRequest = ( | |
| 33 | + sessionToken: string, | |
| 34 | +): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] => | |
| 35 | + ({ | |
| 36 | + cookies: { | |
| 37 | + t3_session: sessionToken, | |
| 38 | + }, | |
| 39 | + headers: {}, | |
| 40 | + }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0]; | |
| 41 | + | |
| 42 | +const requestMetadata = { | |
| 43 | + deviceType: "desktop" as const, | |
| 44 | + os: "macOS", | |
| 45 | + browser: "Chrome", | |
| 46 | + ipAddress: "192.168.1.23", | |
| 47 | +}; | |
| 48 | + | |
| 49 | +it.layer(NodeServices.layer)("ServerAuthLive", (it) => { | |
| 50 | + it.effect("maps invalid bootstrap credential failures to 401", () => | |
| 51 | + Effect.sync(() => { | |
| 52 | + const error = toBootstrapExchangeAuthError( | |
| 53 | + new BootstrapCredentialError({ | |
| 54 | + message: "Unknown bootstrap credential.", | |
| 55 | + status: 401, | |
| 56 | + }), | |
| 57 | + ); | |
| 58 | + | |
| 59 | + expect(error.status).toBe(401); | |
| 60 | + expect(error.message).toBe("Invalid bootstrap credential."); | |
| 61 | + }), | |
| 62 | + ); | |
| 63 | + | |
| 64 | + it.effect("maps unexpected bootstrap failures to 500", () => | |
| 65 | + Effect.sync(() => { | |
| 66 | + const error = toBootstrapExchangeAuthError( | |
| 67 | + new BootstrapCredentialError({ | |
| 68 | + message: "Failed to consume bootstrap credential.", | |
| 69 | + status: 500, | |
| 70 | + cause: new Error("sqlite is unavailable"), | |
| 71 | + }), | |
| 72 | + ); | |
| 73 | + | |
| 74 | + expect(error.status).toBe(500); | |
| 75 | + expect(error.message).toBe("Failed to validate bootstrap credential."); | |
| 76 | + }), | |
| 77 | + ); | |
| 78 | + | |
| 79 | + it.effect("issues client pairing credentials by default", () => | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const serverAuth = yield* ServerAuth; | |
| 82 | + | |
| 83 | + const pairingCredential = yield* serverAuth.issuePairingCredential(); | |
| 84 | + const exchanged = yield* serverAuth.exchangeBootstrapCredential( | |
| 85 | + pairingCredential.credential, | |
| 86 | + requestMetadata, | |
| 87 | + ); | |
| 88 | + const verified = yield* serverAuth.authenticateHttpRequest( | |
| 89 | + makeCookieRequest(exchanged.sessionToken), | |
| 90 | + ); | |
| 91 | + | |
| 92 | + expect(verified.sessionId.length).toBeGreaterThan(0); | |
| 93 | + expect(verified.role).toBe("client"); | |
| 94 | + expect(verified.subject).toBe("one-time-token"); | |
| 95 | + }).pipe(Effect.provide(makeServerAuthLayer())), | |
| 96 | + ); | |
| 97 | + | |
| 98 | + it.effect("issues startup pairing URLs that bootstrap owner sessions", () => | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const serverAuth = yield* ServerAuth; | |
| 101 | + | |
| 102 | + const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); | |
| 103 | + const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); | |
| 104 | + const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 105 | + expect(token).toBeTruthy(); | |
| 106 | + expect( | |
| 107 | + listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"), | |
| 108 | + ).toBe(false); | |
| 109 | + | |
| 110 | + const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata); | |
| 111 | + const verified = yield* serverAuth.authenticateHttpRequest( | |
| 112 | + makeCookieRequest(exchanged.sessionToken), | |
| 113 | + ); | |
| 114 | + | |
| 115 | + expect(verified.role).toBe("owner"); | |
| 116 | + expect(verified.subject).toBe("owner-bootstrap"); | |
| 117 | + }).pipe(Effect.provide(makeServerAuthLayer())), | |
| 118 | + ); | |
| 119 | + | |
| 120 | + it.effect("lists pairing links and revokes other client sessions while keeping the owner", () => | |
| 121 | + Effect.gen(function* () { | |
| 122 | + const serverAuth = yield* ServerAuth; | |
| 123 | + | |
| 124 | + const ownerExchange = yield* serverAuth.exchangeBootstrapCredential( | |
| 125 | + "desktop-bootstrap-token", | |
| 126 | + requestMetadata, | |
| 127 | + ); | |
| 128 | + const ownerSession = yield* serverAuth.authenticateHttpRequest( | |
| 129 | + makeCookieRequest(ownerExchange.sessionToken), | |
| 130 | + ); | |
| 131 | + const pairingCredential = yield* serverAuth.issuePairingCredential({ | |
| 132 | + label: "Julius iPhone", | |
| 133 | + }); | |
| 134 | + const listedPairingLinks = yield* serverAuth.listPairingLinks(); | |
| 135 | + const clientExchange = yield* serverAuth.exchangeBootstrapCredential( | |
| 136 | + pairingCredential.credential, | |
| 137 | + { | |
| 138 | + ...requestMetadata, | |
| 139 | + deviceType: "mobile", | |
| 140 | + os: "iOS", | |
| 141 | + browser: "Safari", | |
| 142 | + ipAddress: "192.168.1.88", | |
| 143 | + }, | |
| 144 | + ); | |
| 145 | + const clientSession = yield* serverAuth.authenticateHttpRequest( | |
| 146 | + makeCookieRequest(clientExchange.sessionToken), | |
| 147 | + ); | |
| 148 | + const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); | |
| 149 | + const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId); | |
| 150 | + const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); | |
| 151 | + | |
| 152 | + expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); | |
| 153 | + expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe( | |
| 154 | + "Julius iPhone", | |
| 155 | + ); | |
| 156 | + expect(clientsBeforeRevoke).toHaveLength(2); | |
| 157 | + expect( | |
| 158 | + clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current, | |
| 159 | + ).toBe(true); | |
| 160 | + expect( | |
| 161 | + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, | |
| 162 | + ).toBe(false); | |
| 163 | + expect( | |
| 164 | + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 165 | + .label, | |
| 166 | + ).toBe("Julius iPhone"); | |
| 167 | + expect( | |
| 168 | + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client | |
| 169 | + .deviceType, | |
| 170 | + ).toBe("mobile"); | |
| 171 | + expect(revokedCount).toBe(1); | |
| 172 | + expect(clientsAfterRevoke).toHaveLength(1); | |
| 173 | + expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId); | |
| 174 | + }).pipe( | |
| 175 | + Effect.provide( | |
| 176 | + makeServerAuthLayer({ | |
| 177 | + desktopBootstrapToken: "desktop-bootstrap-token", | |
| 178 | + }), | |
| 179 | + ), | |
| 180 | + ), | |
| 181 | + ); | |
| 182 | +}); | |
| @@ -0,0 +1,395 @@ | ||
| 1 | +import { | |
| 2 | + type AuthBearerBootstrapResult, | |
| 3 | + type AuthClientSession, | |
| 4 | + type AuthBootstrapResult, | |
| 5 | + type AuthPairingCredentialResult, | |
| 6 | + type AuthSessionState, | |
| 7 | + type AuthWebSocketTokenResult, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import { DateTime, Effect, Layer, Option } from "effect"; | |
| 10 | +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 11 | + | |
| 12 | +import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; | |
| 13 | +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; | |
| 14 | +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; | |
| 15 | +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; | |
| 16 | +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; | |
| 17 | +import { | |
| 18 | + ServerAuth, | |
| 19 | + type AuthenticatedSession, | |
| 20 | + AuthError, | |
| 21 | + type ServerAuthShape, | |
| 22 | +} from "../Services/ServerAuth.ts"; | |
| 23 | +import { | |
| 24 | + SessionCredentialError, | |
| 25 | + SessionCredentialService, | |
| 26 | +} from "../Services/SessionCredentialService.ts"; | |
| 27 | +import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; | |
| 28 | + | |
| 29 | +type BootstrapExchangeResult = { | |
| 30 | + readonly response: AuthBootstrapResult; | |
| 31 | + readonly sessionToken: string; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const AUTHORIZATION_PREFIX = "Bearer "; | |
| 35 | +const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; | |
| 36 | + | |
| 37 | +export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { | |
| 38 | + if (cause.status === 500) { | |
| 39 | + return new AuthError({ | |
| 40 | + message: "Failed to validate bootstrap credential.", | |
| 41 | + status: 500, | |
| 42 | + cause, | |
| 43 | + }); | |
| 44 | + } | |
| 45 | + | |
| 46 | + return new AuthError({ | |
| 47 | + message: "Invalid bootstrap credential.", | |
| 48 | + status: 401, | |
| 49 | + cause, | |
| 50 | + }); | |
| 51 | +} | |
| 52 | + | |
| 53 | +function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { | |
| 54 | + const header = request.headers["authorization"]; | |
| 55 | + if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { | |
| 56 | + return null; | |
| 57 | + } | |
| 58 | + const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); | |
| 59 | + return token.length > 0 ? token : null; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export const makeServerAuth = Effect.gen(function* () { | |
| 63 | + const policy = yield* ServerAuthPolicy; | |
| 64 | + const bootstrapCredentials = yield* BootstrapCredentialService; | |
| 65 | + const authControlPlane = yield* AuthControlPlane; | |
| 66 | + const sessions = yield* SessionCredentialService; | |
| 67 | + const descriptor = yield* policy.getDescriptor(); | |
| 68 | + | |
| 69 | + const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> => | |
| 70 | + sessions.verify(token).pipe( | |
| 71 | + Effect.tapError((cause: SessionCredentialError) => | |
| 72 | + Effect.logWarning("Rejected authenticated session credential.").pipe( | |
| 73 | + Effect.annotateLogs({ | |
| 74 | + reason: cause.message, | |
| 75 | + }), | |
| 76 | + ), | |
| 77 | + ), | |
| 78 | + Effect.map((session) => ({ | |
| 79 | + sessionId: session.sessionId, | |
| 80 | + subject: session.subject, | |
| 81 | + method: session.method, | |
| 82 | + role: session.role, | |
| 83 | + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 84 | + })), | |
| 85 | + Effect.mapError( | |
| 86 | + (cause) => | |
| 87 | + new AuthError({ | |
| 88 | + message: "Unauthorized request.", | |
| 89 | + status: 401, | |
| 90 | + cause, | |
| 91 | + }), | |
| 92 | + ), | |
| 93 | + ); | |
| 94 | + | |
| 95 | + const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | |
| 96 | + const cookieToken = request.cookies[sessions.cookieName]; | |
| 97 | + const bearerToken = parseBearerToken(request); | |
| 98 | + const credential = cookieToken ?? bearerToken; | |
| 99 | + if (!credential) { | |
| 100 | + return Effect.fail( | |
| 101 | + new AuthError({ | |
| 102 | + message: "Authentication required.", | |
| 103 | + status: 401, | |
| 104 | + }), | |
| 105 | + ); | |
| 106 | + } | |
| 107 | + return authenticateToken(credential); | |
| 108 | + }; | |
| 109 | + | |
| 110 | + const getSessionState: ServerAuthShape["getSessionState"] = (request) => | |
| 111 | + authenticateRequest(request).pipe( | |
| 112 | + Effect.map( | |
| 113 | + (session) => | |
| 114 | + ({ | |
| 115 | + authenticated: true, | |
| 116 | + auth: descriptor, | |
| 117 | + role: session.role, | |
| 118 | + sessionMethod: session.method, | |
| 119 | + ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), | |
| 120 | + }) satisfies AuthSessionState, | |
| 121 | + ), | |
| 122 | + Effect.catchTag("AuthError", () => | |
| 123 | + Effect.succeed({ | |
| 124 | + authenticated: false, | |
| 125 | + auth: descriptor, | |
| 126 | + } satisfies AuthSessionState), | |
| 127 | + ), | |
| 128 | + ); | |
| 129 | + | |
| 130 | + const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( | |
| 131 | + credential, | |
| 132 | + requestMetadata, | |
| 133 | + ) => | |
| 134 | + bootstrapCredentials.consume(credential).pipe( | |
| 135 | + Effect.mapError(toBootstrapExchangeAuthError), | |
| 136 | + Effect.flatMap((grant) => | |
| 137 | + sessions | |
| 138 | + .issue({ | |
| 139 | + method: "browser-session-cookie", | |
| 140 | + subject: grant.subject, | |
| 141 | + role: grant.role, | |
| 142 | + client: { | |
| 143 | + ...requestMetadata, | |
| 144 | + ...(grant.label ? { label: grant.label } : {}), | |
| 145 | + }, | |
| 146 | + }) | |
| 147 | + .pipe( | |
| 148 | + Effect.mapError( | |
| 149 | + (cause) => | |
| 150 | + new AuthError({ | |
| 151 | + message: "Failed to issue authenticated session.", | |
| 152 | + cause, | |
| 153 | + }), | |
| 154 | + ), | |
| 155 | + ), | |
| 156 | + ), | |
| 157 | + Effect.map( | |
| 158 | + (session) => | |
| 159 | + ({ | |
| 160 | + response: { | |
| 161 | + authenticated: true, | |
| 162 | + role: session.role, | |
| 163 | + sessionMethod: session.method, | |
| 164 | + expiresAt: DateTime.toUtc(session.expiresAt), | |
| 165 | + } satisfies AuthBootstrapResult, | |
| 166 | + sessionToken: session.token, | |
| 167 | + }) satisfies BootstrapExchangeResult, | |
| 168 | + ), | |
| 169 | + ); | |
| 170 | + | |
| 171 | + const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] = | |
| 172 | + (credential, requestMetadata) => | |
| 173 | + bootstrapCredentials.consume(credential).pipe( | |
| 174 | + Effect.mapError(toBootstrapExchangeAuthError), | |
| 175 | + Effect.flatMap((grant) => | |
| 176 | + sessions | |
| 177 | + .issue({ | |
| 178 | + method: "bearer-session-token", | |
| 179 | + subject: grant.subject, | |
| 180 | + role: grant.role, | |
| 181 | + client: { | |
| 182 | + ...requestMetadata, | |
| 183 | + ...(grant.label ? { label: grant.label } : {}), | |
| 184 | + }, | |
| 185 | + }) | |
| 186 | + .pipe( | |
| 187 | + Effect.mapError( | |
| 188 | + (cause) => | |
| 189 | + new AuthError({ | |
| 190 | + message: "Failed to issue authenticated session.", | |
| 191 | + cause, | |
| 192 | + }), | |
| 193 | + ), | |
| 194 | + ), | |
| 195 | + ), | |
| 196 | + Effect.map( | |
| 197 | + (session) => | |
| 198 | + ({ | |
| 199 | + authenticated: true, | |
| 200 | + role: session.role, | |
| 201 | + sessionMethod: "bearer-session-token", | |
| 202 | + expiresAt: DateTime.toUtc(session.expiresAt), | |
| 203 | + sessionToken: session.token, | |
| 204 | + }) satisfies AuthBearerBootstrapResult, | |
| 205 | + ), | |
| 206 | + ); | |
| 207 | + | |
| 208 | + const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) => | |
| 209 | + authControlPlane | |
| 210 | + .createPairingLink({ | |
| 211 | + role: input?.role ?? "client", | |
| 212 | + subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", | |
| 213 | + ...(input?.label ? { label: input.label } : {}), | |
| 214 | + }) | |
| 215 | + .pipe( | |
| 216 | + Effect.mapError( | |
| 217 | + (cause) => | |
| 218 | + new AuthError({ | |
| 219 | + message: "Failed to issue pairing credential.", | |
| 220 | + cause, | |
| 221 | + }), | |
| 222 | + ), | |
| 223 | + Effect.map( | |
| 224 | + (issued) => | |
| 225 | + ({ | |
| 226 | + id: issued.id, | |
| 227 | + credential: issued.credential, | |
| 228 | + ...(issued.label ? { label: issued.label } : {}), | |
| 229 | + expiresAt: issued.expiresAt, | |
| 230 | + }) satisfies AuthPairingCredentialResult, | |
| 231 | + ), | |
| 232 | + ); | |
| 233 | + | |
| 234 | + const listPairingLinks: ServerAuthShape["listPairingLinks"] = () => | |
| 235 | + authControlPlane | |
| 236 | + .listPairingLinks({ | |
| 237 | + role: "client", | |
| 238 | + excludeSubjects: ["owner-bootstrap"], | |
| 239 | + }) | |
| 240 | + .pipe( | |
| 241 | + Effect.mapError( | |
| 242 | + (cause) => | |
| 243 | + new AuthError({ | |
| 244 | + message: "Failed to load pairing links.", | |
| 245 | + cause, | |
| 246 | + }), | |
| 247 | + ), | |
| 248 | + ); | |
| 249 | + | |
| 250 | + const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) => | |
| 251 | + authControlPlane.revokePairingLink(id).pipe( | |
| 252 | + Effect.mapError( | |
| 253 | + (cause) => | |
| 254 | + new AuthError({ | |
| 255 | + message: "Failed to revoke pairing link.", | |
| 256 | + cause, | |
| 257 | + }), | |
| 258 | + ), | |
| 259 | + ); | |
| 260 | + | |
| 261 | + const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) => | |
| 262 | + authControlPlane.listSessions().pipe( | |
| 263 | + Effect.mapError( | |
| 264 | + (cause) => | |
| 265 | + new AuthError({ | |
| 266 | + message: "Failed to load paired clients.", | |
| 267 | + cause, | |
| 268 | + }), | |
| 269 | + ), | |
| 270 | + Effect.map((clientSessions) => | |
| 271 | + clientSessions.map( | |
| 272 | + (clientSession): AuthClientSession => ({ | |
| 273 | + ...clientSession, | |
| 274 | + current: clientSession.sessionId === currentSessionId, | |
| 275 | + }), | |
| 276 | + ), | |
| 277 | + ), | |
| 278 | + ); | |
| 279 | + | |
| 280 | + const revokeClientSession: ServerAuthShape["revokeClientSession"] = ( | |
| 281 | + currentSessionId, | |
| 282 | + targetSessionId, | |
| 283 | + ) => | |
| 284 | + Effect.gen(function* () { | |
| 285 | + if (currentSessionId === targetSessionId) { | |
| 286 | + return yield* new AuthError({ | |
| 287 | + message: "Use revoke other clients to keep the current owner session active.", | |
| 288 | + status: 403, | |
| 289 | + }); | |
| 290 | + } | |
| 291 | + return yield* authControlPlane.revokeSession(targetSessionId).pipe( | |
| 292 | + Effect.mapError( | |
| 293 | + (cause) => | |
| 294 | + new AuthError({ | |
| 295 | + message: "Failed to revoke client session.", | |
| 296 | + cause, | |
| 297 | + }), | |
| 298 | + ), | |
| 299 | + ); | |
| 300 | + }); | |
| 301 | + | |
| 302 | + const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( | |
| 303 | + currentSessionId, | |
| 304 | + ) => | |
| 305 | + authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe( | |
| 306 | + Effect.mapError( | |
| 307 | + (cause) => | |
| 308 | + new AuthError({ | |
| 309 | + message: "Failed to revoke other client sessions.", | |
| 310 | + cause, | |
| 311 | + }), | |
| 312 | + ), | |
| 313 | + ); | |
| 314 | + | |
| 315 | + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => | |
| 316 | + issuePairingCredential({ role: "owner" }).pipe( | |
| 317 | + Effect.map((issued) => { | |
| 318 | + const url = new URL(baseUrl); | |
| 319 | + url.pathname = "/pair"; | |
| 320 | + url.searchParams.delete("token"); | |
| 321 | + url.hash = new URLSearchParams([["token", issued.credential]]).toString(); | |
| 322 | + return url.toString(); | |
| 323 | + }), | |
| 324 | + ); | |
| 325 | + | |
| 326 | + const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) => | |
| 327 | + sessions.issueWebSocketToken(session.sessionId).pipe( | |
| 328 | + Effect.mapError( | |
| 329 | + (cause) => | |
| 330 | + new AuthError({ | |
| 331 | + message: "Failed to issue websocket token.", | |
| 332 | + cause, | |
| 333 | + }), | |
| 334 | + ), | |
| 335 | + Effect.map( | |
| 336 | + (issued) => | |
| 337 | + ({ | |
| 338 | + token: issued.token, | |
| 339 | + expiresAt: DateTime.toUtc(issued.expiresAt), | |
| 340 | + }) satisfies AuthWebSocketTokenResult, | |
| 341 | + ), | |
| 342 | + ); | |
| 343 | + | |
| 344 | + const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) => | |
| 345 | + Effect.gen(function* () { | |
| 346 | + const requestUrl = HttpServerRequest.toURL(request); | |
| 347 | + if (Option.isSome(requestUrl)) { | |
| 348 | + const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM); | |
| 349 | + if (websocketToken && websocketToken.trim().length > 0) { | |
| 350 | + return yield* sessions.verifyWebSocketToken(websocketToken).pipe( | |
| 351 | + Effect.map((session) => ({ | |
| 352 | + sessionId: session.sessionId, | |
| 353 | + subject: session.subject, | |
| 354 | + method: session.method, | |
| 355 | + role: session.role, | |
| 356 | + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), | |
| 357 | + })), | |
| 358 | + Effect.mapError( | |
| 359 | + (cause) => | |
| 360 | + new AuthError({ | |
| 361 | + message: "Unauthorized request.", | |
| 362 | + status: 401, | |
| 363 | + cause, | |
| 364 | + }), | |
| 365 | + ), | |
| 366 | + ); | |
| 367 | + } | |
| 368 | + } | |
| 369 | + | |
| 370 | + return yield* authenticateRequest(request); | |
| 371 | + }); | |
| 372 | + | |
| 373 | + return { | |
| 374 | + getDescriptor: () => Effect.succeed(descriptor), | |
| 375 | + getSessionState, | |
| 376 | + exchangeBootstrapCredential, | |
| 377 | + exchangeBootstrapCredentialForBearerSession, | |
| 378 | + issuePairingCredential, | |
| 379 | + listPairingLinks, | |
| 380 | + revokePairingLink, | |
| 381 | + listClientSessions, | |
| 382 | + revokeClientSession, | |
| 383 | + revokeOtherClientSessions, | |
| 384 | + authenticateHttpRequest: authenticateRequest, | |
| 385 | + authenticateWebSocketUpgrade, | |
| 386 | + issueWebSocketToken, | |
| 387 | + issueStartupPairingUrl, | |
| 388 | + } satisfies ServerAuthShape; | |
| 389 | +}); | |
| 390 | + | |
| 391 | +export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( | |
| 392 | + Layer.provideMerge(AuthControlPlaneLive), | |
| 393 | + Layer.provideMerge(AuthCoreLive), | |
| 394 | + Layer.provideMerge(ServerAuthPolicyLive), | |
| 395 | +); | |
| @@ -0,0 +1,114 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Effect, Layer } from "effect"; | |
| 4 | + | |
| 5 | +import type { ServerConfigShape } from "../../config.ts"; | |
| 6 | +import { ServerConfig } from "../../config.ts"; | |
| 7 | +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; | |
| 8 | +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; | |
| 9 | + | |
| 10 | +const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) => | |
| 11 | + ServerAuthPolicyLive.pipe( | |
| 12 | + Layer.provide( | |
| 13 | + Layer.effect( | |
| 14 | + ServerConfig, | |
| 15 | + Effect.gen(function* () { | |
| 16 | + const config = yield* ServerConfig; | |
| 17 | + return { | |
| 18 | + ...config, | |
| 19 | + ...overrides, | |
| 20 | + } satisfies ServerConfigShape; | |
| 21 | + }), | |
| 22 | + ).pipe( | |
| 23 | + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), | |
| 24 | + ), | |
| 25 | + ), | |
| 26 | + ); | |
| 27 | + | |
| 28 | +it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { | |
| 29 | + it.effect("uses desktop-managed-local policy for desktop mode", () => | |
| 30 | + Effect.gen(function* () { | |
| 31 | + const policy = yield* ServerAuthPolicy; | |
| 32 | + const descriptor = yield* policy.getDescriptor(); | |
| 33 | + | |
| 34 | + expect(descriptor.policy).toBe("desktop-managed-local"); | |
| 35 | + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); | |
| 36 | + expect(descriptor.sessionCookieName).toBe("t3_session_3773"); | |
| 37 | + }).pipe( | |
| 38 | + Effect.provide( | |
| 39 | + makeServerAuthPolicyLayer({ | |
| 40 | + mode: "desktop", | |
| 41 | + port: 3773, | |
| 42 | + }), | |
| 43 | + ), | |
| 44 | + ), | |
| 45 | + ); | |
| 46 | + | |
| 47 | + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => | |
| 48 | + Effect.gen(function* () { | |
| 49 | + const policy = yield* ServerAuthPolicy; | |
| 50 | + const descriptor = yield* policy.getDescriptor(); | |
| 51 | + | |
| 52 | + expect(descriptor.policy).toBe("remote-reachable"); | |
| 53 | + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); | |
| 54 | + }).pipe( | |
| 55 | + Effect.provide( | |
| 56 | + makeServerAuthPolicyLayer({ | |
| 57 | + mode: "desktop", | |
| 58 | + host: "0.0.0.0", | |
| 59 | + }), | |
| 60 | + ), | |
| 61 | + ), | |
| 62 | + ); | |
| 63 | + | |
| 64 | + it.effect("uses loopback-browser policy for loopback web hosts", () => | |
| 65 | + Effect.gen(function* () { | |
| 66 | + const policy = yield* ServerAuthPolicy; | |
| 67 | + const descriptor = yield* policy.getDescriptor(); | |
| 68 | + | |
| 69 | + expect(descriptor.policy).toBe("loopback-browser"); | |
| 70 | + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 71 | + expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 72 | + }).pipe( | |
| 73 | + Effect.provide( | |
| 74 | + makeServerAuthPolicyLayer({ | |
| 75 | + mode: "web", | |
| 76 | + host: "127.0.0.1", | |
| 77 | + }), | |
| 78 | + ), | |
| 79 | + ), | |
| 80 | + ); | |
| 81 | + | |
| 82 | + it.effect("uses remote-reachable policy for wildcard web hosts", () => | |
| 83 | + Effect.gen(function* () { | |
| 84 | + const policy = yield* ServerAuthPolicy; | |
| 85 | + const descriptor = yield* policy.getDescriptor(); | |
| 86 | + | |
| 87 | + expect(descriptor.policy).toBe("remote-reachable"); | |
| 88 | + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | |
| 89 | + }).pipe( | |
| 90 | + Effect.provide( | |
| 91 | + makeServerAuthPolicyLayer({ | |
| 92 | + mode: "web", | |
| 93 | + host: "0.0.0.0", | |
| 94 | + }), | |
| 95 | + ), | |
| 96 | + ), | |
| 97 | + ); | |
| 98 | + | |
| 99 | + it.effect("uses remote-reachable policy for non-loopback web hosts", () => | |
| 100 | + Effect.gen(function* () { | |
| 101 | + const policy = yield* ServerAuthPolicy; | |
| 102 | + const descriptor = yield* policy.getDescriptor(); | |
| 103 | + | |
| 104 | + expect(descriptor.policy).toBe("remote-reachable"); | |
| 105 | + }).pipe( | |
| 106 | + Effect.provide( | |
| 107 | + makeServerAuthPolicyLayer({ | |
| 108 | + mode: "web", | |
| 109 | + host: "192.168.1.50", | |
| 110 | + }), | |
| 111 | + ), | |
| 112 | + ), | |
| 113 | + ); | |
| 114 | +}); | |
| @@ -0,0 +1,44 @@ | ||
| 1 | +import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | +import { Effect, Layer } from "effect"; | |
| 3 | + | |
| 4 | +import { ServerConfig } from "../../config.ts"; | |
| 5 | +import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; | |
| 6 | +import { resolveSessionCookieName } from "../utils.ts"; | |
| 7 | +import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; | |
| 8 | + | |
| 9 | +export const makeServerAuthPolicy = Effect.gen(function* () { | |
| 10 | + const config = yield* ServerConfig; | |
| 11 | + const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | |
| 12 | + | |
| 13 | + const policy = | |
| 14 | + config.mode === "desktop" | |
| 15 | + ? isRemoteReachable | |
| 16 | + ? "remote-reachable" | |
| 17 | + : "desktop-managed-local" | |
| 18 | + : isRemoteReachable | |
| 19 | + ? "remote-reachable" | |
| 20 | + : "loopback-browser"; | |
| 21 | + | |
| 22 | + const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = | |
| 23 | + policy === "desktop-managed-local" | |
| 24 | + ? ["desktop-bootstrap"] | |
| 25 | + : config.mode === "desktop" && policy === "remote-reachable" | |
| 26 | + ? ["desktop-bootstrap", "one-time-token"] | |
| 27 | + : ["one-time-token"]; | |
| 28 | + | |
| 29 | + const descriptor: ServerAuthDescriptor = { | |
| 30 | + policy, | |
| 31 | + bootstrapMethods, | |
| 32 | + sessionMethods: ["browser-session-cookie", "bearer-session-token"], | |
| 33 | + sessionCookieName: resolveSessionCookieName({ | |
| 34 | + mode: config.mode, | |
| 35 | + port: config.port, | |
| 36 | + }), | |
| 37 | + }; | |
| 38 | + | |
| 39 | + return { | |
| 40 | + getDescriptor: () => Effect.succeed(descriptor), | |
| 41 | + } satisfies ServerAuthPolicyShape; | |
| 42 | +}); | |
| 43 | + | |
| 44 | +export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); | |
| @@ -0,0 +1,263 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect"; | |
| 4 | +import * as PlatformError from "effect/PlatformError"; | |
| 5 | + | |
| 6 | +import { ServerConfig } from "../../config.ts"; | |
| 7 | +import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; | |
| 8 | +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 9 | + | |
| 10 | +const makeServerConfigLayer = () => | |
| 11 | + ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); | |
| 12 | + | |
| 13 | +const makeServerSecretStoreLayer = () => | |
| 14 | + Layer.provide(ServerSecretStoreLive, makeServerConfigLayer()); | |
| 15 | + | |
| 16 | +const PermissionDeniedFileSystemLayer = Layer.effect( | |
| 17 | + FileSystem.FileSystem, | |
| 18 | + Effect.gen(function* () { | |
| 19 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 20 | + | |
| 21 | + return { | |
| 22 | + ...fileSystem, | |
| 23 | + readFile: (path) => | |
| 24 | + Effect.fail( | |
| 25 | + PlatformError.systemError({ | |
| 26 | + _tag: "PermissionDenied", | |
| 27 | + module: "FileSystem", | |
| 28 | + method: "readFile", | |
| 29 | + pathOrDescriptor: path, | |
| 30 | + description: "Permission denied while reading secret file.", | |
| 31 | + }), | |
| 32 | + ), | |
| 33 | + } satisfies FileSystem.FileSystem; | |
| 34 | + }), | |
| 35 | +).pipe(Layer.provide(NodeServices.layer)); | |
| 36 | + | |
| 37 | +const makePermissionDeniedSecretStoreLayer = () => | |
| 38 | + ServerSecretStoreLive.pipe( | |
| 39 | + Layer.provide(makeServerConfigLayer()), | |
| 40 | + Layer.provideMerge(PermissionDeniedFileSystemLayer), | |
| 41 | + ); | |
| 42 | + | |
| 43 | +const RenameFailureFileSystemLayer = Layer.effect( | |
| 44 | + FileSystem.FileSystem, | |
| 45 | + Effect.gen(function* () { | |
| 46 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 47 | + | |
| 48 | + return { | |
| 49 | + ...fileSystem, | |
| 50 | + rename: (from, to) => | |
| 51 | + Effect.fail( | |
| 52 | + PlatformError.systemError({ | |
| 53 | + _tag: "PermissionDenied", | |
| 54 | + module: "FileSystem", | |
| 55 | + method: "rename", | |
| 56 | + pathOrDescriptor: `${String(from)} -> ${String(to)}`, | |
| 57 | + description: "Permission denied while persisting secret file.", | |
| 58 | + }), | |
| 59 | + ), | |
| 60 | + } satisfies FileSystem.FileSystem; | |
| 61 | + }), | |
| 62 | +).pipe(Layer.provide(NodeServices.layer)); | |
| 63 | + | |
| 64 | +const makeRenameFailureSecretStoreLayer = () => | |
| 65 | + ServerSecretStoreLive.pipe( | |
| 66 | + Layer.provide(makeServerConfigLayer()), | |
| 67 | + Layer.provideMerge(RenameFailureFileSystemLayer), | |
| 68 | + ); | |
| 69 | + | |
| 70 | +const RemoveFailureFileSystemLayer = Layer.effect( | |
| 71 | + FileSystem.FileSystem, | |
| 72 | + Effect.gen(function* () { | |
| 73 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 74 | + | |
| 75 | + return { | |
| 76 | + ...fileSystem, | |
| 77 | + remove: (path, options) => | |
| 78 | + Effect.fail( | |
| 79 | + PlatformError.systemError({ | |
| 80 | + _tag: "PermissionDenied", | |
| 81 | + module: "FileSystem", | |
| 82 | + method: "remove", | |
| 83 | + pathOrDescriptor: String(path), | |
| 84 | + description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, | |
| 85 | + }), | |
| 86 | + ), | |
| 87 | + } satisfies FileSystem.FileSystem; | |
| 88 | + }), | |
| 89 | +).pipe(Layer.provide(NodeServices.layer)); | |
| 90 | + | |
| 91 | +const makeRemoveFailureSecretStoreLayer = () => | |
| 92 | + ServerSecretStoreLive.pipe( | |
| 93 | + Layer.provide(makeServerConfigLayer()), | |
| 94 | + Layer.provideMerge(RemoveFailureFileSystemLayer), | |
| 95 | + ); | |
| 96 | + | |
| 97 | +const ConcurrentReadMissFileSystemLayer = Layer.effect( | |
| 98 | + FileSystem.FileSystem, | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 101 | + const readCountRef = yield* Ref.make(0); | |
| 102 | + const readBarrier = yield* Deferred.make<void>(); | |
| 103 | + | |
| 104 | + return { | |
| 105 | + ...fileSystem, | |
| 106 | + readFile: (path) => | |
| 107 | + String(path).endsWith("/session-signing-key.bin") | |
| 108 | + ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( | |
| 109 | + Effect.flatMap((count) => { | |
| 110 | + if (count > 2) { | |
| 111 | + return fileSystem.readFile(path); | |
| 112 | + } | |
| 113 | + return Effect.gen(function* () { | |
| 114 | + if (count === 2) { | |
| 115 | + yield* Deferred.succeed(readBarrier, void 0); | |
| 116 | + } | |
| 117 | + yield* Deferred.await(readBarrier); | |
| 118 | + return yield* Effect.failCause( | |
| 119 | + Cause.fail( | |
| 120 | + PlatformError.systemError({ | |
| 121 | + _tag: "NotFound", | |
| 122 | + module: "FileSystem", | |
| 123 | + method: "readFile", | |
| 124 | + pathOrDescriptor: String(path), | |
| 125 | + description: "Secret file does not exist yet.", | |
| 126 | + }), | |
| 127 | + ), | |
| 128 | + ); | |
| 129 | + }); | |
| 130 | + }), | |
| 131 | + ) | |
| 132 | + : fileSystem.readFile(path), | |
| 133 | + } satisfies FileSystem.FileSystem; | |
| 134 | + }), | |
| 135 | +).pipe(Layer.provide(NodeServices.layer)); | |
| 136 | + | |
| 137 | +const makeConcurrentCreateSecretStoreLayer = () => | |
| 138 | + ServerSecretStoreLive.pipe( | |
| 139 | + Layer.provide(makeServerConfigLayer()), | |
| 140 | + Layer.provideMerge(ConcurrentReadMissFileSystemLayer), | |
| 141 | + ); | |
| 142 | + | |
| 143 | +it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { | |
| 144 | + it.effect("returns null when a secret file does not exist", () => | |
| 145 | + Effect.gen(function* () { | |
| 146 | + const secretStore = yield* ServerSecretStore; | |
| 147 | + | |
| 148 | + const secret = yield* secretStore.get("missing-secret"); | |
| 149 | + | |
| 150 | + expect(secret).toBeNull(); | |
| 151 | + }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 152 | + ); | |
| 153 | + | |
| 154 | + it.effect("reuses an existing secret instead of regenerating it", () => | |
| 155 | + Effect.gen(function* () { | |
| 156 | + const secretStore = yield* ServerSecretStore; | |
| 157 | + | |
| 158 | + const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 159 | + const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); | |
| 160 | + | |
| 161 | + expect(Array.from(second)).toEqual(Array.from(first)); | |
| 162 | + }).pipe(Effect.provide(makeServerSecretStoreLayer())), | |
| 163 | + ); | |
| 164 | + | |
| 165 | + it.effect("returns the persisted secret when concurrent creators race", () => | |
| 166 | + Effect.gen(function* () { | |
| 167 | + const secretStore = yield* ServerSecretStore; | |
| 168 | + | |
| 169 | + const [first, second] = yield* Effect.all( | |
| 170 | + [ | |
| 171 | + secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 172 | + secretStore.getOrCreateRandom("session-signing-key", 32), | |
| 173 | + ], | |
| 174 | + { concurrency: "unbounded" }, | |
| 175 | + ); | |
| 176 | + const persisted = yield* secretStore.get("session-signing-key"); | |
| 177 | + | |
| 178 | + expect(persisted).not.toBeNull(); | |
| 179 | + expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 180 | + expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); | |
| 181 | + }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), | |
| 182 | + ); | |
| 183 | + | |
| 184 | + it.effect("uses restrictive permissions for the secret directory and files", () => | |
| 185 | + Effect.gen(function* () { | |
| 186 | + const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; | |
| 187 | + const recordingFileSystemLayer = Layer.effect( | |
| 188 | + FileSystem.FileSystem, | |
| 189 | + Effect.gen(function* () { | |
| 190 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 191 | + | |
| 192 | + return { | |
| 193 | + ...fileSystem, | |
| 194 | + makeDirectory: () => Effect.void, | |
| 195 | + writeFile: () => Effect.void, | |
| 196 | + rename: () => Effect.void, | |
| 197 | + chmod: (path, mode) => | |
| 198 | + Effect.sync(() => { | |
| 199 | + chmodCalls.push({ path: String(path), mode }); | |
| 200 | + }), | |
| 201 | + } satisfies FileSystem.FileSystem; | |
| 202 | + }), | |
| 203 | + ).pipe(Layer.provide(NodeServices.layer)); | |
| 204 | + | |
| 205 | + const secretStore = yield* Effect.service(ServerSecretStore).pipe( | |
| 206 | + Effect.provide( | |
| 207 | + ServerSecretStoreLive.pipe( | |
| 208 | + Layer.provide(makeServerConfigLayer()), | |
| 209 | + Layer.provideMerge(recordingFileSystemLayer), | |
| 210 | + ), | |
| 211 | + ), | |
| 212 | + ); | |
| 213 | + | |
| 214 | + yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); | |
| 215 | + | |
| 216 | + expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( | |
| 217 | + true, | |
| 218 | + ); | |
| 219 | + expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); | |
| 220 | + }).pipe(Effect.provide(NodeServices.layer)), | |
| 221 | + ); | |
| 222 | + | |
| 223 | + it.effect("propagates read failures other than missing-file errors", () => | |
| 224 | + Effect.gen(function* () { | |
| 225 | + const secretStore = yield* ServerSecretStore; | |
| 226 | + | |
| 227 | + const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); | |
| 228 | + | |
| 229 | + expect(error).toBeInstanceOf(SecretStoreError); | |
| 230 | + expect(error.message).toContain("Failed to read secret session-signing-key."); | |
| 231 | + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 232 | + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 233 | + }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), | |
| 234 | + ); | |
| 235 | + | |
| 236 | + it.effect("propagates write failures instead of treating them as success", () => | |
| 237 | + Effect.gen(function* () { | |
| 238 | + const secretStore = yield* ServerSecretStore; | |
| 239 | + | |
| 240 | + const error = yield* Effect.flip( | |
| 241 | + secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), | |
| 242 | + ); | |
| 243 | + | |
| 244 | + expect(error).toBeInstanceOf(SecretStoreError); | |
| 245 | + expect(error.message).toContain("Failed to persist secret session-signing-key."); | |
| 246 | + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 247 | + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 248 | + }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), | |
| 249 | + ); | |
| 250 | + | |
| 251 | + it.effect("propagates remove failures other than missing-file errors", () => | |
| 252 | + Effect.gen(function* () { | |
| 253 | + const secretStore = yield* ServerSecretStore; | |
| 254 | + | |
| 255 | + const error = yield* Effect.flip(secretStore.remove("session-signing-key")); | |
| 256 | + | |
| 257 | + expect(error).toBeInstanceOf(SecretStoreError); | |
| 258 | + expect(error.message).toContain("Failed to remove secret session-signing-key."); | |
| 259 | + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); | |
| 260 | + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); | |
| 261 | + }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), | |
| 262 | + ); | |
| 263 | +}); | |
| @@ -0,0 +1,148 @@ | ||
| 1 | +import * as Crypto from "node:crypto"; | |
| 2 | + | |
| 3 | +import { Effect, FileSystem, Layer, Path, Predicate } from "effect"; | |
| 4 | +import * as PlatformError from "effect/PlatformError"; | |
| 5 | + | |
| 6 | +import { ServerConfig } from "../../config.ts"; | |
| 7 | +import { | |
| 8 | + SecretStoreError, | |
| 9 | + ServerSecretStore, | |
| 10 | + type ServerSecretStoreShape, | |
| 11 | +} from "../Services/ServerSecretStore.ts"; | |
| 12 | + | |
| 13 | +export const makeServerSecretStore = Effect.gen(function* () { | |
| 14 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 15 | + const path = yield* Path.Path; | |
| 16 | + const serverConfig = yield* ServerConfig; | |
| 17 | + | |
| 18 | + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); | |
| 19 | + yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( | |
| 20 | + Effect.mapError( | |
| 21 | + (cause) => | |
| 22 | + new SecretStoreError({ | |
| 23 | + message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, | |
| 24 | + cause, | |
| 25 | + }), | |
| 26 | + ), | |
| 27 | + ); | |
| 28 | + | |
| 29 | + const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | |
| 30 | + | |
| 31 | + const isPlatformError = (u: unknown): u is PlatformError.PlatformError => | |
| 32 | + Predicate.isTagged(u, "PlatformError"); | |
| 33 | + | |
| 34 | + const get: ServerSecretStoreShape["get"] = (name) => | |
| 35 | + fileSystem.readFile(resolveSecretPath(name)).pipe( | |
| 36 | + Effect.map((bytes) => Uint8Array.from(bytes)), | |
| 37 | + Effect.catch((cause) => | |
| 38 | + cause.reason._tag === "NotFound" | |
| 39 | + ? Effect.succeed(null) | |
| 40 | + : Effect.fail( | |
| 41 | + new SecretStoreError({ | |
| 42 | + message: `Failed to read secret ${name}.`, | |
| 43 | + cause, | |
| 44 | + }), | |
| 45 | + ), | |
| 46 | + ), | |
| 47 | + ); | |
| 48 | + | |
| 49 | + const set: ServerSecretStoreShape["set"] = (name, value) => { | |
| 50 | + const secretPath = resolveSecretPath(name); | |
| 51 | + const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; | |
| 52 | + return Effect.gen(function* () { | |
| 53 | + yield* fileSystem.writeFile(tempPath, value); | |
| 54 | + yield* fileSystem.chmod(tempPath, 0o600); | |
| 55 | + yield* fileSystem.rename(tempPath, secretPath); | |
| 56 | + yield* fileSystem.chmod(secretPath, 0o600); | |
| 57 | + }).pipe( | |
| 58 | + Effect.catch((cause) => | |
| 59 | + fileSystem.remove(tempPath).pipe( | |
| 60 | + Effect.ignore, | |
| 61 | + Effect.flatMap(() => | |
| 62 | + Effect.fail( | |
| 63 | + new SecretStoreError({ | |
| 64 | + message: `Failed to persist secret ${name}.`, | |
| 65 | + cause, | |
| 66 | + }), | |
| 67 | + ), | |
| 68 | + ), | |
| 69 | + ), | |
| 70 | + ), | |
| 71 | + ); | |
| 72 | + }; | |
| 73 | + | |
| 74 | + const create: ServerSecretStoreShape["set"] = (name, value) => { | |
| 75 | + const secretPath = resolveSecretPath(name); | |
| 76 | + return Effect.scoped( | |
| 77 | + Effect.gen(function* () { | |
| 78 | + const file = yield* fileSystem.open(secretPath, { | |
| 79 | + flag: "wx", | |
| 80 | + mode: 0o600, | |
| 81 | + }); | |
| 82 | + yield* file.writeAll(value); | |
| 83 | + yield* file.sync; | |
| 84 | + yield* fileSystem.chmod(secretPath, 0o600); | |
| 85 | + }), | |
| 86 | + ).pipe( | |
| 87 | + Effect.mapError( | |
| 88 | + (cause) => | |
| 89 | + new SecretStoreError({ | |
| 90 | + message: `Failed to persist secret ${name}.`, | |
| 91 | + cause, | |
| 92 | + }), | |
| 93 | + ), | |
| 94 | + ); | |
| 95 | + }; | |
| 96 | + | |
| 97 | + const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => | |
| 98 | + get(name).pipe( | |
| 99 | + Effect.flatMap((existing) => { | |
| 100 | + if (existing) { | |
| 101 | + return Effect.succeed(existing); | |
| 102 | + } | |
| 103 | + | |
| 104 | + const generated = Crypto.randomBytes(bytes); | |
| 105 | + return create(name, generated).pipe( | |
| 106 | + Effect.as(Uint8Array.from(generated)), | |
| 107 | + Effect.catchTag("SecretStoreError", (error) => | |
| 108 | + isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists" | |
| 109 | + ? get(name).pipe( | |
| 110 | + Effect.flatMap((created) => | |
| 111 | + created !== null | |
| 112 | + ? Effect.succeed(created) | |
| 113 | + : Effect.fail( | |
| 114 | + new SecretStoreError({ | |
| 115 | + message: `Failed to read secret ${name} after concurrent creation.`, | |
| 116 | + }), | |
| 117 | + ), | |
| 118 | + ), | |
| 119 | + ) | |
| 120 | + : Effect.fail(error), | |
| 121 | + ), | |
| 122 | + ); | |
| 123 | + }), | |
| 124 | + ); | |
| 125 | + | |
| 126 | + const remove: ServerSecretStoreShape["remove"] = (name) => | |
| 127 | + fileSystem.remove(resolveSecretPath(name)).pipe( | |
| 128 | + Effect.catch((cause) => | |
| 129 | + cause.reason._tag === "NotFound" | |
| 130 | + ? Effect.void | |
| 131 | + : Effect.fail( | |
| 132 | + new SecretStoreError({ | |
| 133 | + message: `Failed to remove secret ${name}.`, | |
| 134 | + cause, | |
| 135 | + }), | |
| 136 | + ), | |
| 137 | + ), | |
| 138 | + ); | |
| 139 | + | |
| 140 | + return { | |
| 141 | + get, | |
| 142 | + set, | |
| 143 | + getOrCreateRandom, | |
| 144 | + remove, | |
| 145 | + } satisfies ServerSecretStoreShape; | |
| 146 | +}); | |
| 147 | + | |
| 148 | +export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); | |
| @@ -0,0 +1,191 @@ | ||
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { expect, it } from "@effect/vitest"; | |
| 3 | +import { Duration, Effect, Layer } from "effect"; | |
| 4 | +import { TestClock } from "effect/testing"; | |
| 5 | + | |
| 6 | +import type { ServerConfigShape } from "../../config.ts"; | |
| 7 | +import { ServerConfig } from "../../config.ts"; | |
| 8 | +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | |
| 9 | +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 10 | +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; | |
| 11 | +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; | |
| 12 | + | |
| 13 | +const makeServerConfigLayer = ( | |
| 14 | + overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 15 | +) => | |
| 16 | + Layer.effect( | |
| 17 | + ServerConfig, | |
| 18 | + Effect.gen(function* () { | |
| 19 | + const config = yield* ServerConfig; | |
| 20 | + return { | |
| 21 | + ...config, | |
| 22 | + ...overrides, | |
| 23 | + } satisfies ServerConfigShape; | |
| 24 | + }), | |
| 25 | + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); | |
| 26 | + | |
| 27 | +const makeSessionCredentialLayer = ( | |
| 28 | + overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>, | |
| 29 | +) => | |
| 30 | + SessionCredentialServiceLive.pipe( | |
| 31 | + Layer.provide(SqlitePersistenceMemory), | |
| 32 | + Layer.provide(ServerSecretStoreLive), | |
| 33 | + Layer.provide(makeServerConfigLayer(overrides)), | |
| 34 | + ); | |
| 35 | + | |
| 36 | +it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { | |
| 37 | + it.effect("issues and verifies signed browser session tokens", () => | |
| 38 | + Effect.gen(function* () { | |
| 39 | + const sessions = yield* SessionCredentialService; | |
| 40 | + const issued = yield* sessions.issue({ | |
| 41 | + subject: "desktop-bootstrap", | |
| 42 | + role: "owner", | |
| 43 | + client: { | |
| 44 | + label: "Desktop app", | |
| 45 | + deviceType: "desktop", | |
| 46 | + os: "macOS", | |
| 47 | + browser: "Electron", | |
| 48 | + ipAddress: "127.0.0.1", | |
| 49 | + }, | |
| 50 | + }); | |
| 51 | + const verified = yield* sessions.verify(issued.token); | |
| 52 | + | |
| 53 | + expect(verified.method).toBe("browser-session-cookie"); | |
| 54 | + expect(verified.subject).toBe("desktop-bootstrap"); | |
| 55 | + expect(verified.role).toBe("owner"); | |
| 56 | + expect(verified.client.label).toBe("Desktop app"); | |
| 57 | + expect(verified.client.browser).toBe("Electron"); | |
| 58 | + expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); | |
| 59 | + }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 60 | + ); | |
| 61 | + it.effect("rejects malformed session tokens", () => | |
| 62 | + Effect.gen(function* () { | |
| 63 | + const sessions = yield* SessionCredentialService; | |
| 64 | + const error = yield* Effect.flip(sessions.verify("not-a-session-token")); | |
| 65 | + | |
| 66 | + expect(error._tag).toBe("SessionCredentialError"); | |
| 67 | + expect(error.message).toContain("Malformed session token"); | |
| 68 | + }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 69 | + ); | |
| 70 | + it.effect("verifies session tokens against the Effect clock", () => | |
| 71 | + Effect.gen(function* () { | |
| 72 | + const sessions = yield* SessionCredentialService; | |
| 73 | + const issued = yield* sessions.issue({ | |
| 74 | + method: "bearer-session-token", | |
| 75 | + subject: "test-clock", | |
| 76 | + }); | |
| 77 | + const verified = yield* sessions.verify(issued.token); | |
| 78 | + | |
| 79 | + expect(verified.method).toBe("bearer-session-token"); | |
| 80 | + expect(verified.subject).toBe("test-clock"); | |
| 81 | + expect(verified.role).toBe("client"); | |
| 82 | + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 83 | + ); | |
| 84 | + | |
| 85 | + it.effect("rejects websocket tokens once the parent session has expired", () => | |
| 86 | + Effect.gen(function* () { | |
| 87 | + const sessions = yield* SessionCredentialService; | |
| 88 | + const issued = yield* sessions.issue({ | |
| 89 | + method: "bearer-session-token", | |
| 90 | + subject: "short-lived", | |
| 91 | + ttl: Duration.seconds(1), | |
| 92 | + }); | |
| 93 | + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); | |
| 94 | + | |
| 95 | + yield* TestClock.adjust(Duration.seconds(2)); | |
| 96 | + | |
| 97 | + const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); | |
| 98 | + expect(error.message).toContain("expired"); | |
| 99 | + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 100 | + ); | |
| 101 | + | |
| 102 | + it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => | |
| 103 | + Effect.gen(function* () { | |
| 104 | + const sessions = yield* SessionCredentialService; | |
| 105 | + const owner = yield* sessions.issue({ | |
| 106 | + subject: "desktop-bootstrap", | |
| 107 | + role: "owner", | |
| 108 | + client: { | |
| 109 | + label: "Desktop app", | |
| 110 | + deviceType: "desktop", | |
| 111 | + os: "macOS", | |
| 112 | + browser: "Electron", | |
| 113 | + }, | |
| 114 | + }); | |
| 115 | + const client = yield* sessions.issue({ | |
| 116 | + subject: "one-time-token", | |
| 117 | + role: "client", | |
| 118 | + client: { | |
| 119 | + label: "Julius iPhone", | |
| 120 | + deviceType: "mobile", | |
| 121 | + os: "iOS", | |
| 122 | + browser: "Safari", | |
| 123 | + ipAddress: "192.168.1.88", | |
| 124 | + }, | |
| 125 | + }); | |
| 126 | + | |
| 127 | + yield* sessions.markConnected(client.sessionId); | |
| 128 | + const beforeRevoke = yield* sessions.listActive(); | |
| 129 | + const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); | |
| 130 | + const afterRevoke = yield* sessions.listActive(); | |
| 131 | + const revokedClient = yield* Effect.flip(sessions.verify(client.token)); | |
| 132 | + | |
| 133 | + expect(beforeRevoke).toHaveLength(2); | |
| 134 | + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( | |
| 135 | + true, | |
| 136 | + ); | |
| 137 | + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe( | |
| 138 | + "Julius iPhone", | |
| 139 | + ); | |
| 140 | + expect( | |
| 141 | + beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType, | |
| 142 | + ).toBe("desktop"); | |
| 143 | + expect(revokedCount).toBe(1); | |
| 144 | + expect(afterRevoke).toHaveLength(1); | |
| 145 | + expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); | |
| 146 | + expect(revokedClient.message).toContain("revoked"); | |
| 147 | + }).pipe(Effect.provide(makeSessionCredentialLayer())), | |
| 148 | + ); | |
| 149 | + | |
| 150 | + it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => | |
| 151 | + Effect.gen(function* () { | |
| 152 | + const sessions = yield* SessionCredentialService; | |
| 153 | + const issued = yield* sessions.issue({ | |
| 154 | + subject: "reconnect-test", | |
| 155 | + method: "bearer-session-token", | |
| 156 | + }); | |
| 157 | + | |
| 158 | + const beforeConnect = yield* sessions.listActive(); | |
| 159 | + expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); | |
| 160 | + | |
| 161 | + yield* TestClock.adjust(Duration.seconds(1)); | |
| 162 | + yield* sessions.markConnected(issued.sessionId); | |
| 163 | + const firstConnect = yield* sessions.listActive(); | |
| 164 | + const firstConnectedAt = firstConnect[0]?.lastConnectedAt; | |
| 165 | + | |
| 166 | + expect(firstConnect[0]?.connected).toBe(true); | |
| 167 | + expect(firstConnectedAt).not.toBeNull(); | |
| 168 | + | |
| 169 | + yield* TestClock.adjust(Duration.seconds(1)); | |
| 170 | + yield* sessions.markConnected(issued.sessionId); | |
| 171 | + const stillConnected = yield* sessions.listActive(); | |
| 172 | + | |
| 173 | + expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 174 | + | |
| 175 | + yield* sessions.markDisconnected(issued.sessionId); | |
| 176 | + yield* sessions.markDisconnected(issued.sessionId); | |
| 177 | + const afterDisconnect = yield* sessions.listActive(); | |
| 178 | + | |
| 179 | + expect(afterDisconnect[0]?.connected).toBe(false); | |
| 180 | + expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); | |
| 181 | + | |
| 182 | + yield* TestClock.adjust(Duration.seconds(1)); | |
| 183 | + yield* sessions.markConnected(issued.sessionId); | |
| 184 | + const afterReconnect = yield* sessions.listActive(); | |
| 185 | + | |
| 186 | + expect(afterReconnect[0]?.connected).toBe(true); | |
| 187 | + expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); | |
| 188 | + expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); | |
| 189 | + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), | |
| 190 | + ); | |
| 191 | +}); | |
| @@ -0,0 +1,500 @@ | ||
| 1 | +import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; | |
| 2 | +import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; | |
| 3 | +import { Option } from "effect"; | |
| 4 | + | |
| 5 | +import { ServerConfig } from "../../config.ts"; | |
| 6 | +import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; | |
| 7 | +import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; | |
| 8 | +import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; | |
| 9 | +import { | |
| 10 | + SessionCredentialError, | |
| 11 | + SessionCredentialService, | |
| 12 | + type IssuedSession, | |
| 13 | + type SessionCredentialChange, | |
| 14 | + type SessionCredentialServiceShape, | |
| 15 | + type VerifiedSession, | |
| 16 | +} from "../Services/SessionCredentialService.ts"; | |
| 17 | +import { | |
| 18 | + base64UrlDecodeUtf8, | |
| 19 | + base64UrlEncode, | |
| 20 | + resolveSessionCookieName, | |
| 21 | + signPayload, | |
| 22 | + timingSafeEqualBase64Url, | |
| 23 | +} from "../utils.ts"; | |
| 24 | + | |
| 25 | +const SIGNING_SECRET_NAME = "server-signing-key"; | |
| 26 | +const DEFAULT_SESSION_TTL = Duration.days(30); | |
| 27 | +const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); | |
| 28 | + | |
| 29 | +const SessionClaims = Schema.Struct({ | |
| 30 | + v: Schema.Literal(1), | |
| 31 | + kind: Schema.Literal("session"), | |
| 32 | + sid: AuthSessionId, | |
| 33 | + sub: Schema.String, | |
| 34 | + role: Schema.Literals(["owner", "client"]), | |
| 35 | + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), | |
| 36 | + iat: Schema.Number, | |
| 37 | + exp: Schema.Number, | |
| 38 | +}); | |
| 39 | +type SessionClaims = typeof SessionClaims.Type; | |
| 40 | + | |
| 41 | +const WebSocketClaims = Schema.Struct({ | |
| 42 | + v: Schema.Literal(1), | |
| 43 | + kind: Schema.Literal("websocket"), | |
| 44 | + sid: AuthSessionId, | |
| 45 | + iat: Schema.Number, | |
| 46 | + exp: Schema.Number, | |
| 47 | +}); | |
| 48 | +type WebSocketClaims = typeof WebSocketClaims.Type; | |
| 49 | + | |
| 50 | +const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims)); | |
| 51 | +const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims)); | |
| 52 | + | |
| 53 | +function createDefaultClientMetadata(): AuthClientMetadata { | |
| 54 | + return { | |
| 55 | + deviceType: "unknown", | |
| 56 | + }; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function toClientMetadata(record: { | |
| 60 | + readonly label: string | null; | |
| 61 | + readonly ipAddress: string | null; | |
| 62 | + readonly userAgent: string | null; | |
| 63 | + readonly deviceType: AuthClientMetadata["deviceType"]; | |
| 64 | + readonly os: string | null; | |
| 65 | + readonly browser: string | null; | |
| 66 | +}): AuthClientMetadata { | |
| 67 | + return { | |
| 68 | + ...(record.label ? { label: record.label } : {}), | |
| 69 | + ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), | |
| 70 | + ...(record.userAgent ? { userAgent: record.userAgent } : {}), | |
| 71 | + deviceType: record.deviceType, | |
| 72 | + ...(record.os ? { os: record.os } : {}), | |
| 73 | + ...(record.browser ? { browser: record.browser } : {}), | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession { | |
| 78 | + return { | |
| 79 | + ...input, | |
| 80 | + current: false, | |
| 81 | + }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export const makeSessionCredentialService = Effect.gen(function* () { | |
| 85 | + const serverConfig = yield* ServerConfig; | |
| 86 | + const secretStore = yield* ServerSecretStore; | |
| 87 | + const authSessions = yield* AuthSessionRepository; | |
| 88 | + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | |
| 89 | + const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); | |
| 90 | + const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); | |
| 91 | + const cookieName = resolveSessionCookieName({ | |
| 92 | + mode: serverConfig.mode, | |
| 93 | + port: serverConfig.port, | |
| 94 | + }); | |
| 95 | + | |
| 96 | + const toSessionCredentialError = (message: string) => (cause: unknown) => | |
| 97 | + new SessionCredentialError({ | |
| 98 | + message, | |
| 99 | + cause, | |
| 100 | + }); | |
| 101 | + | |
| 102 | + const emitUpsert = (clientSession: AuthClientSession) => | |
| 103 | + PubSub.publish(changesPubSub, { | |
| 104 | + type: "clientUpserted", | |
| 105 | + clientSession, | |
| 106 | + }).pipe(Effect.asVoid); | |
| 107 | + | |
| 108 | + const emitRemoved = (sessionId: AuthSessionId) => | |
| 109 | + PubSub.publish(changesPubSub, { | |
| 110 | + type: "clientRemoved", | |
| 111 | + sessionId, | |
| 112 | + }).pipe(Effect.asVoid); | |
| 113 | + | |
| 114 | + const loadActiveSession = (sessionId: AuthSessionId) => | |
| 115 | + Effect.gen(function* () { | |
| 116 | + const row = yield* authSessions.getById({ sessionId }); | |
| 117 | + if (Option.isNone(row) || row.value.revokedAt !== null) { | |
| 118 | + return Option.none<AuthClientSession>(); | |
| 119 | + } | |
| 120 | + | |
| 121 | + const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 122 | + return Option.some( | |
| 123 | + toAuthClientSession({ | |
| 124 | + sessionId: row.value.sessionId, | |
| 125 | + subject: row.value.subject, | |
| 126 | + role: row.value.role, | |
| 127 | + method: row.value.method, | |
| 128 | + client: toClientMetadata(row.value.client), | |
| 129 | + issuedAt: row.value.issuedAt, | |
| 130 | + expiresAt: row.value.expiresAt, | |
| 131 | + lastConnectedAt: row.value.lastConnectedAt, | |
| 132 | + connected: connectedSessions.has(row.value.sessionId), | |
| 133 | + }), | |
| 134 | + ); | |
| 135 | + }); | |
| 136 | + | |
| 137 | + const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) => | |
| 138 | + Ref.modify(connectedSessionsRef, (current) => { | |
| 139 | + const next = new Map(current); | |
| 140 | + const wasDisconnected = !next.has(sessionId); | |
| 141 | + next.set(sessionId, (next.get(sessionId) ?? 0) + 1); | |
| 142 | + return [wasDisconnected, next] as const; | |
| 143 | + }).pipe( | |
| 144 | + Effect.flatMap((wasDisconnected) => | |
| 145 | + wasDisconnected | |
| 146 | + ? DateTime.now.pipe( | |
| 147 | + Effect.flatMap((lastConnectedAt) => | |
| 148 | + authSessions.setLastConnectedAt({ | |
| 149 | + sessionId, | |
| 150 | + lastConnectedAt, | |
| 151 | + }), | |
| 152 | + ), | |
| 153 | + ) | |
| 154 | + : Effect.void, | |
| 155 | + ), | |
| 156 | + Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 157 | + Effect.flatMap((session) => | |
| 158 | + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 159 | + ), | |
| 160 | + Effect.catchCause((cause) => | |
| 161 | + Effect.logError("Failed to publish connected-session auth update.").pipe( | |
| 162 | + Effect.annotateLogs({ | |
| 163 | + sessionId, | |
| 164 | + cause, | |
| 165 | + }), | |
| 166 | + ), | |
| 167 | + ), | |
| 168 | + ); | |
| 169 | + | |
| 170 | + const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) => | |
| 171 | + Ref.update(connectedSessionsRef, (current) => { | |
| 172 | + const next = new Map(current); | |
| 173 | + const remaining = (next.get(sessionId) ?? 0) - 1; | |
| 174 | + if (remaining > 0) { | |
| 175 | + next.set(sessionId, remaining); | |
| 176 | + } else { | |
| 177 | + next.delete(sessionId); | |
| 178 | + } | |
| 179 | + return next; | |
| 180 | + }).pipe( | |
| 181 | + Effect.flatMap(() => loadActiveSession(sessionId)), | |
| 182 | + Effect.flatMap((session) => | |
| 183 | + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, | |
| 184 | + ), | |
| 185 | + Effect.catchCause((cause) => | |
| 186 | + Effect.logError("Failed to publish disconnected-session auth update.").pipe( | |
| 187 | + Effect.annotateLogs({ | |
| 188 | + sessionId, | |
| 189 | + cause, | |
| 190 | + }), | |
| 191 | + ), | |
| 192 | + ), | |
| 193 | + ); | |
| 194 | + | |
| 195 | + const issue: SessionCredentialServiceShape["issue"] = (input) => | |
| 196 | + Effect.gen(function* () { | |
| 197 | + const sessionId = AuthSessionId.make(crypto.randomUUID()); | |
| 198 | + const issuedAt = yield* DateTime.now; | |
| 199 | + const expiresAt = DateTime.add(issuedAt, { | |
| 200 | + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), | |
| 201 | + }); | |
| 202 | + const claims: SessionClaims = { | |
| 203 | + v: 1, | |
| 204 | + kind: "session", | |
| 205 | + sid: sessionId, | |
| 206 | + sub: input?.subject ?? "browser", | |
| 207 | + role: input?.role ?? "client", | |
| 208 | + method: input?.method ?? "browser-session-cookie", | |
| 209 | + iat: issuedAt.epochMilliseconds, | |
| 210 | + exp: expiresAt.epochMilliseconds, | |
| 211 | + }; | |
| 212 | + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); | |
| 213 | + const signature = signPayload(encodedPayload, signingSecret); | |
| 214 | + const client = input?.client ?? createDefaultClientMetadata(); | |
| 215 | + yield* authSessions.create({ | |
| 216 | + sessionId, | |
| 217 | + subject: claims.sub, | |
| 218 | + role: claims.role, | |
| 219 | + method: claims.method, | |
| 220 | + client: { | |
| 221 | + label: client.label ?? null, | |
| 222 | + ipAddress: client.ipAddress ?? null, | |
| 223 | + userAgent: client.userAgent ?? null, | |
| 224 | + deviceType: client.deviceType, | |
| 225 | + os: client.os ?? null, | |
| 226 | + browser: client.browser ?? null, | |
| 227 | + }, | |
| 228 | + issuedAt, | |
| 229 | + expiresAt, | |
| 230 | + }); | |
| 231 | + yield* emitUpsert( | |
| 232 | + toAuthClientSession({ | |
| 233 | + sessionId, | |
| 234 | + subject: claims.sub, | |
| 235 | + role: claims.role, | |
| 236 | + method: claims.method, | |
| 237 | + client, | |
| 238 | + issuedAt, | |
| 239 | + expiresAt, | |
| 240 | + lastConnectedAt: null, | |
| 241 | + connected: false, | |
| 242 | + }), | |
| 243 | + ); | |
| 244 | + | |
| 245 | + return { | |
| 246 | + sessionId, | |
| 247 | + token: `${encodedPayload}.${signature}`, | |
| 248 | + method: claims.method, | |
| 249 | + client, | |
| 250 | + expiresAt: expiresAt, | |
| 251 | + role: claims.role, | |
| 252 | + } satisfies IssuedSession; | |
| 253 | + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); | |
| 254 | + | |
| 255 | + const verify: SessionCredentialServiceShape["verify"] = (token) => | |
| 256 | + Effect.gen(function* () { | |
| 257 | + const [encodedPayload, signature] = token.split("."); | |
| 258 | + if (!encodedPayload || !signature) { | |
| 259 | + return yield* new SessionCredentialError({ | |
| 260 | + message: "Malformed session token.", | |
| 261 | + }); | |
| 262 | + } | |
| 263 | + | |
| 264 | + const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 265 | + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 266 | + return yield* new SessionCredentialError({ | |
| 267 | + message: "Invalid session token signature.", | |
| 268 | + }); | |
| 269 | + } | |
| 270 | + | |
| 271 | + const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | |
| 272 | + Effect.mapError( | |
| 273 | + (cause) => | |
| 274 | + new SessionCredentialError({ | |
| 275 | + message: "Invalid session token payload.", | |
| 276 | + cause, | |
| 277 | + }), | |
| 278 | + ), | |
| 279 | + ); | |
| 280 | + | |
| 281 | + const now = yield* Clock.currentTimeMillis; | |
| 282 | + if (claims.exp <= now) { | |
| 283 | + return yield* new SessionCredentialError({ | |
| 284 | + message: "Session token expired.", | |
| 285 | + }); | |
| 286 | + } | |
| 287 | + | |
| 288 | + const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 289 | + if (Option.isNone(row)) { | |
| 290 | + return yield* new SessionCredentialError({ | |
| 291 | + message: "Unknown session token.", | |
| 292 | + }); | |
| 293 | + } | |
| 294 | + if (row.value.revokedAt !== null) { | |
| 295 | + return yield* new SessionCredentialError({ | |
| 296 | + message: "Session token revoked.", | |
| 297 | + }); | |
| 298 | + } | |
| 299 | + | |
| 300 | + return { | |
| 301 | + sessionId: claims.sid, | |
| 302 | + token, | |
| 303 | + method: claims.method, | |
| 304 | + client: toClientMetadata(row.value.client), | |
| 305 | + expiresAt: DateTime.makeUnsafe(claims.exp), | |
| 306 | + subject: claims.sub, | |
| 307 | + role: claims.role, | |
| 308 | + } satisfies VerifiedSession; | |
| 309 | + }).pipe( | |
| 310 | + Effect.mapError((cause) => | |
| 311 | + cause instanceof SessionCredentialError | |
| 312 | + ? cause | |
| 313 | + : new SessionCredentialError({ | |
| 314 | + message: "Failed to verify session credential.", | |
| 315 | + cause, | |
| 316 | + }), | |
| 317 | + ), | |
| 318 | + ); | |
| 319 | + | |
| 320 | + const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( | |
| 321 | + sessionId, | |
| 322 | + input, | |
| 323 | + ) => | |
| 324 | + Effect.gen(function* () { | |
| 325 | + const issuedAt = yield* DateTime.now; | |
| 326 | + const expiresAt = DateTime.add(issuedAt, { | |
| 327 | + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), | |
| 328 | + }); | |
| 329 | + const claims: WebSocketClaims = { | |
| 330 | + v: 1, | |
| 331 | + kind: "websocket", | |
| 332 | + sid: sessionId, | |
| 333 | + iat: issuedAt.epochMilliseconds, | |
| 334 | + exp: expiresAt.epochMilliseconds, | |
| 335 | + }; | |
| 336 | + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); | |
| 337 | + const signature = signPayload(encodedPayload, signingSecret); | |
| 338 | + return { | |
| 339 | + token: `${encodedPayload}.${signature}`, | |
| 340 | + expiresAt, | |
| 341 | + }; | |
| 342 | + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token."))); | |
| 343 | + | |
| 344 | + const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) => | |
| 345 | + Effect.gen(function* () { | |
| 346 | + const [encodedPayload, signature] = token.split("."); | |
| 347 | + if (!encodedPayload || !signature) { | |
| 348 | + return yield* new SessionCredentialError({ | |
| 349 | + message: "Malformed websocket token.", | |
| 350 | + }); | |
| 351 | + } | |
| 352 | + | |
| 353 | + const expectedSignature = signPayload(encodedPayload, signingSecret); | |
| 354 | + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | |
| 355 | + return yield* new SessionCredentialError({ | |
| 356 | + message: "Invalid websocket token signature.", | |
| 357 | + }); | |
| 358 | + } | |
| 359 | + | |
| 360 | + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | |
| 361 | + Effect.mapError( | |
| 362 | + (cause) => | |
| 363 | + new SessionCredentialError({ | |
| 364 | + message: "Invalid websocket token payload.", | |
| 365 | + cause, | |
| 366 | + }), | |
| 367 | + ), | |
| 368 | + ); | |
| 369 | + | |
| 370 | + const now = yield* Clock.currentTimeMillis; | |
| 371 | + if (claims.exp <= now) { | |
| 372 | + return yield* new SessionCredentialError({ | |
| 373 | + message: "Websocket token expired.", | |
| 374 | + }); | |
| 375 | + } | |
| 376 | + | |
| 377 | + const row = yield* authSessions.getById({ sessionId: claims.sid }); | |
| 378 | + if (Option.isNone(row)) { | |
| 379 | + return yield* new SessionCredentialError({ | |
| 380 | + message: "Unknown websocket session.", | |
| 381 | + }); | |
| 382 | + } | |
| 383 | + if (row.value.expiresAt.epochMilliseconds <= now) { | |
| 384 | + return yield* new SessionCredentialError({ | |
| 385 | + message: "Websocket session expired.", | |
| 386 | + }); | |
| 387 | + } | |
| 388 | + if (row.value.revokedAt !== null) { | |
| 389 | + return yield* new SessionCredentialError({ | |
| 390 | + message: "Websocket session revoked.", | |
| 391 | + }); | |
| 392 | + } | |
| 393 | + | |
| 394 | + return { | |
| 395 | + sessionId: row.value.sessionId, | |
| 396 | + token, | |
| 397 | + method: row.value.method, | |
| 398 | + client: toClientMetadata(row.value.client), | |
| 399 | + expiresAt: row.value.expiresAt, | |
| 400 | + subject: row.value.subject, | |
| 401 | + role: row.value.role, | |
| 402 | + } satisfies VerifiedSession; | |
| 403 | + }).pipe( | |
| 404 | + Effect.mapError((cause) => | |
| 405 | + cause instanceof SessionCredentialError | |
| 406 | + ? cause | |
| 407 | + : new SessionCredentialError({ | |
| 408 | + message: "Failed to verify websocket token.", | |
| 409 | + cause, | |
| 410 | + }), | |
| 411 | + ), | |
| 412 | + ); | |
| 413 | + | |
| 414 | + const listActive: SessionCredentialServiceShape["listActive"] = () => | |
| 415 | + Effect.gen(function* () { | |
| 416 | + const now = yield* DateTime.now; | |
| 417 | + const connectedSessions = yield* Ref.get(connectedSessionsRef); | |
| 418 | + const rows = yield* authSessions.listActive({ now }); | |
| 419 | + | |
| 420 | + return rows.map((row) => | |
| 421 | + toAuthClientSession({ | |
| 422 | + sessionId: row.sessionId, | |
| 423 | + subject: row.subject, | |
| 424 | + role: row.role, | |
| 425 | + method: row.method, | |
| 426 | + client: toClientMetadata(row.client), | |
| 427 | + issuedAt: row.issuedAt, | |
| 428 | + expiresAt: row.expiresAt, | |
| 429 | + lastConnectedAt: row.lastConnectedAt, | |
| 430 | + connected: connectedSessions.has(row.sessionId), | |
| 431 | + }), | |
| 432 | + ); | |
| 433 | + }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); | |
| 434 | + | |
| 435 | + const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => | |
| 436 | + Effect.gen(function* () { | |
| 437 | + const revokedAt = yield* DateTime.now; | |
| 438 | + const revoked = yield* authSessions.revoke({ | |
| 439 | + sessionId, | |
| 440 | + revokedAt, | |
| 441 | + }); | |
| 442 | + if (revoked) { | |
| 443 | + yield* Ref.update(connectedSessionsRef, (current) => { | |
| 444 | + const next = new Map(current); | |
| 445 | + next.delete(sessionId); | |
| 446 | + return next; | |
| 447 | + }); | |
| 448 | + yield* emitRemoved(sessionId); | |
| 449 | + } | |
| 450 | + return revoked; | |
| 451 | + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); | |
| 452 | + | |
| 453 | + const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => | |
| 454 | + Effect.gen(function* () { | |
| 455 | + const revokedAt = yield* DateTime.now; | |
| 456 | + const revokedSessionIds = yield* authSessions.revokeAllExcept({ | |
| 457 | + currentSessionId: sessionId, | |
| 458 | + revokedAt, | |
| 459 | + }); | |
| 460 | + if (revokedSessionIds.length > 0) { | |
| 461 | + yield* Ref.update(connectedSessionsRef, (current) => { | |
| 462 | + const next = new Map(current); | |
| 463 | + for (const revokedSessionId of revokedSessionIds) { | |
| 464 | + next.delete(revokedSessionId); | |
| 465 | + } | |
| 466 | + return next; | |
| 467 | + }); | |
| 468 | + yield* Effect.forEach( | |
| 469 | + revokedSessionIds, | |
| 470 | + (revokedSessionId) => emitRemoved(revokedSessionId), | |
| 471 | + { | |
| 472 | + concurrency: "unbounded", | |
| 473 | + discard: true, | |
| 474 | + }, | |
| 475 | + ); | |
| 476 | + } | |
| 477 | + return revokedSessionIds.length; | |
| 478 | + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); | |
| 479 | + | |
| 480 | + return { | |
| 481 | + cookieName, | |
| 482 | + issue, | |
| 483 | + verify, | |
| 484 | + issueWebSocketToken, | |
| 485 | + verifyWebSocketToken, | |
| 486 | + listActive, | |
| 487 | + get streamChanges() { | |
| 488 | + return Stream.fromPubSub(changesPubSub); | |
| 489 | + }, | |
| 490 | + revoke, | |
| 491 | + revokeAllExcept, | |
| 492 | + markConnected, | |
| 493 | + markDisconnected, | |
| 494 | + } satisfies SessionCredentialServiceShape; | |
| 495 | +}); | |
| 496 | + | |
| 497 | +export const SessionCredentialServiceLive = Layer.effect( | |
| 498 | + SessionCredentialService, | |
| 499 | + makeSessionCredentialService, | |
| 500 | +).pipe(Layer.provideMerge(AuthSessionRepositoryLive)); | |
| @@ -0,0 +1,69 @@ | ||
| 1 | +import type { | |
| 2 | + AuthClientMetadata, | |
| 3 | + AuthClientSession, | |
| 4 | + AuthPairingLink, | |
| 5 | + AuthSessionId, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import { Data, DateTime, Duration, Effect, Context } from "effect"; | |
| 8 | +import type { SessionRole } from "./SessionCredentialService.ts"; | |
| 9 | + | |
| 10 | +export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; | |
| 11 | + | |
| 12 | +export interface IssuedPairingLink { | |
| 13 | + readonly id: string; | |
| 14 | + readonly credential: string; | |
| 15 | + readonly role: SessionRole; | |
| 16 | + readonly subject: string; | |
| 17 | + readonly label?: string; | |
| 18 | + readonly createdAt: DateTime.Utc; | |
| 19 | + readonly expiresAt: DateTime.Utc; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface IssuedBearerSession { | |
| 23 | + readonly sessionId: AuthSessionId; | |
| 24 | + readonly token: string; | |
| 25 | + readonly method: "bearer-session-token"; | |
| 26 | + readonly role: SessionRole; | |
| 27 | + readonly subject: string; | |
| 28 | + readonly client: AuthClientMetadata; | |
| 29 | + readonly expiresAt: DateTime.Utc; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{ | |
| 33 | + readonly message: string; | |
| 34 | + readonly cause?: unknown; | |
| 35 | +}> {} | |
| 36 | + | |
| 37 | +export interface AuthControlPlaneShape { | |
| 38 | + readonly createPairingLink: (input?: { | |
| 39 | + readonly ttl?: Duration.Duration; | |
| 40 | + readonly label?: string; | |
| 41 | + readonly role?: SessionRole; | |
| 42 | + readonly subject?: string; | |
| 43 | + }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>; | |
| 44 | + readonly listPairingLinks: (input?: { | |
| 45 | + readonly role?: SessionRole; | |
| 46 | + readonly excludeSubjects?: ReadonlyArray<string>; | |
| 47 | + }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>; | |
| 48 | + readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>; | |
| 49 | + readonly issueSession: (input?: { | |
| 50 | + readonly ttl?: Duration.Duration; | |
| 51 | + readonly subject?: string; | |
| 52 | + readonly role?: SessionRole; | |
| 53 | + readonly label?: string; | |
| 54 | + }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>; | |
| 55 | + readonly listSessions: () => Effect.Effect< | |
| 56 | + ReadonlyArray<AuthClientSession>, | |
| 57 | + AuthControlPlaneError | |
| 58 | + >; | |
| 59 | + readonly revokeSession: ( | |
| 60 | + sessionId: AuthSessionId, | |
| 61 | + ) => Effect.Effect<boolean, AuthControlPlaneError>; | |
| 62 | + readonly revokeOtherSessionsExcept: ( | |
| 63 | + sessionId: AuthSessionId, | |
| 64 | + ) => Effect.Effect<number, AuthControlPlaneError>; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()( | |
| 68 | + "t3/AuthControlPlane", | |
| 69 | +) {} | |
| @@ -0,0 +1,57 @@ | ||
| 1 | +import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; | |
| 2 | +import { Data, DateTime, Duration, Context } from "effect"; | |
| 3 | +import type { Effect, Stream } from "effect"; | |
| 4 | + | |
| 5 | +export type BootstrapCredentialRole = "owner" | "client"; | |
| 6 | + | |
| 7 | +export interface BootstrapGrant { | |
| 8 | + readonly method: ServerAuthBootstrapMethod; | |
| 9 | + readonly role: BootstrapCredentialRole; | |
| 10 | + readonly subject: string; | |
| 11 | + readonly label?: string; | |
| 12 | + readonly expiresAt: DateTime.DateTime; | |
| 13 | +} | |
| 14 | + | |
| 15 | +export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ | |
| 16 | + readonly message: string; | |
| 17 | + readonly status?: 401 | 500; | |
| 18 | + readonly cause?: unknown; | |
| 19 | +}> {} | |
| 20 | + | |
| 21 | +export interface IssuedBootstrapCredential { | |
| 22 | + readonly id: string; | |
| 23 | + readonly credential: string; | |
| 24 | + readonly label?: string; | |
| 25 | + readonly expiresAt: DateTime.Utc; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export type BootstrapCredentialChange = | |
| 29 | + | { | |
| 30 | + readonly type: "pairingLinkUpserted"; | |
| 31 | + readonly pairingLink: AuthPairingLink; | |
| 32 | + } | |
| 33 | + | { | |
| 34 | + readonly type: "pairingLinkRemoved"; | |
| 35 | + readonly id: string; | |
| 36 | + }; | |
| 37 | + | |
| 38 | +export interface BootstrapCredentialServiceShape { | |
| 39 | + readonly issueOneTimeToken: (input?: { | |
| 40 | + readonly ttl?: Duration.Duration; | |
| 41 | + readonly role?: BootstrapCredentialRole; | |
| 42 | + readonly subject?: string; | |
| 43 | + readonly label?: string; | |
| 44 | + }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>; | |
| 45 | + readonly listActive: () => Effect.Effect< | |
| 46 | + ReadonlyArray<AuthPairingLink>, | |
| 47 | + BootstrapCredentialError | |
| 48 | + >; | |
| 49 | + readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | |
| 50 | + readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>; | |
| 51 | + readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export class BootstrapCredentialService extends Context.Service< | |
| 55 | + BootstrapCredentialService, | |
| 56 | + BootstrapCredentialServiceShape | |
| 57 | +>()("t3/auth/Services/BootstrapCredentialService") {} | |
| @@ -0,0 +1,84 @@ | ||
| 1 | +import type { | |
| 2 | + AuthBearerBootstrapResult, | |
| 3 | + AuthBootstrapResult, | |
| 4 | + AuthClientMetadata, | |
| 5 | + AuthClientSession, | |
| 6 | + AuthCreatePairingCredentialInput, | |
| 7 | + AuthPairingLink, | |
| 8 | + AuthPairingCredentialResult, | |
| 9 | + AuthSessionId, | |
| 10 | + AuthSessionState, | |
| 11 | + ServerAuthDescriptor, | |
| 12 | + ServerAuthSessionMethod, | |
| 13 | + AuthWebSocketTokenResult, | |
| 14 | +} from "@t3tools/contracts"; | |
| 15 | +import { Data, DateTime, Context } from "effect"; | |
| 16 | +import type { Effect } from "effect"; | |
| 17 | +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 18 | +import type { SessionRole } from "./SessionCredentialService.ts"; | |
| 19 | + | |
| 20 | +export interface AuthenticatedSession { | |
| 21 | + readonly sessionId: AuthSessionId; | |
| 22 | + readonly subject: string; | |
| 23 | + readonly method: ServerAuthSessionMethod; | |
| 24 | + readonly role: SessionRole; | |
| 25 | + readonly expiresAt?: DateTime.DateTime; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export class AuthError extends Data.TaggedError("AuthError")<{ | |
| 29 | + readonly message: string; | |
| 30 | + readonly status?: 400 | 401 | 403 | 500; | |
| 31 | + readonly cause?: unknown; | |
| 32 | +}> {} | |
| 33 | + | |
| 34 | +export interface ServerAuthShape { | |
| 35 | + readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 36 | + readonly getSessionState: ( | |
| 37 | + request: HttpServerRequest.HttpServerRequest, | |
| 38 | + ) => Effect.Effect<AuthSessionState, never>; | |
| 39 | + readonly exchangeBootstrapCredential: ( | |
| 40 | + credential: string, | |
| 41 | + requestMetadata: AuthClientMetadata, | |
| 42 | + ) => Effect.Effect< | |
| 43 | + { | |
| 44 | + readonly response: AuthBootstrapResult; | |
| 45 | + readonly sessionToken: string; | |
| 46 | + }, | |
| 47 | + AuthError | |
| 48 | + >; | |
| 49 | + readonly exchangeBootstrapCredentialForBearerSession: ( | |
| 50 | + credential: string, | |
| 51 | + requestMetadata: AuthClientMetadata, | |
| 52 | + ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>; | |
| 53 | + readonly issuePairingCredential: ( | |
| 54 | + input?: AuthCreatePairingCredentialInput & { | |
| 55 | + readonly role?: SessionRole; | |
| 56 | + }, | |
| 57 | + ) => Effect.Effect<AuthPairingCredentialResult, AuthError>; | |
| 58 | + readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>; | |
| 59 | + readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>; | |
| 60 | + readonly listClientSessions: ( | |
| 61 | + currentSessionId: AuthSessionId, | |
| 62 | + ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>; | |
| 63 | + readonly revokeClientSession: ( | |
| 64 | + currentSessionId: AuthSessionId, | |
| 65 | + targetSessionId: AuthSessionId, | |
| 66 | + ) => Effect.Effect<boolean, AuthError>; | |
| 67 | + readonly revokeOtherClientSessions: ( | |
| 68 | + currentSessionId: AuthSessionId, | |
| 69 | + ) => Effect.Effect<number, AuthError>; | |
| 70 | + readonly authenticateHttpRequest: ( | |
| 71 | + request: HttpServerRequest.HttpServerRequest, | |
| 72 | + ) => Effect.Effect<AuthenticatedSession, AuthError>; | |
| 73 | + readonly authenticateWebSocketUpgrade: ( | |
| 74 | + request: HttpServerRequest.HttpServerRequest, | |
| 75 | + ) => Effect.Effect<AuthenticatedSession, AuthError>; | |
| 76 | + readonly issueWebSocketToken: ( | |
| 77 | + session: AuthenticatedSession, | |
| 78 | + ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>; | |
| 79 | + readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export class ServerAuth extends Context.Service<ServerAuth, ServerAuthShape>()( | |
| 83 | + "t3/auth/Services/ServerAuth", | |
| 84 | +) {} | |
| @@ -0,0 +1,11 @@ | ||
| 1 | +import type { ServerAuthDescriptor } from "@t3tools/contracts"; | |
| 2 | +import { Context } from "effect"; | |
| 3 | +import type { Effect } from "effect"; | |
| 4 | + | |
| 5 | +export interface ServerAuthPolicyShape { | |
| 6 | + readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | |
| 7 | +} | |
| 8 | + | |
| 9 | +export class ServerAuthPolicy extends Context.Service<ServerAuthPolicy, ServerAuthPolicyShape>()( | |
| 10 | + "t3/auth/Services/ServerAuthPolicy", | |
| 11 | +) {} | |
| @@ -0,0 +1,21 @@ | ||
| 1 | +import { Data, Context } from "effect"; | |
| 2 | +import type { Effect } from "effect"; | |
| 3 | + | |
| 4 | +export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ | |
| 5 | + readonly message: string; | |
| 6 | + readonly cause?: unknown; | |
| 7 | +}> {} | |
| 8 | + | |
| 9 | +export interface ServerSecretStoreShape { | |
| 10 | + readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>; | |
| 11 | + readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | |
| 12 | + readonly getOrCreateRandom: ( | |
| 13 | + name: string, | |
| 14 | + bytes: number, | |
| 15 | + ) => Effect.Effect<Uint8Array, SecretStoreError>; | |
| 16 | + readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()( | |
| 20 | + "t3/auth/Services/ServerSecretStore", | |
| 21 | +) {} | |
| @@ -0,0 +1,87 @@ | ||
| 1 | +import type { | |
| 2 | + AuthClientMetadata, | |
| 3 | + AuthClientSession, | |
| 4 | + AuthSessionId, | |
| 5 | + ServerAuthSessionMethod, | |
| 6 | +} from "@t3tools/contracts"; | |
| 7 | +import { Data, DateTime, Duration, Context } from "effect"; | |
| 8 | +import type { Effect, Stream } from "effect"; | |
| 9 | + | |
| 10 | +export type SessionRole = "owner" | "client"; | |
| 11 | + | |
| 12 | +export interface IssuedSession { | |
| 13 | + readonly sessionId: AuthSessionId; | |
| 14 | + readonly token: string; | |
| 15 | + readonly method: ServerAuthSessionMethod; | |
| 16 | + readonly client: AuthClientMetadata; | |
| 17 | + readonly expiresAt: DateTime.DateTime; | |
| 18 | + readonly role: SessionRole; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface VerifiedSession { | |
| 22 | + readonly sessionId: AuthSessionId; | |
| 23 | + readonly token: string; | |
| 24 | + readonly method: ServerAuthSessionMethod; | |
| 25 | + readonly client: AuthClientMetadata; | |
| 26 | + readonly expiresAt?: DateTime.DateTime; | |
| 27 | + readonly subject: string; | |
| 28 | + readonly role: SessionRole; | |
| 29 | +} | |
| 30 | + | |
| 31 | +export type SessionCredentialChange = | |
| 32 | + | { | |
| 33 | + readonly type: "clientUpserted"; | |
| 34 | + readonly clientSession: AuthClientSession; | |
| 35 | + } | |
| 36 | + | { | |
| 37 | + readonly type: "clientRemoved"; | |
| 38 | + readonly sessionId: AuthSessionId; | |
| 39 | + }; | |
| 40 | + | |
| 41 | +export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ | |
| 42 | + readonly message: string; | |
| 43 | + readonly cause?: unknown; | |
| 44 | +}> {} | |
| 45 | + | |
| 46 | +export interface SessionCredentialServiceShape { | |
| 47 | + readonly cookieName: string; | |
| 48 | + readonly issue: (input?: { | |
| 49 | + readonly ttl?: Duration.Duration; | |
| 50 | + readonly subject?: string; | |
| 51 | + readonly method?: ServerAuthSessionMethod; | |
| 52 | + readonly role?: SessionRole; | |
| 53 | + readonly client?: AuthClientMetadata; | |
| 54 | + }) => Effect.Effect<IssuedSession, SessionCredentialError>; | |
| 55 | + readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 56 | + readonly issueWebSocketToken: ( | |
| 57 | + sessionId: AuthSessionId, | |
| 58 | + input?: { | |
| 59 | + readonly ttl?: Duration.Duration; | |
| 60 | + }, | |
| 61 | + ) => Effect.Effect< | |
| 62 | + { | |
| 63 | + readonly token: string; | |
| 64 | + readonly expiresAt: DateTime.DateTime; | |
| 65 | + }, | |
| 66 | + SessionCredentialError | |
| 67 | + >; | |
| 68 | + readonly verifyWebSocketToken: ( | |
| 69 | + token: string, | |
| 70 | + ) => Effect.Effect<VerifiedSession, SessionCredentialError>; | |
| 71 | + readonly listActive: () => Effect.Effect< | |
| 72 | + ReadonlyArray<AuthClientSession>, | |
| 73 | + SessionCredentialError | |
| 74 | + >; | |
| 75 | + readonly streamChanges: Stream.Stream<SessionCredentialChange>; | |
| 76 | + readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>; | |
| 77 | + readonly revokeAllExcept: ( | |
| 78 | + sessionId: AuthSessionId, | |
| 79 | + ) => Effect.Effect<number, SessionCredentialError>; | |
| 80 | + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 81 | + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export class SessionCredentialService extends Context.Service< | |
| 85 | + SessionCredentialService, | |
| 86 | + SessionCredentialServiceShape | |
| 87 | +>()("t3/auth/Services/SessionCredentialService") {} | |
| @@ -0,0 +1,254 @@ | ||
| 1 | +import { | |
| 2 | + type AuthBearerBootstrapResult, | |
| 3 | + AuthBootstrapInput, | |
| 4 | + AuthCreatePairingCredentialInput, | |
| 5 | + AuthRevokeClientSessionInput, | |
| 6 | + AuthRevokePairingLinkInput, | |
| 7 | + type AuthWebSocketTokenResult, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import { DateTime, Effect, Schema } from "effect"; | |
| 10 | +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 11 | + | |
| 12 | +import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; | |
| 13 | +import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; | |
| 14 | +import { deriveAuthClientMetadata } from "./utils.ts"; | |
| 15 | + | |
| 16 | +export const respondToAuthError = (error: AuthError) => | |
| 17 | + Effect.gen(function* () { | |
| 18 | + if ((error.status ?? 500) >= 500) { | |
| 19 | + yield* Effect.logError("auth route failed", { | |
| 20 | + message: error.message, | |
| 21 | + cause: error.cause, | |
| 22 | + }); | |
| 23 | + } | |
| 24 | + return HttpServerResponse.jsonUnsafe( | |
| 25 | + { | |
| 26 | + error: error.message, | |
| 27 | + }, | |
| 28 | + { status: error.status ?? 500 }, | |
| 29 | + ); | |
| 30 | + }); | |
| 31 | + | |
| 32 | +export const authSessionRouteLayer = HttpRouter.add( | |
| 33 | + "GET", | |
| 34 | + "/api/auth/session", | |
| 35 | + Effect.gen(function* () { | |
| 36 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 37 | + const serverAuth = yield* ServerAuth; | |
| 38 | + const session = yield* serverAuth.getSessionState(request); | |
| 39 | + return HttpServerResponse.jsonUnsafe(session, { status: 200 }); | |
| 40 | + }), | |
| 41 | +); | |
| 42 | + | |
| 43 | +const PairingCredentialRequestHeaders = Schema.Struct({ | |
| 44 | + "content-length": Schema.optionalKey(Schema.String), | |
| 45 | + "content-type": Schema.optionalKey(Schema.String), | |
| 46 | + "transfer-encoding": Schema.optionalKey(Schema.String), | |
| 47 | +}); | |
| 48 | + | |
| 49 | +function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) { | |
| 50 | + const contentLengthHeader = headers["content-length"]; | |
| 51 | + if (typeof contentLengthHeader === "string") { | |
| 52 | + const contentLength = Number.parseInt(contentLengthHeader, 10); | |
| 53 | + if (Number.isFinite(contentLength)) { | |
| 54 | + return contentLength > 0; | |
| 55 | + } | |
| 56 | + } | |
| 57 | + return typeof headers["transfer-encoding"] === "string"; | |
| 58 | +} | |
| 59 | + | |
| 60 | +export const authBootstrapRouteLayer = HttpRouter.add( | |
| 61 | + "POST", | |
| 62 | + "/api/auth/bootstrap", | |
| 63 | + Effect.gen(function* () { | |
| 64 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 65 | + const serverAuth = yield* ServerAuth; | |
| 66 | + const sessions = yield* SessionCredentialService; | |
| 67 | + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | |
| 68 | + Effect.mapError( | |
| 69 | + (cause) => | |
| 70 | + new AuthError({ | |
| 71 | + message: "Invalid bootstrap payload.", | |
| 72 | + status: 400, | |
| 73 | + cause, | |
| 74 | + }), | |
| 75 | + ), | |
| 76 | + ); | |
| 77 | + const result = yield* serverAuth.exchangeBootstrapCredential( | |
| 78 | + payload.credential, | |
| 79 | + deriveAuthClientMetadata({ request }), | |
| 80 | + ); | |
| 81 | + | |
| 82 | + return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe( | |
| 83 | + HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { | |
| 84 | + expires: DateTime.toDate(result.response.expiresAt), | |
| 85 | + httpOnly: true, | |
| 86 | + path: "/", | |
| 87 | + sameSite: "lax", | |
| 88 | + }), | |
| 89 | + ); | |
| 90 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 91 | +); | |
| 92 | + | |
| 93 | +export const authBearerBootstrapRouteLayer = HttpRouter.add( | |
| 94 | + "POST", | |
| 95 | + "/api/auth/bootstrap/bearer", | |
| 96 | + Effect.gen(function* () { | |
| 97 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 98 | + const serverAuth = yield* ServerAuth; | |
| 99 | + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( | |
| 100 | + Effect.mapError( | |
| 101 | + (cause) => | |
| 102 | + new AuthError({ | |
| 103 | + message: "Invalid bootstrap payload.", | |
| 104 | + status: 400, | |
| 105 | + cause, | |
| 106 | + }), | |
| 107 | + ), | |
| 108 | + ); | |
| 109 | + const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( | |
| 110 | + payload.credential, | |
| 111 | + deriveAuthClientMetadata({ request }), | |
| 112 | + ); | |
| 113 | + return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, { | |
| 114 | + status: 200, | |
| 115 | + }); | |
| 116 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 117 | +); | |
| 118 | + | |
| 119 | +export const authWebSocketTokenRouteLayer = HttpRouter.add( | |
| 120 | + "POST", | |
| 121 | + "/api/auth/ws-token", | |
| 122 | + Effect.gen(function* () { | |
| 123 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 124 | + const serverAuth = yield* ServerAuth; | |
| 125 | + const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 126 | + const result = yield* serverAuth.issueWebSocketToken(session); | |
| 127 | + return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, { | |
| 128 | + status: 200, | |
| 129 | + }); | |
| 130 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 131 | +); | |
| 132 | + | |
| 133 | +export const authPairingCredentialRouteLayer = HttpRouter.add( | |
| 134 | + "POST", | |
| 135 | + "/api/auth/pairing-token", | |
| 136 | + Effect.gen(function* () { | |
| 137 | + const serverAuth = yield* ServerAuth; | |
| 138 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 139 | + const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 140 | + if (session.role !== "owner") { | |
| 141 | + return yield* new AuthError({ | |
| 142 | + message: "Only owner sessions can create pairing credentials.", | |
| 143 | + status: 403, | |
| 144 | + }); | |
| 145 | + } | |
| 146 | + const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe( | |
| 147 | + Effect.mapError( | |
| 148 | + (cause) => | |
| 149 | + new AuthError({ | |
| 150 | + message: "Invalid pairing credential request headers.", | |
| 151 | + status: 400, | |
| 152 | + cause, | |
| 153 | + }), | |
| 154 | + ), | |
| 155 | + ); | |
| 156 | + const payload = hasRequestBody(headers) | |
| 157 | + ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe( | |
| 158 | + Effect.mapError( | |
| 159 | + (cause) => | |
| 160 | + new AuthError({ | |
| 161 | + message: "Invalid pairing credential payload.", | |
| 162 | + status: 400, | |
| 163 | + cause, | |
| 164 | + }), | |
| 165 | + ), | |
| 166 | + ) | |
| 167 | + : {}; | |
| 168 | + const result = yield* serverAuth.issuePairingCredential(payload); | |
| 169 | + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); | |
| 170 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 171 | +); | |
| 172 | + | |
| 173 | +const authenticateOwnerSession = Effect.gen(function* () { | |
| 174 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 175 | + const serverAuth = yield* ServerAuth; | |
| 176 | + const session = yield* serverAuth.authenticateHttpRequest(request); | |
| 177 | + if (session.role !== "owner") { | |
| 178 | + return yield* new AuthError({ | |
| 179 | + message: "Only owner sessions can manage network access.", | |
| 180 | + status: 403, | |
| 181 | + }); | |
| 182 | + } | |
| 183 | + return { serverAuth, session } as const; | |
| 184 | +}); | |
| 185 | + | |
| 186 | +export const authPairingLinksRouteLayer = HttpRouter.add( | |
| 187 | + "GET", | |
| 188 | + "/api/auth/pairing-links", | |
| 189 | + Effect.gen(function* () { | |
| 190 | + const { serverAuth } = yield* authenticateOwnerSession; | |
| 191 | + const pairingLinks = yield* serverAuth.listPairingLinks(); | |
| 192 | + return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); | |
| 193 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 194 | +); | |
| 195 | + | |
| 196 | +export const authPairingLinksRevokeRouteLayer = HttpRouter.add( | |
| 197 | + "POST", | |
| 198 | + "/api/auth/pairing-links/revoke", | |
| 199 | + Effect.gen(function* () { | |
| 200 | + const { serverAuth } = yield* authenticateOwnerSession; | |
| 201 | + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe( | |
| 202 | + Effect.mapError( | |
| 203 | + (cause) => | |
| 204 | + new AuthError({ | |
| 205 | + message: "Invalid revoke pairing link payload.", | |
| 206 | + status: 400, | |
| 207 | + cause, | |
| 208 | + }), | |
| 209 | + ), | |
| 210 | + ); | |
| 211 | + const revoked = yield* serverAuth.revokePairingLink(payload.id); | |
| 212 | + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | |
| 213 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 214 | +); | |
| 215 | + | |
| 216 | +export const authClientsRouteLayer = HttpRouter.add( | |
| 217 | + "GET", | |
| 218 | + "/api/auth/clients", | |
| 219 | + Effect.gen(function* () { | |
| 220 | + const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 221 | + const clients = yield* serverAuth.listClientSessions(session.sessionId); | |
| 222 | + return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); | |
| 223 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 224 | +); | |
| 225 | + | |
| 226 | +export const authClientsRevokeRouteLayer = HttpRouter.add( | |
| 227 | + "POST", | |
| 228 | + "/api/auth/clients/revoke", | |
| 229 | + Effect.gen(function* () { | |
| 230 | + const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 231 | + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( | |
| 232 | + Effect.mapError( | |
| 233 | + (cause) => | |
| 234 | + new AuthError({ | |
| 235 | + message: "Invalid revoke client payload.", | |
| 236 | + status: 400, | |
| 237 | + cause, | |
| 238 | + }), | |
| 239 | + ), | |
| 240 | + ); | |
| 241 | + const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); | |
| 242 | + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); | |
| 243 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 244 | +); | |
| 245 | + | |
| 246 | +export const authClientsRevokeOthersRouteLayer = HttpRouter.add( | |
| 247 | + "POST", | |
| 248 | + "/api/auth/clients/revoke-others", | |
| 249 | + Effect.gen(function* () { | |
| 250 | + const { serverAuth, session } = yield* authenticateOwnerSession; | |
| 251 | + const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); | |
| 252 | + return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); | |
| 253 | + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), | |
| 254 | +); | |
| @@ -0,0 +1,26 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { deriveAuthClientMetadata } from "./utils.ts"; | |
| 4 | + | |
| 5 | +describe("deriveAuthClientMetadata", () => { | |
| 6 | + it("labels Electron user agents as Electron instead of Chrome", () => { | |
| 7 | + const metadata = deriveAuthClientMetadata({ | |
| 8 | + request: { | |
| 9 | + headers: { | |
| 10 | + "user-agent": | |
| 11 | + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) t3code/0.0.15 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36", | |
| 12 | + }, | |
| 13 | + source: { | |
| 14 | + remoteAddress: "::ffff:127.0.0.1", | |
| 15 | + }, | |
| 16 | + } as never, | |
| 17 | + }); | |
| 18 | + | |
| 19 | + expect(metadata).toMatchObject({ | |
| 20 | + browser: "Electron", | |
| 21 | + deviceType: "desktop", | |
| 22 | + ipAddress: "127.0.0.1", | |
| 23 | + os: "macOS", | |
| 24 | + }); | |
| 25 | + }); | |
| 26 | +}); | |
| @@ -0,0 +1,132 @@ | ||
| 1 | +import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts"; | |
| 2 | +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | |
| 3 | +import * as Crypto from "node:crypto"; | |
| 4 | + | |
| 5 | +const SESSION_COOKIE_NAME = "t3_session"; | |
| 6 | + | |
| 7 | +export function resolveSessionCookieName(input: { | |
| 8 | + readonly mode: "web" | "desktop"; | |
| 9 | + readonly port: number; | |
| 10 | +}): string { | |
| 11 | + if (input.mode !== "desktop") { | |
| 12 | + return SESSION_COOKIE_NAME; | |
| 13 | + } | |
| 14 | + | |
| 15 | + return `${SESSION_COOKIE_NAME}_${input.port}`; | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function base64UrlEncode(input: string | Uint8Array): string { | |
| 19 | + const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); | |
| 20 | + return buffer.toString("base64url"); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function base64UrlDecodeUtf8(input: string): string { | |
| 24 | + return Buffer.from(input, "base64url").toString("utf8"); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function signPayload(payload: string, secret: Uint8Array): string { | |
| 28 | + return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export function timingSafeEqualBase64Url(left: string, right: string): boolean { | |
| 32 | + const leftBuffer = Buffer.from(left, "base64url"); | |
| 33 | + const rightBuffer = Buffer.from(right, "base64url"); | |
| 34 | + if (leftBuffer.length !== rightBuffer.length) { | |
| 35 | + return false; | |
| 36 | + } | |
| 37 | + return Crypto.timingSafeEqual(leftBuffer, rightBuffer); | |
| 38 | +} | |
| 39 | + | |
| 40 | +function normalizeNonEmptyString(value: string | null | undefined): string | undefined { | |
| 41 | + if (typeof value !== "string") { | |
| 42 | + return undefined; | |
| 43 | + } | |
| 44 | + const trimmed = value.trim(); | |
| 45 | + return trimmed.length > 0 ? trimmed : undefined; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function normalizeIpAddress(value: string | null | undefined): string | undefined { | |
| 49 | + const normalized = normalizeNonEmptyString(value); | |
| 50 | + if (!normalized) { | |
| 51 | + return undefined; | |
| 52 | + } | |
| 53 | + return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType { | |
| 57 | + if (!userAgent) { | |
| 58 | + return "unknown"; | |
| 59 | + } | |
| 60 | + | |
| 61 | + const normalized = userAgent.toLowerCase(); | |
| 62 | + if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) { | |
| 63 | + return "bot"; | |
| 64 | + } | |
| 65 | + if (/ipad|tablet/.test(normalized)) { | |
| 66 | + return "tablet"; | |
| 67 | + } | |
| 68 | + if (/iphone|android.+mobile|mobile/.test(normalized)) { | |
| 69 | + return "mobile"; | |
| 70 | + } | |
| 71 | + return "desktop"; | |
| 72 | +} | |
| 73 | + | |
| 74 | +function inferBrowser(userAgent: string | undefined): string | undefined { | |
| 75 | + if (!userAgent) { | |
| 76 | + return undefined; | |
| 77 | + } | |
| 78 | + const normalized = userAgent.toLowerCase(); | |
| 79 | + if (/edg\//.test(normalized)) return "Edge"; | |
| 80 | + if (/opr\//.test(normalized)) return "Opera"; | |
| 81 | + if (/firefox\//.test(normalized)) return "Firefox"; | |
| 82 | + if (/electron\//.test(normalized)) return "Electron"; | |
| 83 | + if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; | |
| 84 | + if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; | |
| 85 | + return undefined; | |
| 86 | +} | |
| 87 | + | |
| 88 | +function inferOs(userAgent: string | undefined): string | undefined { | |
| 89 | + if (!userAgent) { | |
| 90 | + return undefined; | |
| 91 | + } | |
| 92 | + const normalized = userAgent.toLowerCase(); | |
| 93 | + if (/iphone|ipad|ipod/.test(normalized)) return "iOS"; | |
| 94 | + if (/android/.test(normalized)) return "Android"; | |
| 95 | + if (/mac os x|macintosh/.test(normalized)) return "macOS"; | |
| 96 | + if (/windows nt/.test(normalized)) return "Windows"; | |
| 97 | + if (/linux/.test(normalized)) return "Linux"; | |
| 98 | + return undefined; | |
| 99 | +} | |
| 100 | + | |
| 101 | +function readRemoteAddressFromSource(source: unknown): string | undefined { | |
| 102 | + if (!source || typeof source !== "object") { | |
| 103 | + return undefined; | |
| 104 | + } | |
| 105 | + | |
| 106 | + const candidate = source as { | |
| 107 | + readonly remoteAddress?: string | null; | |
| 108 | + readonly socket?: { | |
| 109 | + readonly remoteAddress?: string | null; | |
| 110 | + }; | |
| 111 | + }; | |
| 112 | + | |
| 113 | + return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); | |
| 114 | +} | |
| 115 | + | |
| 116 | +export function deriveAuthClientMetadata(input: { | |
| 117 | + readonly request: HttpServerRequest.HttpServerRequest; | |
| 118 | + readonly label?: string; | |
| 119 | +}): AuthClientMetadata { | |
| 120 | + const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); | |
| 121 | + const ipAddress = readRemoteAddressFromSource(input.request.source); | |
| 122 | + const os = inferOs(userAgent); | |
| 123 | + const browser = inferBrowser(userAgent); | |
| 124 | + return { | |
| 125 | + ...(input.label ? { label: input.label } : {}), | |
| 126 | + ...(ipAddress ? { ipAddress } : {}), | |
| 127 | + ...(userAgent ? { userAgent } : {}), | |
| 128 | + deviceType: inferDeviceType(userAgent), | |
| 129 | + ...(os ? { os } : {}), | |
| 130 | + ...(browser ? { browser } : {}), | |
| 131 | + }; | |
| 132 | +} | |
| @@ -5,12 +5,12 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines | ||
| 5 | 5 | import { Command } from "effect/unstable/cli"; |
| 6 | 6 | |
| 7 | 7 | import { NetService } from "@t3tools/shared/Net"; |
| 8 | −import { cli } from "./cli"; | |
| 9 | −import { version } from "../package.json" with { type: "json" }; | |
| 8 | +import { cli } from "./cli.ts"; | |
| 9 | +import packageJson from "../package.json" with { type: "json" }; | |
| 10 | 10 | |
| 11 | 11 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 12 | 12 | |
| 13 | −Command.run(cli, { version }).pipe( | |
| 13 | +Command.run(cli, { version: packageJson.version }).pipe( | |
| 14 | 14 | Effect.scoped, |
| 15 | 15 | Effect.provide(CliRuntimeLayer), |
| 16 | 16 | NodeRuntime.runMain, |
| @@ -10,7 +10,7 @@ import * as Fiber from "effect/Fiber"; · 9 unmodified lines | ||
| 10 | 10 | import { TestClock } from "effect/testing"; |
| 11 | 11 | import { vi } from "vitest"; |
| 12 | 12 | |
| 13 | −import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; | |
| 13 | +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts"; | |
| 14 | 14 | import { assertNone, assertSome } from "@effect/vitest/utils"; |
| 15 | 15 | |
| 16 | 16 | const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null })); |
| @@ -26,7 +26,7 @@ function makeThreadCheckpointContext(input: { · 25 unmodified lines | ||
| 26 | 26 | worktreePath: input.worktreePath, |
| 27 | 27 | checkpoints: [ |
| 28 | 28 | { |
| 29 | − turnId: TurnId.makeUnsafe("turn-1"), | |
| 29 | + turnId: TurnId.make("turn-1"), | |
| 30 | 30 | checkpointTurnCount: input.checkpointTurnCount, |
| 31 | 31 | checkpointRef: input.checkpointRef, |
| 32 | 32 | status: "ready", |
| @@ -40,8 +40,8 @@ function makeThreadCheckpointContext(input: { · 7 unmodified lines | ||
| 40 | 40 | |
| 41 | 41 | describe("CheckpointDiffQueryLive", () => { |
| 42 | 42 | it("computes diffs using canonical turn-0 checkpoint refs", async () => { |
| 43 | − const projectId = ProjectId.makeUnsafe("project-1"); | |
| 44 | − const threadId = ThreadId.makeUnsafe("thread-1"); | |
| 43 | + const projectId = ProjectId.make("project-1"); | |
| 44 | + const threadId = ThreadId.make("thread-1"); | |
| 45 | 45 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 46 | 46 | const hasCheckpointRefCalls: Array<CheckpointRef> = []; |
| 47 | 47 | const diffCheckpointsCalls: Array<{ |
| @@ -82,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => { · 34 unmodified lines | ||
| 82 | 82 | Layer.succeed(ProjectionSnapshotQuery, { |
| 83 | 83 | getSnapshot: () => |
| 84 | 84 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 85 | + getShellSnapshot: () => | |
| 86 | + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 85 | 87 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 86 | 88 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 89 | + getProjectShellById: () => Effect.succeed(Option.none()), | |
| 87 | 90 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 88 | 91 | getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), |
| 92 | + getThreadShellById: () => Effect.succeed(Option.none()), | |
| 93 | + getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 89 | 94 | }), |
| 90 | 95 | ), |
| 91 | 96 | ); |
| @@ -119,7 +124,7 @@ describe("CheckpointDiffQueryLive", () => { · 27 unmodified lines | ||
| 119 | 124 | }); |
| 120 | 125 | |
| 121 | 126 | it("fails when the thread is missing from the snapshot", async () => { |
| 122 | − const threadId = ThreadId.makeUnsafe("thread-missing"); | |
| 127 | + const threadId = ThreadId.make("thread-missing"); | |
| 123 | 128 | |
| 124 | 129 | const checkpointStore: CheckpointStoreShape = { |
| 125 | 130 | isGitRepository: () => Effect.succeed(true), |
| @@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => { · 10 unmodified lines | ||
| 136 | 141 | Layer.succeed(ProjectionSnapshotQuery, { |
| 137 | 142 | getSnapshot: () => |
| 138 | 143 | Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), |
| 144 | + getShellSnapshot: () => | |
| 145 | + Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"), | |
| 139 | 146 | getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), |
| 140 | 147 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 148 | + getProjectShellById: () => Effect.succeed(Option.none()), | |
| 141 | 149 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 142 | 150 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 151 | + getThreadShellById: () => Effect.succeed(Option.none()), | |
| 152 | + getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 143 | 153 | }), |
| 144 | 154 | ), |
| 145 | 155 | ); |
| @@ -93,7 +93,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { · 92 unmodified lines | ||
| 93 | 93 | const tmp = yield* makeTmpDir(); |
| 94 | 94 | yield* initRepoWithCommit(tmp); |
| 95 | 95 | const checkpointStore = yield* CheckpointStore; |
| 96 | − const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); | |
| 96 | + const threadId = ThreadId.make("thread-checkpoint-store"); | |
| 97 | 97 | const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); |
| 98 | 98 | const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); |
| 99 | 99 | |
| @@ -12,7 +12,7 @@ import type { · 11 unmodified lines | ||
| 12 | 12 | OrchestrationGetTurnDiffInput, |
| 13 | 13 | OrchestrationGetTurnDiffResult, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | −import { ServiceMap } from "effect"; | |
| 15 | +import { Context } from "effect"; | |
| 16 | 16 | import type { Effect } from "effect"; |
| 17 | 17 | |
| 18 | 18 | import type { CheckpointServiceError } from "../Errors.ts"; |
| @@ -43,7 +43,7 @@ export interface CheckpointDiffQueryShape { · 24 unmodified lines | ||
| 43 | 43 | /** |
| 44 | 44 | * CheckpointDiffQuery - Service tag for checkpoint diff queries. |
| 45 | 45 | */ |
| 46 | −export class CheckpointDiffQuery extends ServiceMap.Service< | |
| 46 | +export class CheckpointDiffQuery extends Context.Service< | |
| 47 | 47 | CheckpointDiffQuery, |
| 48 | 48 | CheckpointDiffQueryShape |
| 49 | 49 | >()("t3/checkpointing/Services/CheckpointDiffQuery") {} |
| @@ -5,12 +5,12 @@ · 4 unmodified lines | ||
| 5 | 5 | * workspace thread timeline. It does not store user-facing checkpoint metadata |
| 6 | 6 | * and does not coordinate provider conversation rollback. |
| 7 | 7 | * |
| 8 | − * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 8 | + * Uses Effect `Context.Service` for dependency injection and exposes typed | |
| 9 | 9 | * domain errors for checkpoint storage operations. |
| 10 | 10 | * |
| 11 | 11 | * @module CheckpointStore |
| 12 | 12 | */ |
| 13 | −import { ServiceMap } from "effect"; | |
| 13 | +import { Context } from "effect"; | |
| 14 | 14 | import type { Effect } from "effect"; |
| 15 | 15 | |
| 16 | 16 | import type { CheckpointStoreError } from "../Errors.ts"; |
| @@ -95,6 +95,6 @@ export interface CheckpointStoreShape { · 78 unmodified lines | ||
| 95 | 95 | /** |
| 96 | 96 | * CheckpointStore - Service tag for checkpoint persistence and restore operations. |
| 97 | 97 | */ |
| 98 | −export class CheckpointStore extends ServiceMap.Service<CheckpointStore, CheckpointStoreShape>()( | |
| 98 | +export class CheckpointStore extends Context.Service<CheckpointStore, CheckpointStoreShape>()( | |
| 99 | 99 | "t3/checkpointing/Services/CheckpointStore", |
| 100 | 100 | ) {} |
| @@ -4,7 +4,7 @@ import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; · 3 unmodified lines | ||
| 4 | 4 | export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; |
| 5 | 5 | |
| 6 | 6 | export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { |
| 7 | − return CheckpointRef.makeUnsafe( | |
| 7 | + return CheckpointRef.make( | |
| 8 | 8 | `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`, |
| 9 | 9 | ); |
| 10 | 10 | } |
| @@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { NetService } from "@t3tools/shared/Net"; |
| 7 | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | −import { deriveServerPaths } from "./config"; | |
| 9 | −import { resolveServerConfig } from "./cli"; | |
| 8 | +import { deriveServerPaths } from "./config.ts"; | |
| 9 | +import { resolveServerConfig } from "./cli.ts"; | |
| 10 | 10 | |
| 11 | 11 | it.layer(NodeServices.layer)("cli config resolution", (it) => { |
| 12 | 12 | const defaultObservabilityConfig = { |
| @@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 30 unmodified lines | ||
| 43 | 43 | cwd: Option.none(), |
| 44 | 44 | devUrl: Option.none(), |
| 45 | 45 | noBrowser: Option.none(), |
| 46 | − authToken: Option.none(), | |
| 47 | 46 | bootstrapFd: Option.none(), |
| 48 | 47 | autoBootstrapProjectFromCwd: Option.none(), |
| 49 | 48 | logWebSocketEvents: Option.none(), |
| @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 12 unmodified lines | ||
| 62 | 61 | T3CODE_HOME: baseDir, |
| 63 | 62 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 64 | 63 | T3CODE_NO_BROWSER: "true", |
| 65 | − T3CODE_AUTH_TOKEN: "env-token", | |
| 66 | 64 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 67 | 65 | T3CODE_LOG_WS_EVENTS: "true", |
| 68 | 66 | }, |
| @@ -85,7 +83,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 16 unmodified lines | ||
| 85 | 83 | staticDir: undefined, |
| 86 | 84 | devUrl: new URL("http://127.0.0.1:5173"), |
| 87 | 85 | noBrowser: true, |
| 88 | − authToken: "env-token", | |
| 86 | + startupPresentation: "browser", | |
| 87 | + desktopBootstrapToken: undefined, | |
| 89 | 88 | autoBootstrapProjectFromCwd: false, |
| 90 | 89 | logWebSocketEvents: true, |
| 91 | 90 | }); |
| @@ -106,7 +105,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 14 unmodified lines | ||
| 106 | 105 | cwd: Option.none(), |
| 107 | 106 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), |
| 108 | 107 | noBrowser: Option.some(true), |
| 109 | − authToken: Option.some("flag-token"), | |
| 110 | 108 | bootstrapFd: Option.none(), |
| 111 | 109 | autoBootstrapProjectFromCwd: Option.some(true), |
| 112 | 110 | logWebSocketEvents: Option.some(true), |
| @@ -125,7 +123,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 12 unmodified lines | ||
| 125 | 123 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), |
| 126 | 124 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", |
| 127 | 125 | T3CODE_NO_BROWSER: "false", |
| 128 | − T3CODE_AUTH_TOKEN: "ignored-token", | |
| 129 | 126 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", |
| 130 | 127 | T3CODE_LOG_WS_EVENTS: "false", |
| 131 | 128 | }, |
| @@ -148,13 +145,77 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 16 unmodified lines | ||
| 148 | 145 | staticDir: undefined, |
| 149 | 146 | devUrl: new URL("http://127.0.0.1:4173"), |
| 150 | 147 | noBrowser: true, |
| 151 | − authToken: "flag-token", | |
| 148 | + startupPresentation: "browser", | |
| 149 | + desktopBootstrapToken: undefined, | |
| 152 | 150 | autoBootstrapProjectFromCwd: true, |
| 153 | 151 | logWebSocketEvents: true, |
| 154 | 152 | }); |
| 155 | 153 | }), |
| 156 | 154 | ); |
| 157 | 155 | |
| 156 | + it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => | |
| 157 | + Effect.gen(function* () { | |
| 158 | + const { join } = yield* Path.Path; | |
| 159 | + const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); | |
| 160 | + const fd = yield* openBootstrapFd({ | |
| 161 | + noBrowser: true, | |
| 162 | + autoBootstrapProjectFromCwd: true, | |
| 163 | + logWebSocketEvents: true, | |
| 164 | + }); | |
| 165 | + const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | |
| 166 | + | |
| 167 | + const resolved = yield* resolveServerConfig( | |
| 168 | + { | |
| 169 | + mode: Option.some("web"), | |
| 170 | + port: Option.some(8788), | |
| 171 | + host: Option.some("127.0.0.1"), | |
| 172 | + baseDir: Option.some(baseDir), | |
| 173 | + cwd: Option.none(), | |
| 174 | + devUrl: Option.some(new URL("http://127.0.0.1:4173")), | |
| 175 | + noBrowser: Option.some(false), | |
| 176 | + bootstrapFd: Option.none(), | |
| 177 | + autoBootstrapProjectFromCwd: Option.some(false), | |
| 178 | + logWebSocketEvents: Option.some(false), | |
| 179 | + }, | |
| 180 | + Option.none(), | |
| 181 | + ).pipe( | |
| 182 | + Effect.provide( | |
| 183 | + Layer.mergeAll( | |
| 184 | + ConfigProvider.layer( | |
| 185 | + ConfigProvider.fromEnv({ | |
| 186 | + env: { | |
| 187 | + T3CODE_BOOTSTRAP_FD: String(fd), | |
| 188 | + T3CODE_NO_BROWSER: "true", | |
| 189 | + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | |
| 190 | + T3CODE_LOG_WS_EVENTS: "true", | |
| 191 | + }, | |
| 192 | + }), | |
| 193 | + ), | |
| 194 | + NetService.layer, | |
| 195 | + ), | |
| 196 | + ), | |
| 197 | + ); | |
| 198 | + | |
| 199 | + expect(resolved).toEqual({ | |
| 200 | + logLevel: "Info", | |
| 201 | + ...defaultObservabilityConfig, | |
| 202 | + mode: "web", | |
| 203 | + port: 8788, | |
| 204 | + cwd: process.cwd(), | |
| 205 | + baseDir, | |
| 206 | + ...derivedPaths, | |
| 207 | + host: "127.0.0.1", | |
| 208 | + staticDir: undefined, | |
| 209 | + devUrl: new URL("http://127.0.0.1:4173"), | |
| 210 | + noBrowser: false, | |
| 211 | + startupPresentation: "browser", | |
| 212 | + desktopBootstrapToken: undefined, | |
| 213 | + autoBootstrapProjectFromCwd: false, | |
| 214 | + logWebSocketEvents: false, | |
| 215 | + }); | |
| 216 | + }), | |
| 217 | + ); | |
| 218 | + | |
| 158 | 219 | it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => |
| 159 | 220 | Effect.gen(function* () { |
| 160 | 221 | const { join } = yield* Path.Path; |
| @@ -166,7 +227,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 5 unmodified lines | ||
| 166 | 227 | t3Home: baseDir, |
| 167 | 228 | devUrl: "http://127.0.0.1:5173", |
| 168 | 229 | noBrowser: true, |
| 169 | − authToken: "bootstrap-token", | |
| 170 | 230 | autoBootstrapProjectFromCwd: false, |
| 171 | 231 | logWebSocketEvents: true, |
| 172 | 232 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| @@ -183,7 +243,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 10 unmodified lines | ||
| 183 | 243 | cwd: Option.none(), |
| 184 | 244 | devUrl: Option.none(), |
| 185 | 245 | noBrowser: Option.none(), |
| 186 | − authToken: Option.none(), | |
| 187 | 246 | bootstrapFd: Option.none(), |
| 188 | 247 | autoBootstrapProjectFromCwd: Option.none(), |
| 189 | 248 | logWebSocketEvents: Option.none(), |
| @@ -218,7 +277,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 28 unmodified lines | ||
| 218 | 277 | staticDir: undefined, |
| 219 | 278 | devUrl: new URL("http://127.0.0.1:5173"), |
| 220 | 279 | noBrowser: true, |
| 221 | − authToken: "bootstrap-token", | |
| 280 | + startupPresentation: "browser", | |
| 281 | + desktopBootstrapToken: undefined, | |
| 222 | 282 | autoBootstrapProjectFromCwd: false, |
| 223 | 283 | logWebSocketEvents: true, |
| 224 | 284 | }); |
| @@ -242,7 +302,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 17 unmodified lines | ||
| 242 | 302 | cwd: Option.some(customCwd), |
| 243 | 303 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), |
| 244 | 304 | noBrowser: Option.none(), |
| 245 | − authToken: Option.none(), | |
| 246 | 305 | bootstrapFd: Option.none(), |
| 247 | 306 | autoBootstrapProjectFromCwd: Option.none(), |
| 248 | 307 | logWebSocketEvents: Option.none(), |
| @@ -285,7 +344,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 36 unmodified lines | ||
| 285 | 344 | t3Home: "/tmp/t3-bootstrap-home", |
| 286 | 345 | devUrl: "http://127.0.0.1:5173", |
| 287 | 346 | noBrowser: false, |
| 288 | − authToken: "bootstrap-token", | |
| 289 | 347 | autoBootstrapProjectFromCwd: false, |
| 290 | 348 | logWebSocketEvents: false, |
| 291 | 349 | }); |
| @@ -300,7 +358,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 8 unmodified lines | ||
| 300 | 358 | cwd: Option.none(), |
| 301 | 359 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), |
| 302 | 360 | noBrowser: Option.none(), |
| 303 | − authToken: Option.some("flag-token"), | |
| 304 | 361 | bootstrapFd: Option.none(), |
| 305 | 362 | autoBootstrapProjectFromCwd: Option.none(), |
| 306 | 363 | logWebSocketEvents: Option.none(), |
| @@ -338,7 +395,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 31 unmodified lines | ||
| 338 | 395 | staticDir: undefined, |
| 339 | 396 | devUrl: new URL("http://127.0.0.1:4173"), |
| 340 | 397 | noBrowser: true, |
| 341 | − authToken: "flag-token", | |
| 398 | + startupPresentation: "browser", | |
| 399 | + desktopBootstrapToken: undefined, | |
| 342 | 400 | autoBootstrapProjectFromCwd: true, |
| 343 | 401 | logWebSocketEvents: true, |
| 344 | 402 | }); |
| @@ -371,7 +429,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 26 unmodified lines | ||
| 371 | 429 | cwd: Option.none(), |
| 372 | 430 | devUrl: Option.none(), |
| 373 | 431 | noBrowser: Option.none(), |
| 374 | − authToken: Option.none(), | |
| 375 | 432 | bootstrapFd: Option.none(), |
| 376 | 433 | autoBootstrapProjectFromCwd: Option.none(), |
| 377 | 434 | logWebSocketEvents: Option.none(), |
| @@ -402,7 +459,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 24 unmodified lines | ||
| 402 | 459 | staticDir: resolved.staticDir, |
| 403 | 460 | devUrl: undefined, |
| 404 | 461 | noBrowser: true, |
| 405 | − authToken: undefined, | |
| 462 | + startupPresentation: "browser", | |
| 463 | + desktopBootstrapToken: undefined, | |
| 464 | + autoBootstrapProjectFromCwd: false, | |
| 465 | + logWebSocketEvents: false, | |
| 466 | + }); | |
| 467 | + }), | |
| 468 | + ); | |
| 469 | + | |
| 470 | + it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => | |
| 471 | + Effect.gen(function* () { | |
| 472 | + const { join } = yield* Path.Path; | |
| 473 | + const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base"); | |
| 474 | + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | |
| 475 | + | |
| 476 | + const resolved = yield* resolveServerConfig( | |
| 477 | + { | |
| 478 | + mode: Option.some("web"), | |
| 479 | + port: Option.some(3773), | |
| 480 | + host: Option.none(), | |
| 481 | + baseDir: Option.some(baseDir), | |
| 482 | + cwd: Option.none(), | |
| 483 | + devUrl: Option.none(), | |
| 484 | + noBrowser: Option.none(), | |
| 485 | + bootstrapFd: Option.none(), | |
| 486 | + autoBootstrapProjectFromCwd: Option.none(), | |
| 487 | + logWebSocketEvents: Option.none(), | |
| 488 | + }, | |
| 489 | + Option.none(), | |
| 490 | + { | |
| 491 | + startupPresentation: "headless", | |
| 492 | + }, | |
| 493 | + ).pipe( | |
| 494 | + Effect.provide( | |
| 495 | + Layer.mergeAll( | |
| 496 | + ConfigProvider.layer( | |
| 497 | + ConfigProvider.fromEnv({ | |
| 498 | + env: { | |
| 499 | + T3CODE_NO_BROWSER: "false", | |
| 500 | + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | |
| 501 | + }, | |
| 502 | + }), | |
| 503 | + ), | |
| 504 | + NetService.layer, | |
| 505 | + ), | |
| 506 | + ), | |
| 507 | + ); | |
| 508 | + | |
| 509 | + expect(resolved).toEqual({ | |
| 510 | + logLevel: "Info", | |
| 511 | + ...defaultObservabilityConfig, | |
| 512 | + mode: "web", | |
| 513 | + port: 3773, | |
| 514 | + cwd: process.cwd(), | |
| 515 | + baseDir, | |
| 516 | + ...derivedPaths, | |
| 517 | + host: undefined, | |
| 518 | + staticDir: resolved.staticDir, | |
| 519 | + devUrl: undefined, | |
| 520 | + noBrowser: true, | |
| 521 | + startupPresentation: "headless", | |
| 522 | + desktopBootstrapToken: undefined, | |
| 406 | 523 | autoBootstrapProjectFromCwd: false, |
| 407 | 524 | logWebSocketEvents: false, |
| 408 | 525 | }); |
| @@ -1,28 +1,164 @@ | ||
| 1 | +import * as NodeHttp from "node:http"; | |
| 2 | +import { mkdtempSync } from "node:fs"; | |
| 3 | +import { tmpdir } from "node:os"; | |
| 4 | +import { join } from "node:path"; | |
| 5 | + | |
| 6 | +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | |
| 1 | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 8 | import { NetService } from "@t3tools/shared/Net"; |
| 3 | 9 | import { assert, it } from "@effect/vitest"; |
| 4 | 10 | import * as Effect from "effect/Effect"; |
| 5 | 11 | import * as Layer from "effect/Layer"; |
| 12 | +import * as HttpRouter from "effect/unstable/http/HttpRouter"; | |
| 13 | +import * as HttpServer from "effect/unstable/http/HttpServer"; | |
| 6 | 14 | import * as CliError from "effect/unstable/cli/CliError"; |
| 15 | +import * as TestConsole from "effect/testing/TestConsole"; | |
| 7 | 16 | import { Command } from "effect/unstable/cli"; |
| 8 | 17 | |
| 9 | 18 | import { cli } from "./cli.ts"; |
| 19 | +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; | |
| 20 | +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; | |
| 21 | +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 22 | +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | |
| 23 | +import { | |
| 24 | + orchestrationDispatchRouteLayer, | |
| 25 | + orchestrationSnapshotRouteLayer, | |
| 26 | +} from "./orchestration/http.ts"; | |
| 27 | +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | |
| 28 | +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | |
| 29 | +import { | |
| 30 | + makePersistedServerRuntimeState, | |
| 31 | + persistServerRuntimeState, | |
| 32 | +} from "./serverRuntimeState.ts"; | |
| 33 | +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | |
| 34 | +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | |
| 35 | +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | |
| 10 | 36 | |
| 11 | 37 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 12 | 38 | |
| 39 | +const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); | |
| 40 | +const runCliWithRuntime = (args: ReadonlyArray<string>) => | |
| 41 | + runCli(args).pipe(Effect.provide(CliRuntimeLayer)); | |
| 42 | + | |
| 43 | +const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 44 | + Effect.gen(function* () { | |
| 45 | + const result = yield* effect; | |
| 46 | + const output = | |
| 47 | + (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? | |
| 48 | + ""; | |
| 49 | + return { result, output }; | |
| 50 | + }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); | |
| 51 | + | |
| 52 | +const makeCliTestServerConfig = (baseDir: string) => | |
| 53 | + Effect.gen(function* () { | |
| 54 | + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | |
| 55 | + return { | |
| 56 | + logLevel: "Info", | |
| 57 | + traceMinLevel: "Info", | |
| 58 | + traceTimingEnabled: true, | |
| 59 | + traceBatchWindowMs: 200, | |
| 60 | + traceMaxBytes: 10 * 1024 * 1024, | |
| 61 | + traceMaxFiles: 10, | |
| 62 | + otlpTracesUrl: undefined, | |
| 63 | + otlpMetricsUrl: undefined, | |
| 64 | + otlpExportIntervalMs: 10_000, | |
| 65 | + otlpServiceName: "t3-server", | |
| 66 | + mode: "web", | |
| 67 | + port: 0, | |
| 68 | + host: "127.0.0.1", | |
| 69 | + cwd: process.cwd(), | |
| 70 | + baseDir, | |
| 71 | + ...derivedPaths, | |
| 72 | + staticDir: undefined, | |
| 73 | + devUrl: undefined, | |
| 74 | + noBrowser: true, | |
| 75 | + startupPresentation: "browser", | |
| 76 | + desktopBootstrapToken: undefined, | |
| 77 | + autoBootstrapProjectFromCwd: false, | |
| 78 | + logWebSocketEvents: false, | |
| 79 | + } satisfies ServerConfigShape; | |
| 80 | + }); | |
| 81 | + | |
| 82 | +const makeProjectPersistenceLayer = (config: ServerConfigShape) => | |
| 83 | + Layer.mergeAll( | |
| 84 | + OrchestrationLayerLive.pipe( | |
| 85 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 86 | + Layer.provideMerge(SqlitePersistenceLayerLive), | |
| 87 | + ), | |
| 88 | + WorkspacePathsLive, | |
| 89 | + ).pipe( | |
| 90 | + Layer.provideMerge(NodeServices.layer), | |
| 91 | + Layer.provide(Layer.succeed(ServerConfig, config)), | |
| 92 | + ); | |
| 93 | + | |
| 94 | +const readPersistedSnapshot = (baseDir: string) => | |
| 95 | + Effect.gen(function* () { | |
| 96 | + const config = yield* makeCliTestServerConfig(baseDir); | |
| 97 | + return yield* Effect.gen(function* () { | |
| 98 | + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 99 | + return yield* projectionSnapshotQuery.getSnapshot(); | |
| 100 | + }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); | |
| 101 | + }); | |
| 102 | + | |
| 103 | +const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => | |
| 104 | + Effect.gen(function* () { | |
| 105 | + const config = yield* makeCliTestServerConfig(baseDir); | |
| 106 | + const routesLayer = Layer.mergeAll( | |
| 107 | + orchestrationSnapshotRouteLayer, | |
| 108 | + orchestrationDispatchRouteLayer, | |
| 109 | + ); | |
| 110 | + const appLayer = HttpRouter.serve(routesLayer, { | |
| 111 | + disableListenLog: true, | |
| 112 | + disableLogger: true, | |
| 113 | + }).pipe( | |
| 114 | + Layer.provideMerge( | |
| 115 | + ServerAuthLive.pipe( | |
| 116 | + Layer.provideMerge(SqlitePersistenceLayerLive), | |
| 117 | + Layer.provide(ServerSecretStoreLive), | |
| 118 | + ), | |
| 119 | + ), | |
| 120 | + Layer.provideMerge(makeProjectPersistenceLayer(config)), | |
| 121 | + Layer.provideMerge( | |
| 122 | + NodeHttpServer.layer(NodeHttp.createServer, { | |
| 123 | + host: "127.0.0.1", | |
| 124 | + port: 0, | |
| 125 | + }), | |
| 126 | + ), | |
| 127 | + Layer.provideMerge(NodeServices.layer), | |
| 128 | + Layer.provide(Layer.succeed(ServerConfig, config)), | |
| 129 | + ); | |
| 130 | + | |
| 131 | + return yield* Effect.scoped( | |
| 132 | + Effect.gen(function* () { | |
| 133 | + const server = yield* HttpServer.HttpServer; | |
| 134 | + const address = server.address; | |
| 135 | + if (typeof address === "string" || !("port" in address)) { | |
| 136 | + assert.fail(`Expected TCP address, got ${address}`); | |
| 137 | + } | |
| 138 | + yield* persistServerRuntimeState({ | |
| 139 | + path: config.serverRuntimeStatePath, | |
| 140 | + state: makePersistedServerRuntimeState({ | |
| 141 | + config, | |
| 142 | + port: address.port, | |
| 143 | + }), | |
| 144 | + }); | |
| 145 | + return yield* run(); | |
| 146 | + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), | |
| 147 | + ); | |
| 148 | + }); | |
| 149 | + | |
| 13 | 150 | it.layer(NodeServices.layer)("cli log-level parsing", (it) => { |
| 14 | 151 | it.effect("accepts the built-in lowercase log-level flag values", () => |
| 15 | − Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe( | |
| 16 | − Effect.provide(CliRuntimeLayer), | |
| 17 | − ), | |
| 152 | + runCliWithRuntime(["--log-level", "debug", "--version"]), | |
| 153 | + ); | |
| 154 | + | |
| 155 | + it.effect("accepts canonical --no-<flag> boolean negation", () => | |
| 156 | + runCliWithRuntime(["--no-log-websocket-events", "--version"]), | |
| 18 | 157 | ); |
| 19 | 158 | |
| 20 | 159 | it.effect("rejects invalid log-level casing before launching the server", () => |
| 21 | 160 | Effect.gen(function* () { |
| 22 | − const error = yield* Command.runWith(cli, { version: "0.0.0" })([ | |
| 23 | − "--log-level", | |
| 24 | − "Debug", | |
| 25 | − ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip); | |
| 161 | + const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); | |
| 26 | 162 | |
| 27 | 163 | if (!CliError.isCliError(error)) { |
| 28 | 164 | assert.fail(`Expected CliError, got ${String(error)}`); |
| @@ -34,4 +170,187 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { · 5 unmodified lines | ||
| 34 | 170 | assert.equal(error.value, "Debug"); |
| 35 | 171 | }), |
| 36 | 172 | ); |
| 173 | + | |
| 174 | + it.effect("executes auth pairing subcommands and redacts secrets from list output", () => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); | |
| 177 | + | |
| 178 | + const createdOutput = yield* captureStdout( | |
| 179 | + runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), | |
| 180 | + ); | |
| 181 | + const created = JSON.parse(createdOutput.output) as { | |
| 182 | + readonly id: string; | |
| 183 | + readonly credential: string; | |
| 184 | + }; | |
| 185 | + const listedOutput = yield* captureStdout( | |
| 186 | + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), | |
| 187 | + ); | |
| 188 | + const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | |
| 189 | + readonly id: string; | |
| 190 | + readonly credential?: string; | |
| 191 | + }>; | |
| 192 | + | |
| 193 | + assert.equal(typeof created.id, "string"); | |
| 194 | + assert.equal(typeof created.credential, "string"); | |
| 195 | + assert.equal(created.credential.length > 0, true); | |
| 196 | + assert.equal(listed.length, 1); | |
| 197 | + assert.equal(listed[0]?.id, created.id); | |
| 198 | + assert.equal("credential" in (listed[0] ?? {}), false); | |
| 199 | + }), | |
| 200 | + ); | |
| 201 | + | |
| 202 | + it.effect("executes auth session subcommands and redacts secrets from list output", () => | |
| 203 | + Effect.gen(function* () { | |
| 204 | + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); | |
| 205 | + | |
| 206 | + const issuedOutput = yield* captureStdout( | |
| 207 | + runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), | |
| 208 | + ); | |
| 209 | + const issued = JSON.parse(issuedOutput.output) as { | |
| 210 | + readonly sessionId: string; | |
| 211 | + readonly token: string; | |
| 212 | + readonly role: string; | |
| 213 | + }; | |
| 214 | + const listedOutput = yield* captureStdout( | |
| 215 | + runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), | |
| 216 | + ); | |
| 217 | + const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | |
| 218 | + readonly sessionId: string; | |
| 219 | + readonly token?: string; | |
| 220 | + readonly role: string; | |
| 221 | + }>; | |
| 222 | + | |
| 223 | + assert.equal(typeof issued.sessionId, "string"); | |
| 224 | + assert.equal(typeof issued.token, "string"); | |
| 225 | + assert.equal(issued.role, "owner"); | |
| 226 | + assert.equal(listed.length, 1); | |
| 227 | + assert.equal(listed[0]?.sessionId, issued.sessionId); | |
| 228 | + assert.equal(listed[0]?.role, "owner"); | |
| 229 | + assert.equal("token" in (listed[0] ?? {}), false); | |
| 230 | + }), | |
| 231 | + ); | |
| 232 | + | |
| 233 | + it.effect("rejects invalid ttl values before running auth commands", () => | |
| 234 | + Effect.gen(function* () { | |
| 235 | + const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( | |
| 236 | + Effect.flip, | |
| 237 | + ); | |
| 238 | + | |
| 239 | + if (!CliError.isCliError(error)) { | |
| 240 | + assert.fail(`Expected CliError, got ${String(error)}`); | |
| 241 | + } | |
| 242 | + if (error._tag !== "ShowHelp") { | |
| 243 | + assert.fail(`Expected ShowHelp, got ${error._tag}`); | |
| 244 | + } | |
| 245 | + assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]); | |
| 246 | + const ttlError = error.errors[0] as CliError.CliError | undefined; | |
| 247 | + if (!ttlError || ttlError._tag !== "InvalidValue") { | |
| 248 | + assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`); | |
| 249 | + } | |
| 250 | + assert.equal(ttlError.option, "ttl"); | |
| 251 | + assert.equal(ttlError.value, "soon"); | |
| 252 | + assert.isTrue(ttlError.message.includes("Invalid duration")); | |
| 253 | + assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); | |
| 254 | + }), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + it.effect("adds, renames, and removes projects offline through the orchestration engine", () => | |
| 258 | + Effect.gen(function* () { | |
| 259 | + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-")); | |
| 260 | + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-")); | |
| 261 | + | |
| 262 | + yield* runCliWithRuntime([ | |
| 263 | + "project", | |
| 264 | + "add", | |
| 265 | + workspaceRoot, | |
| 266 | + "--title", | |
| 267 | + "Alpha", | |
| 268 | + "--base-dir", | |
| 269 | + baseDir, | |
| 270 | + ]); | |
| 271 | + const afterAdd = yield* readPersistedSnapshot(baseDir); | |
| 272 | + const addedProject = afterAdd.projects.find( | |
| 273 | + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, | |
| 274 | + ); | |
| 275 | + assert.isTrue(addedProject !== undefined); | |
| 276 | + assert.equal(addedProject?.title, "Alpha"); | |
| 277 | + | |
| 278 | + yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]); | |
| 279 | + const afterRename = yield* readPersistedSnapshot(baseDir); | |
| 280 | + const renamedProject = afterRename.projects.find( | |
| 281 | + (project) => project.id === addedProject?.id, | |
| 282 | + ); | |
| 283 | + assert.equal(renamedProject?.title, "Beta"); | |
| 284 | + assert.equal(renamedProject?.deletedAt, null); | |
| 285 | + | |
| 286 | + yield* runCliWithRuntime([ | |
| 287 | + "project", | |
| 288 | + "remove", | |
| 289 | + addedProject?.id ?? "", | |
| 290 | + "--base-dir", | |
| 291 | + baseDir, | |
| 292 | + ]); | |
| 293 | + const afterRemove = yield* readPersistedSnapshot(baseDir); | |
| 294 | + const removedProject = afterRemove.projects.find( | |
| 295 | + (project) => project.id === addedProject?.id, | |
| 296 | + ); | |
| 297 | + assert.isTrue((removedProject?.deletedAt ?? null) !== null); | |
| 298 | + }), | |
| 299 | + ); | |
| 300 | + | |
| 301 | + it.effect("routes project commands through a running server when runtime state is present", () => | |
| 302 | + Effect.gen(function* () { | |
| 303 | + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-")); | |
| 304 | + const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-")); | |
| 305 | + | |
| 306 | + yield* withLiveProjectCliServer(baseDir, () => | |
| 307 | + Effect.gen(function* () { | |
| 308 | + yield* runCliWithRuntime([ | |
| 309 | + "project", | |
| 310 | + "add", | |
| 311 | + workspaceRoot, | |
| 312 | + "--title", | |
| 313 | + "Live Project", | |
| 314 | + "--base-dir", | |
| 315 | + baseDir, | |
| 316 | + ]); | |
| 317 | + const orchestrationEngine = yield* OrchestrationEngineService; | |
| 318 | + const readModel = yield* orchestrationEngine.getReadModel(); | |
| 319 | + const addedProject = readModel.projects.find( | |
| 320 | + (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, | |
| 321 | + ); | |
| 322 | + assert.isTrue(addedProject !== undefined); | |
| 323 | + assert.equal(addedProject?.title, "Live Project"); | |
| 324 | + }), | |
| 325 | + ); | |
| 326 | + }), | |
| 327 | + ); | |
| 328 | + | |
| 329 | + it.effect("rejects dev-url on project commands", () => | |
| 330 | + Effect.gen(function* () { | |
| 331 | + const workspaceRoot = mkdtempSync( | |
| 332 | + join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"), | |
| 333 | + ); | |
| 334 | + const error = yield* runCliWithRuntime([ | |
| 335 | + "project", | |
| 336 | + "add", | |
| 337 | + workspaceRoot, | |
| 338 | + "--dev-url", | |
| 339 | + "http://127.0.0.1:5173", | |
| 340 | + ]).pipe(Effect.flip); | |
| 341 | + | |
| 342 | + if (!CliError.isCliError(error)) { | |
| 343 | + assert.fail(`Expected CliError, got ${String(error)}`); | |
| 344 | + } | |
| 345 | + if (error._tag !== "ShowHelp") { | |
| 346 | + assert.fail(`Expected ShowHelp, got ${error._tag}`); | |
| 347 | + } | |
| 348 | + assert.deepEqual(error.commandPath, ["t3", "project", "add"]); | |
| 349 | + const optionError = error.errors[0] as CliError.CliError | undefined; | |
| 350 | + if (!optionError || optionError._tag !== "UnrecognizedOption") { | |
| 351 | + assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); | |
| 352 | + } | |
| 353 | + assert.equal(optionError.option, "--dev-url"); | |
| 354 | + }), | |
| 355 | + ); | |
| 37 | 356 | }); |
| @@ -1,7 +1,35 @@ | ||
| 1 | 1 | import { NetService } from "@t3tools/shared/Net"; |
| 2 | 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; |
| 3 | −import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; | |
| 3 | +import { | |
| 4 | + AuthSessionId, | |
| 5 | + CommandId, | |
| 6 | + OrchestrationReadModel, | |
| 7 | + ProjectId, | |
| 8 | + type ClientOrchestrationCommand, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import { | |
| 11 | + Config, | |
| 12 | + Console, | |
| 13 | + Duration, | |
| 14 | + Effect, | |
| 15 | + Exit, | |
| 16 | + FileSystem, | |
| 17 | + Layer, | |
| 18 | + LogLevel, | |
| 19 | + Option, | |
| 20 | + Path, | |
| 21 | + References, | |
| 22 | + Schema, | |
| 23 | + SchemaIssue, | |
| 24 | + SchemaTransformation, | |
| 25 | +} from "effect"; | |
| 4 | 26 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 27 | +import { | |
| 28 | + FetchHttpClient, | |
| 29 | + HttpClient, | |
| 30 | + HttpClientRequest, | |
| 31 | + HttpClientResponse, | |
| 32 | +} from "effect/unstable/http"; | |
| 5 | 33 | |
| 6 | 34 | import { |
| 7 | 35 | DEFAULT_PORT, |
| @@ -11,10 +39,32 @@ import { · 3 unmodified lines | ||
| 11 | 39 | ServerConfig, |
| 12 | 40 | RuntimeMode, |
| 13 | 41 | type ServerConfigShape, |
| 14 | −} from "./config"; | |
| 15 | −import { readBootstrapEnvelope } from "./bootstrap"; | |
| 16 | −import { expandHomePath, resolveBaseDir } from "./os-jank"; | |
| 17 | −import { runServer } from "./server"; | |
| 42 | + type StartupPresentation, | |
| 43 | +} from "./config.ts"; | |
| 44 | +import { readBootstrapEnvelope } from "./bootstrap.ts"; | |
| 45 | +import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; | |
| 46 | +import { runServer } from "./server.ts"; | |
| 47 | +import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; | |
| 48 | +import { | |
| 49 | + formatIssuedPairingCredential, | |
| 50 | + formatIssuedSession, | |
| 51 | + formatPairingCredentialList, | |
| 52 | + formatSessionList, | |
| 53 | +} from "./cliAuthFormat.ts"; | |
| 54 | +import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts"; | |
| 55 | +import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; | |
| 56 | +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; | |
| 57 | +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | |
| 58 | +import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | |
| 59 | +import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | |
| 60 | +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | |
| 61 | +import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts"; | |
| 62 | +import { | |
| 63 | + clearPersistedServerRuntimeState, | |
| 64 | + readPersistedServerRuntimeState, | |
| 65 | +} from "./serverRuntimeState.ts"; | |
| 66 | +import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; | |
| 67 | +import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | |
| 18 | 68 | |
| 19 | 69 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); |
| 20 | 70 | |
| @@ -25,7 +75,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ · 4 unmodified lines | ||
| 25 | 75 | t3Home: Schema.optional(Schema.String), |
| 26 | 76 | devUrl: Schema.optional(Schema.URLFromString), |
| 27 | 77 | noBrowser: Schema.optional(Schema.Boolean), |
| 28 | − authToken: Schema.optional(Schema.String), | |
| 78 | + desktopBootstrapToken: Schema.optional(Schema.String), | |
| 29 | 79 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), |
| 30 | 80 | logWebSocketEvents: Schema.optional(Schema.Boolean), |
| 31 | 81 | otlpTracesUrl: Schema.optional(Schema.String), |
| @@ -58,11 +108,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( · 26 unmodified lines | ||
| 58 | 108 | Flag.withDescription("Disable automatic browser opening."), |
| 59 | 109 | Flag.optional, |
| 60 | 110 | ); |
| 61 | −const authTokenFlag = Flag.string("auth-token").pipe( | |
| 62 | − Flag.withDescription("Auth token required for WebSocket connections."), | |
| 63 | − Flag.withAlias("token"), | |
| 64 | − Flag.optional, | |
| 65 | −); | |
| 66 | 111 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( |
| 67 | 112 | Flag.withSchema(Schema.Int), |
| 68 | 113 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), |
| @@ -117,10 +162,6 @@ const EnvServerConfig = Config.all({ · 48 unmodified lines | ||
| 117 | 162 | Config.option, |
| 118 | 163 | Config.map(Option.getOrUndefined), |
| 119 | 164 | ), |
| 120 | − authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( | |
| 121 | − Config.option, | |
| 122 | − Config.map(Option.getOrUndefined), | |
| 123 | − ), | |
| 124 | 165 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( |
| 125 | 166 | Config.option, |
| 126 | 167 | Config.map(Option.getOrUndefined), |
| @@ -143,14 +184,15 @@ interface CliServerFlags { · 16 unmodified lines | ||
| 143 | 184 | readonly cwd: Option.Option<string>; |
| 144 | 185 | readonly devUrl: Option.Option<URL>; |
| 145 | 186 | readonly noBrowser: Option.Option<boolean>; |
| 146 | − readonly authToken: Option.Option<string>; | |
| 147 | 187 | readonly bootstrapFd: Option.Option<number>; |
| 148 | 188 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; |
| 149 | 189 | readonly logWebSocketEvents: Option.Option<boolean>; |
| 150 | 190 | } |
| 151 | 191 | |
| 152 | −const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | |
| 153 | − Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | |
| 192 | +interface CliAuthLocationFlags { | |
| 193 | + readonly baseDir: Option.Option<string>; | |
| 194 | + readonly devUrl?: Option.Option<URL>; | |
| 195 | +} | |
| 154 | 196 | |
| 155 | 197 | const resolveOptionPrecedence = <Value>( |
| 156 | 198 | ...values: ReadonlyArray<Option.Option<Value>> |
| @@ -170,32 +212,49 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st · 13 unmodified lines | ||
| 170 | 212 | export const resolveServerConfig = ( |
| 171 | 213 | flags: CliServerFlags, |
| 172 | 214 | cliLogLevel: Option.Option<LogLevel.LogLevel>, |
| 215 | + options?: { | |
| 216 | + readonly startupPresentation?: StartupPresentation; | |
| 217 | + readonly forceAutoBootstrapProjectFromCwd?: boolean; | |
| 218 | + }, | |
| 173 | 219 | ) => |
| 174 | 220 | Effect.gen(function* () { |
| 175 | 221 | const { findAvailablePort } = yield* NetService; |
| 176 | 222 | const path = yield* Path.Path; |
| 177 | 223 | const fs = yield* FileSystem.FileSystem; |
| 178 | 224 | const env = yield* EnvServerConfig; |
| 179 | − const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd; | |
| 225 | + const normalizedFlags = { | |
| 226 | + mode: flags.mode ?? Option.none(), | |
| 227 | + port: flags.port ?? Option.none(), | |
| 228 | + host: flags.host ?? Option.none(), | |
| 229 | + baseDir: flags.baseDir ?? Option.none(), | |
| 230 | + cwd: flags.cwd ?? Option.none(), | |
| 231 | + devUrl: flags.devUrl ?? Option.none(), | |
| 232 | + noBrowser: flags.noBrowser ?? Option.none(), | |
| 233 | + bootstrapFd: flags.bootstrapFd ?? Option.none(), | |
| 234 | + autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), | |
| 235 | + logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), | |
| 236 | + } satisfies CliServerFlags; | |
| 237 | + const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; | |
| 180 | 238 | const bootstrapEnvelope = |
| 181 | 239 | bootstrapFd !== undefined |
| 182 | 240 | ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) |
| 183 | 241 | : Option.none(); |
| 242 | + const bootstrap = Option.getOrUndefined(bootstrapEnvelope); | |
| 184 | 243 | |
| 185 | 244 | const mode: RuntimeMode = Option.getOrElse( |
| 186 | 245 | resolveOptionPrecedence( |
| 187 | − flags.mode, | |
| 246 | + normalizedFlags.mode, | |
| 188 | 247 | Option.fromUndefinedOr(env.mode), |
| 189 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)), | |
| 248 | + Option.fromUndefinedOr(bootstrap?.mode), | |
| 190 | 249 | ), |
| 191 | 250 | () => "web", |
| 192 | 251 | ); |
| 193 | 252 | |
| 194 | 253 | const port = yield* Option.match( |
| 195 | 254 | resolveOptionPrecedence( |
| 196 | − flags.port, | |
| 255 | + normalizedFlags.port, | |
| 197 | 256 | Option.fromUndefinedOr(env.port), |
| 198 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)), | |
| 257 | + Option.fromUndefinedOr(bootstrap?.port), | |
| 199 | 258 | ), |
| 200 | 259 | { |
| 201 | 260 | onSome: (value) => Effect.succeed(value), |
| @@ -209,24 +268,22 @@ export const resolveServerConfig = ( · 7 unmodified lines | ||
| 209 | 268 | ); |
| 210 | 269 | const devUrl = Option.getOrElse( |
| 211 | 270 | resolveOptionPrecedence( |
| 212 | − flags.devUrl, | |
| 271 | + normalizedFlags.devUrl, | |
| 213 | 272 | Option.fromUndefinedOr(env.devUrl), |
| 214 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)), | |
| 273 | + Option.fromUndefinedOr(bootstrap?.devUrl), | |
| 215 | 274 | ), |
| 216 | 275 | () => undefined, |
| 217 | 276 | ); |
| 218 | 277 | const baseDir = yield* resolveBaseDir( |
| 219 | 278 | Option.getOrUndefined( |
| 220 | 279 | resolveOptionPrecedence( |
| 221 | − flags.baseDir, | |
| 280 | + normalizedFlags.baseDir, | |
| 222 | 281 | Option.fromUndefinedOr(env.t3Home), |
| 223 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 224 | − Option.fromUndefinedOr(bootstrap.t3Home), | |
| 225 | − ), | |
| 282 | + Option.fromUndefinedOr(bootstrap?.t3Home), | |
| 226 | 283 | ), |
| 227 | 284 | ), |
| 228 | 285 | ); |
| 229 | − const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd()); | |
| 286 | + const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); | |
| 230 | 287 | const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); |
| 231 | 288 | yield* fs.makeDirectory(cwd, { recursive: true }); |
| 232 | 289 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); |
| @@ -236,57 +293,42 @@ export const resolveServerConfig = ( · 3 unmodified lines | ||
| 236 | 293 | ); |
| 237 | 294 | const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; |
| 238 | 295 | yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); |
| 239 | − const noBrowser = resolveBooleanFlag( | |
| 240 | − flags.noBrowser, | |
| 241 | − Option.getOrElse( | |
| 242 | − resolveOptionPrecedence( | |
| 243 | − Option.fromUndefinedOr(env.noBrowser), | |
| 244 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 245 | − Option.fromUndefinedOr(bootstrap.noBrowser), | |
| 246 | − ), | |
| 247 | − ), | |
| 248 | − () => mode === "desktop", | |
| 249 | − ), | |
| 250 | − ); | |
| 251 | − const authToken = Option.getOrUndefined( | |
| 296 | + const startupPresentation = options?.startupPresentation ?? "browser"; | |
| 297 | + const isHeadlessStartup = startupPresentation === "headless"; | |
| 298 | + const noBrowser = Option.getOrElse( | |
| 252 | 299 | resolveOptionPrecedence( |
| 253 | − flags.authToken, | |
| 254 | − Option.fromUndefinedOr(env.authToken), | |
| 255 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 256 | − Option.fromUndefinedOr(bootstrap.authToken), | |
| 257 | − ), | |
| 300 | + isHeadlessStartup ? Option.some(true) : Option.none(), | |
| 301 | + normalizedFlags.noBrowser, | |
| 302 | + Option.fromUndefinedOr(env.noBrowser), | |
| 303 | + Option.fromUndefinedOr(bootstrap?.noBrowser), | |
| 258 | 304 | ), |
| 305 | + () => mode === "desktop", | |
| 259 | 306 | ); |
| 260 | − const autoBootstrapProjectFromCwd = resolveBooleanFlag( | |
| 261 | − flags.autoBootstrapProjectFromCwd, | |
| 262 | − Option.getOrElse( | |
| 263 | − resolveOptionPrecedence( | |
| 264 | − Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | |
| 265 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 266 | − Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), | |
| 267 | − ), | |
| 268 | − ), | |
| 269 | − () => mode === "web", | |
| 307 | + const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; | |
| 308 | + const autoBootstrapProjectFromCwd = Option.getOrElse( | |
| 309 | + resolveOptionPrecedence( | |
| 310 | + Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), | |
| 311 | + isHeadlessStartup ? Option.some(false) : Option.none(), | |
| 312 | + normalizedFlags.autoBootstrapProjectFromCwd, | |
| 313 | + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | |
| 314 | + Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), | |
| 270 | 315 | ), |
| 316 | + () => mode === "web", | |
| 271 | 317 | ); |
| 272 | − const logWebSocketEvents = resolveBooleanFlag( | |
| 273 | − flags.logWebSocketEvents, | |
| 274 | − Option.getOrElse( | |
| 275 | − resolveOptionPrecedence( | |
| 276 | − Option.fromUndefinedOr(env.logWebSocketEvents), | |
| 277 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 278 | − Option.fromUndefinedOr(bootstrap.logWebSocketEvents), | |
| 279 | − ), | |
| 280 | − ), | |
| 281 | − () => Boolean(devUrl), | |
| 318 | + const logWebSocketEvents = Option.getOrElse( | |
| 319 | + resolveOptionPrecedence( | |
| 320 | + normalizedFlags.logWebSocketEvents, | |
| 321 | + Option.fromUndefinedOr(env.logWebSocketEvents), | |
| 322 | + Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), | |
| 282 | 323 | ), |
| 324 | + () => Boolean(devUrl), | |
| 283 | 325 | ); |
| 284 | 326 | const staticDir = devUrl ? undefined : yield* resolveStaticDir(); |
| 285 | 327 | const host = Option.getOrElse( |
| 286 | 328 | resolveOptionPrecedence( |
| 287 | − flags.host, | |
| 329 | + normalizedFlags.host, | |
| 288 | 330 | Option.fromUndefinedOr(env.host), |
| 289 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), | |
| 331 | + Option.fromUndefinedOr(bootstrap?.host), | |
| 290 | 332 | ), |
| 291 | 333 | () => (mode === "desktop" ? "127.0.0.1" : undefined), |
| 292 | 334 | ); |
| @@ -301,19 +343,11 @@ export const resolveServerConfig = ( · 8 unmodified lines | ||
| 301 | 343 | traceMaxFiles: env.traceMaxFiles, |
| 302 | 344 | otlpTracesUrl: |
| 303 | 345 | env.otlpTracesUrl ?? |
| 304 | − Option.getOrUndefined( | |
| 305 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 306 | − Option.fromUndefinedOr(bootstrap.otlpTracesUrl), | |
| 307 | − ), | |
| 308 | − ) ?? | |
| 346 | + bootstrap?.otlpTracesUrl ?? | |
| 309 | 347 | persistedObservabilitySettings.otlpTracesUrl, |
| 310 | 348 | otlpMetricsUrl: |
| 311 | 349 | env.otlpMetricsUrl ?? |
| 312 | − Option.getOrUndefined( | |
| 313 | − Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 314 | − Option.fromUndefinedOr(bootstrap.otlpMetricsUrl), | |
| 315 | − ), | |
| 316 | − ) ?? | |
| 350 | + bootstrap?.otlpMetricsUrl ?? | |
| 317 | 351 | persistedObservabilitySettings.otlpMetricsUrl, |
| 318 | 352 | otlpExportIntervalMs: env.otlpExportIntervalMs, |
| 319 | 353 | otlpServiceName: env.otlpServiceName, |
| @@ -327,7 +361,8 @@ export const resolveServerConfig = ( · 7 unmodified lines | ||
| 327 | 361 | staticDir, |
| 328 | 362 | devUrl, |
| 329 | 363 | noBrowser, |
| 330 | − authToken, | |
| 364 | + startupPresentation, | |
| 365 | + desktopBootstrapToken, | |
| 331 | 366 | autoBootstrapProjectFromCwd, |
| 332 | 367 | logWebSocketEvents, |
| 333 | 368 | }; |
| @@ -335,7 +370,387 @@ export const resolveServerConfig = ( · 1 unmodified line | ||
| 335 | 370 | return config; |
| 336 | 371 | }); |
| 337 | 372 | |
| 338 | −const commandFlags = { | |
| 373 | +const resolveCliAuthConfig = ( | |
| 374 | + flags: CliAuthLocationFlags, | |
| 375 | + cliLogLevel: Option.Option<LogLevel.LogLevel>, | |
| 376 | +) => | |
| 377 | + resolveServerConfig( | |
| 378 | + { | |
| 379 | + mode: Option.none(), | |
| 380 | + port: Option.none(), | |
| 381 | + host: Option.none(), | |
| 382 | + baseDir: flags.baseDir, | |
| 383 | + cwd: Option.none(), | |
| 384 | + devUrl: flags.devUrl ?? Option.none(), | |
| 385 | + noBrowser: Option.none(), | |
| 386 | + bootstrapFd: Option.none(), | |
| 387 | + autoBootstrapProjectFromCwd: Option.none(), | |
| 388 | + logWebSocketEvents: Option.none(), | |
| 389 | + }, | |
| 390 | + cliLogLevel, | |
| 391 | + ); | |
| 392 | + | |
| 393 | +const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i; | |
| 394 | + | |
| 395 | +const parseDurationInput = (value: string): Duration.Duration | null => { | |
| 396 | + const trimmed = value.trim(); | |
| 397 | + if (trimmed.length === 0) return null; | |
| 398 | + | |
| 399 | + const shorthand = DurationShorthandPattern.exec(trimmed); | |
| 400 | + const normalizedInput = shorthand?.groups | |
| 401 | + ? (() => { | |
| 402 | + const amountText = shorthand.groups.value; | |
| 403 | + const unitText = shorthand.groups.unit; | |
| 404 | + if (typeof amountText !== "string" || typeof unitText !== "string") { | |
| 405 | + return null; | |
| 406 | + } | |
| 407 | + | |
| 408 | + const amount = Number.parseInt(amountText, 10); | |
| 409 | + if (!Number.isFinite(amount)) return null; | |
| 410 | + | |
| 411 | + switch (unitText.toLowerCase()) { | |
| 412 | + case "ms": | |
| 413 | + return `${amount} millis`; | |
| 414 | + case "s": | |
| 415 | + return `${amount} seconds`; | |
| 416 | + case "m": | |
| 417 | + return `${amount} minutes`; | |
| 418 | + case "h": | |
| 419 | + return `${amount} hours`; | |
| 420 | + case "d": | |
| 421 | + return `${amount} days`; | |
| 422 | + case "w": | |
| 423 | + return `${amount} weeks`; | |
| 424 | + default: | |
| 425 | + return null; | |
| 426 | + } | |
| 427 | + })() | |
| 428 | + : (trimmed as Duration.Input); | |
| 429 | + | |
| 430 | + if (normalizedInput === null) return null; | |
| 431 | + | |
| 432 | + const decoded = Duration.fromInput(normalizedInput as Duration.Input); | |
| 433 | + return Option.isSome(decoded) ? decoded.value : null; | |
| 434 | +}; | |
| 435 | + | |
| 436 | +const DurationFromString = Schema.String.pipe( | |
| 437 | + Schema.decodeTo( | |
| 438 | + Schema.Duration, | |
| 439 | + SchemaTransformation.transformOrFail({ | |
| 440 | + decode: (value) => { | |
| 441 | + const duration = parseDurationInput(value); | |
| 442 | + if (duration !== null) { | |
| 443 | + return Effect.succeed(duration); | |
| 444 | + } | |
| 445 | + return Effect.fail( | |
| 446 | + new SchemaIssue.InvalidValue(Option.some(value), { | |
| 447 | + message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", | |
| 448 | + }), | |
| 449 | + ); | |
| 450 | + }, | |
| 451 | + encode: (duration) => Effect.succeed(Duration.format(duration)), | |
| 452 | + }), | |
| 453 | + ), | |
| 454 | +); | |
| 455 | + | |
| 456 | +const runWithAuthControlPlane = <A, E>( | |
| 457 | + flags: CliAuthLocationFlags, | |
| 458 | + run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>, | |
| 459 | + options?: { | |
| 460 | + readonly quietLogs?: boolean; | |
| 461 | + }, | |
| 462 | +) => | |
| 463 | + Effect.gen(function* () { | |
| 464 | + const logLevel = yield* GlobalFlag.LogLevel; | |
| 465 | + const config = yield* resolveCliAuthConfig(flags, logLevel); | |
| 466 | + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | |
| 467 | + return yield* Effect.gen(function* () { | |
| 468 | + const authControlPlane = yield* AuthControlPlane; | |
| 469 | + return yield* run(authControlPlane); | |
| 470 | + }).pipe( | |
| 471 | + Effect.provide( | |
| 472 | + Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( | |
| 473 | + Layer.provide(Layer.succeed(ServerConfig, config)), | |
| 474 | + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | |
| 475 | + ), | |
| 476 | + ), | |
| 477 | + ); | |
| 478 | + }); | |
| 479 | + | |
| 480 | +type ProjectMutationTarget = { | |
| 481 | + readonly id: ProjectId; | |
| 482 | + readonly title: string; | |
| 483 | + readonly workspaceRoot: string; | |
| 484 | +}; | |
| 485 | + | |
| 486 | +type ProjectCommandExecutionMode = "live" | "offline"; | |
| 487 | +type ProjectCliDispatchCommand = Extract< | |
| 488 | + ClientOrchestrationCommand, | |
| 489 | + { type: "project.create" | "project.meta.update" | "project.delete" } | |
| 490 | +>; | |
| 491 | + | |
| 492 | +const ProjectCliRuntimeLive = Layer.mergeAll( | |
| 493 | + WorkspacePathsLive, | |
| 494 | + OrchestrationLayerLive.pipe( | |
| 495 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 496 | + Layer.provideMerge(SqlitePersistenceLayerLive), | |
| 497 | + ), | |
| 498 | +); | |
| 499 | + | |
| 500 | +const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); | |
| 501 | +const OrchestrationHttpErrorResponse = Schema.Struct({ | |
| 502 | + error: Schema.String, | |
| 503 | +}); | |
| 504 | + | |
| 505 | +const withProjectCliSessionToken = <A, E, R>( | |
| 506 | + authControlPlane: AuthControlPlaneShape, | |
| 507 | + run: (token: string) => Effect.Effect<A, E, R>, | |
| 508 | +) => | |
| 509 | + Effect.acquireUseRelease( | |
| 510 | + authControlPlane.issueSession({ | |
| 511 | + role: "owner", | |
| 512 | + label: "t3 project cli", | |
| 513 | + }), | |
| 514 | + (issued) => run(issued.token), | |
| 515 | + (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), | |
| 516 | + ); | |
| 517 | + | |
| 518 | +const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 519 | + effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); | |
| 520 | + | |
| 521 | +const runLiveServerRequest = <A, E extends Error, R>( | |
| 522 | + request: HttpClientRequest.HttpClientRequest, | |
| 523 | + handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>, | |
| 524 | +) => | |
| 525 | + Effect.gen(function* () { | |
| 526 | + const httpClient = yield* HttpClient.HttpClient; | |
| 527 | + const response = yield* httpClient.execute(request); | |
| 528 | + return yield* handle(response); | |
| 529 | + }).pipe(withProjectCliLiveServerTimeout); | |
| 530 | + | |
| 531 | +const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => | |
| 532 | + HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); | |
| 533 | + | |
| 534 | +const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => | |
| 535 | + HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( | |
| 536 | + Effect.map((body) => body.error), | |
| 537 | + Effect.catch(() => Effect.succeed(null)), | |
| 538 | + Effect.map((body) => { | |
| 539 | + if (typeof body === "string" && body.trim().length > 0) { | |
| 540 | + return body; | |
| 541 | + } | |
| 542 | + return `Server request failed with status ${response.status}.`; | |
| 543 | + }), | |
| 544 | + ); | |
| 545 | + | |
| 546 | +const normalizeWorkspaceRootForProjectCommand = Effect.fn( | |
| 547 | + "normalizeWorkspaceRootForProjectCommand", | |
| 548 | +)(function* (workspaceRoot: string) { | |
| 549 | + const workspacePaths = yield* WorkspacePaths; | |
| 550 | + return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); | |
| 551 | +}); | |
| 552 | + | |
| 553 | +const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( | |
| 554 | + workspaceRoot: string, | |
| 555 | + explicitTitle?: string, | |
| 556 | +) { | |
| 557 | + if (explicitTitle !== undefined) { | |
| 558 | + const trimmed = explicitTitle.trim(); | |
| 559 | + if (trimmed.length > 0) { | |
| 560 | + return trimmed; | |
| 561 | + } | |
| 562 | + return yield* Effect.fail(new Error("Project title cannot be empty.")); | |
| 563 | + } | |
| 564 | + | |
| 565 | + const path = yield* Path.Path; | |
| 566 | + const basename = path.basename(workspaceRoot).trim(); | |
| 567 | + return basename.length > 0 ? basename : "project"; | |
| 568 | +}); | |
| 569 | + | |
| 570 | +const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { | |
| 571 | + readonly snapshot: OrchestrationReadModel; | |
| 572 | + readonly identifier: string; | |
| 573 | +}) { | |
| 574 | + const trimmedIdentifier = input.identifier.trim(); | |
| 575 | + if (trimmedIdentifier.length === 0) { | |
| 576 | + return yield* Effect.fail(new Error("Project identifier cannot be empty.")); | |
| 577 | + } | |
| 578 | + | |
| 579 | + const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); | |
| 580 | + const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); | |
| 581 | + if (exactIdMatch) { | |
| 582 | + return { | |
| 583 | + id: exactIdMatch.id, | |
| 584 | + title: exactIdMatch.title, | |
| 585 | + workspaceRoot: exactIdMatch.workspaceRoot, | |
| 586 | + } satisfies ProjectMutationTarget; | |
| 587 | + } | |
| 588 | + | |
| 589 | + const normalizedWorkspaceRootResult = yield* Effect.exit( | |
| 590 | + normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), | |
| 591 | + ); | |
| 592 | + const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) | |
| 593 | + ? normalizedWorkspaceRootResult.value | |
| 594 | + : null; | |
| 595 | + | |
| 596 | + const exactWorkspaceMatch = | |
| 597 | + normalizedWorkspaceRoot === null | |
| 598 | + ? undefined | |
| 599 | + : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); | |
| 600 | + | |
| 601 | + const resolved = exactWorkspaceMatch; | |
| 602 | + if (!resolved) { | |
| 603 | + return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); | |
| 604 | + } | |
| 605 | + | |
| 606 | + return { | |
| 607 | + id: resolved.id, | |
| 608 | + title: resolved.title, | |
| 609 | + workspaceRoot: resolved.workspaceRoot, | |
| 610 | + } satisfies ProjectMutationTarget; | |
| 611 | +}); | |
| 612 | + | |
| 613 | +const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => | |
| 614 | + runLiveServerRequest( | |
| 615 | + HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( | |
| 616 | + HttpClientRequest.acceptJson, | |
| 617 | + HttpClientRequest.bearerToken(bearerToken), | |
| 618 | + ), | |
| 619 | + HttpClientResponse.matchStatus({ | |
| 620 | + "2xx": decodeOrchestrationReadModelResponse, | |
| 621 | + orElse: (response) => | |
| 622 | + readErrorMessageFromResponse(response).pipe( | |
| 623 | + Effect.flatMap((message) => Effect.fail(new Error(message))), | |
| 624 | + ), | |
| 625 | + }), | |
| 626 | + ); | |
| 627 | + | |
| 628 | +const dispatchLiveOrchestrationCommand = ( | |
| 629 | + origin: string, | |
| 630 | + bearerToken: string, | |
| 631 | + command: ProjectCliDispatchCommand, | |
| 632 | +) => | |
| 633 | + HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( | |
| 634 | + HttpClientRequest.acceptJson, | |
| 635 | + HttpClientRequest.bearerToken(bearerToken), | |
| 636 | + HttpClientRequest.bodyJson(command), | |
| 637 | + Effect.flatMap((request) => | |
| 638 | + runLiveServerRequest( | |
| 639 | + request, | |
| 640 | + HttpClientResponse.matchStatus({ | |
| 641 | + "2xx": () => Effect.void, | |
| 642 | + orElse: (response) => | |
| 643 | + readErrorMessageFromResponse(response).pipe( | |
| 644 | + Effect.flatMap((message) => Effect.fail(new Error(message))), | |
| 645 | + ), | |
| 646 | + }), | |
| 647 | + ), | |
| 648 | + ), | |
| 649 | + ); | |
| 650 | + | |
| 651 | +const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { | |
| 652 | + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | |
| 653 | + return yield* projectionSnapshotQuery.getSnapshot(); | |
| 654 | +}); | |
| 655 | + | |
| 656 | +const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( | |
| 657 | + function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { | |
| 658 | + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | |
| 659 | + if (Option.isNone(runtimeState)) { | |
| 660 | + return Option.none<{ readonly origin: string }>(); | |
| 661 | + } | |
| 662 | + | |
| 663 | + const attempt = withProjectCliSessionToken(authControlPlane, (token) => | |
| 664 | + fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( | |
| 665 | + Effect.as({ | |
| 666 | + origin: runtimeState.value.origin, | |
| 667 | + }), | |
| 668 | + ), | |
| 669 | + ); | |
| 670 | + | |
| 671 | + const attempted = yield* Effect.exit(attempt); | |
| 672 | + if (Exit.isSuccess(attempted)) { | |
| 673 | + return Option.some(attempted.value); | |
| 674 | + } | |
| 675 | + | |
| 676 | + yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); | |
| 677 | + return Option.none<{ readonly origin: string }>(); | |
| 678 | + }, | |
| 679 | +); | |
| 680 | + | |
| 681 | +const runProjectMutation = Effect.fn("runProjectMutation")(function* ( | |
| 682 | + flags: CliAuthLocationFlags, | |
| 683 | + run: (input: { | |
| 684 | + readonly snapshot: OrchestrationReadModel; | |
| 685 | + readonly dispatch: ( | |
| 686 | + command: ProjectCliDispatchCommand, | |
| 687 | + ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | |
| 688 | + readonly mode: ProjectCommandExecutionMode; | |
| 689 | + }) => Effect.Effect< | |
| 690 | + string, | |
| 691 | + Error, | |
| 692 | + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths | |
| 693 | + >, | |
| 694 | +) { | |
| 695 | + const logLevel = yield* GlobalFlag.LogLevel; | |
| 696 | + const config = yield* resolveCliAuthConfig(flags, logLevel); | |
| 697 | + const minimumLogLevel = config.logLevel; | |
| 698 | + | |
| 699 | + return yield* Effect.gen(function* () { | |
| 700 | + const authControlPlane = yield* AuthControlPlane; | |
| 701 | + const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); | |
| 702 | + | |
| 703 | + if (Option.isSome(liveMode)) { | |
| 704 | + return yield* withProjectCliSessionToken(authControlPlane, (token) => | |
| 705 | + Effect.gen(function* () { | |
| 706 | + const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); | |
| 707 | + const output = yield* run({ | |
| 708 | + snapshot, | |
| 709 | + dispatch: (command) => | |
| 710 | + dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), | |
| 711 | + mode: "live", | |
| 712 | + }); | |
| 713 | + yield* Console.log(output); | |
| 714 | + }), | |
| 715 | + ); | |
| 716 | + } | |
| 717 | + | |
| 718 | + const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( | |
| 719 | + Layer.provide(Layer.succeed(ServerConfig, config)), | |
| 720 | + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | |
| 721 | + ); | |
| 722 | + | |
| 723 | + return yield* Effect.gen(function* () { | |
| 724 | + const snapshot = yield* getOfflineSnapshot(); | |
| 725 | + const orchestrationEngine = yield* OrchestrationEngineService; | |
| 726 | + const output = yield* run({ | |
| 727 | + snapshot, | |
| 728 | + dispatch: (command) => orchestrationEngine.dispatch(command), | |
| 729 | + mode: "offline", | |
| 730 | + }); | |
| 731 | + yield* Console.log(output); | |
| 732 | + }).pipe(Effect.provide(offlineRuntimeLayer)); | |
| 733 | + }).pipe( | |
| 734 | + Effect.provide( | |
| 735 | + Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( | |
| 736 | + Layer.provideMerge(FetchHttpClient.layer), | |
| 737 | + Layer.provide(Layer.succeed(ServerConfig, config)), | |
| 738 | + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | |
| 739 | + ), | |
| 740 | + ), | |
| 741 | + ); | |
| 742 | +}); | |
| 743 | + | |
| 744 | +const sharedServerLocationFlags = { | |
| 745 | + baseDir: baseDirFlag, | |
| 746 | + devUrl: devUrlFlag, | |
| 747 | +} as const; | |
| 748 | + | |
| 749 | +const projectLocationFlags = { | |
| 750 | + baseDir: baseDirFlag, | |
| 751 | +} as const; | |
| 752 | + | |
| 753 | +const sharedServerCommandFlags = { | |
| 339 | 754 | mode: modeFlag, |
| 340 | 755 | port: portFlag, |
| 341 | 756 | host: hostFlag, |
| @@ -348,21 +763,371 @@ const commandFlags = { · 6 unmodified lines | ||
| 348 | 763 | ), |
| 349 | 764 | devUrl: devUrlFlag, |
| 350 | 765 | noBrowser: noBrowserFlag, |
| 351 | − authToken: authTokenFlag, | |
| 352 | 766 | bootstrapFd: bootstrapFdFlag, |
| 353 | 767 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, |
| 354 | 768 | logWebSocketEvents: logWebSocketEventsFlag, |
| 355 | 769 | } as const; |
| 356 | 770 | |
| 357 | −const rootCommand = Command.make("t3", commandFlags).pipe( | |
| 771 | +const authLocationFlags = sharedServerLocationFlags; | |
| 772 | + | |
| 773 | +const ttlFlag = Flag.string("ttl").pipe( | |
| 774 | + Flag.withSchema(DurationFromString), | |
| 775 | + Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), | |
| 776 | + Flag.optional, | |
| 777 | +); | |
| 778 | + | |
| 779 | +const jsonFlag = Flag.boolean("json").pipe( | |
| 780 | + Flag.withDescription("Emit JSON instead of human-readable output."), | |
| 781 | + Flag.withDefault(false), | |
| 782 | +); | |
| 783 | + | |
| 784 | +const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( | |
| 785 | + Flag.withDescription("Role for the issued bearer session."), | |
| 786 | + Flag.withDefault("owner"), | |
| 787 | +); | |
| 788 | + | |
| 789 | +const labelFlag = Flag.string("label").pipe( | |
| 790 | + Flag.withDescription("Optional human-readable label."), | |
| 791 | + Flag.optional, | |
| 792 | +); | |
| 793 | + | |
| 794 | +const subjectFlag = Flag.string("subject").pipe( | |
| 795 | + Flag.withDescription("Optional session subject."), | |
| 796 | + Flag.optional, | |
| 797 | +); | |
| 798 | + | |
| 799 | +const baseUrlFlag = Flag.string("base-url").pipe( | |
| 800 | + Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), | |
| 801 | + Flag.optional, | |
| 802 | +); | |
| 803 | + | |
| 804 | +const tokenOnlyFlag = Flag.boolean("token-only").pipe( | |
| 805 | + Flag.withDescription("Print only the issued bearer token."), | |
| 806 | + Flag.withDefault(false), | |
| 807 | +); | |
| 808 | + | |
| 809 | +const pairingCreateCommand = Command.make("create", { | |
| 810 | + ...authLocationFlags, | |
| 811 | + ttl: ttlFlag, | |
| 812 | + label: labelFlag, | |
| 813 | + baseUrl: baseUrlFlag, | |
| 814 | + json: jsonFlag, | |
| 815 | +}).pipe( | |
| 816 | + Command.withDescription("Issue a new client pairing token."), | |
| 817 | + Command.withHandler((flags) => | |
| 818 | + runWithAuthControlPlane( | |
| 819 | + flags, | |
| 820 | + (authControlPlane) => | |
| 821 | + Effect.gen(function* () { | |
| 822 | + const issued = yield* authControlPlane.createPairingLink({ | |
| 823 | + role: "client", | |
| 824 | + subject: "one-time-token", | |
| 825 | + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | |
| 826 | + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | |
| 827 | + }); | |
| 828 | + const output = formatIssuedPairingCredential(issued, { | |
| 829 | + json: flags.json, | |
| 830 | + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), | |
| 831 | + }); | |
| 832 | + yield* Console.log(output); | |
| 833 | + }), | |
| 834 | + { | |
| 835 | + quietLogs: flags.json, | |
| 836 | + }, | |
| 837 | + ), | |
| 838 | + ), | |
| 839 | +); | |
| 840 | + | |
| 841 | +const pairingListCommand = Command.make("list", { | |
| 842 | + ...authLocationFlags, | |
| 843 | + json: jsonFlag, | |
| 844 | +}).pipe( | |
| 845 | + Command.withDescription("List active client pairing tokens without revealing their secrets."), | |
| 846 | + Command.withHandler((flags) => | |
| 847 | + runWithAuthControlPlane( | |
| 848 | + flags, | |
| 849 | + (authControlPlane) => | |
| 850 | + Effect.gen(function* () { | |
| 851 | + const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); | |
| 852 | + yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); | |
| 853 | + }), | |
| 854 | + { | |
| 855 | + quietLogs: flags.json, | |
| 856 | + }, | |
| 857 | + ), | |
| 858 | + ), | |
| 859 | +); | |
| 860 | + | |
| 861 | +const pairingRevokeCommand = Command.make("revoke", { | |
| 862 | + ...authLocationFlags, | |
| 863 | + id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), | |
| 864 | +}).pipe( | |
| 865 | + Command.withDescription("Revoke an active client pairing token."), | |
| 866 | + Command.withHandler((flags) => | |
| 867 | + runWithAuthControlPlane(flags, (authControlPlane) => | |
| 868 | + Effect.gen(function* () { | |
| 869 | + const revoked = yield* authControlPlane.revokePairingLink(flags.id); | |
| 870 | + yield* Console.log( | |
| 871 | + revoked | |
| 872 | + ? `Revoked pairing credential ${flags.id}.\n` | |
| 873 | + : `No active pairing credential found for ${flags.id}.\n`, | |
| 874 | + ); | |
| 875 | + }), | |
| 876 | + ), | |
| 877 | + ), | |
| 878 | +); | |
| 879 | + | |
| 880 | +const pairingCommand = Command.make("pairing").pipe( | |
| 881 | + Command.withDescription("Manage one-time client pairing tokens."), | |
| 882 | + Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), | |
| 883 | +); | |
| 884 | + | |
| 885 | +const sessionIssueCommand = Command.make("issue", { | |
| 886 | + ...authLocationFlags, | |
| 887 | + ttl: ttlFlag, | |
| 888 | + role: sessionRoleFlag, | |
| 889 | + label: labelFlag, | |
| 890 | + subject: subjectFlag, | |
| 891 | + tokenOnly: tokenOnlyFlag, | |
| 892 | + json: jsonFlag, | |
| 893 | +}).pipe( | |
| 894 | + Command.withDescription("Issue a bearer session token for headless or remote clients."), | |
| 895 | + Command.withHandler((flags) => | |
| 896 | + runWithAuthControlPlane( | |
| 897 | + flags, | |
| 898 | + (authControlPlane) => | |
| 899 | + Effect.gen(function* () { | |
| 900 | + const issued = yield* authControlPlane.issueSession({ | |
| 901 | + role: flags.role, | |
| 902 | + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | |
| 903 | + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | |
| 904 | + ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), | |
| 905 | + }); | |
| 906 | + yield* Console.log( | |
| 907 | + formatIssuedSession(issued, { | |
| 908 | + json: flags.json, | |
| 909 | + tokenOnly: flags.tokenOnly, | |
| 910 | + }), | |
| 911 | + ); | |
| 912 | + }), | |
| 913 | + { | |
| 914 | + quietLogs: flags.json || flags.tokenOnly, | |
| 915 | + }, | |
| 916 | + ), | |
| 917 | + ), | |
| 918 | +); | |
| 919 | + | |
| 920 | +const sessionListCommand = Command.make("list", { | |
| 921 | + ...authLocationFlags, | |
| 922 | + json: jsonFlag, | |
| 923 | +}).pipe( | |
| 924 | + Command.withDescription("List active sessions without revealing bearer tokens."), | |
| 925 | + Command.withHandler((flags) => | |
| 926 | + runWithAuthControlPlane( | |
| 927 | + flags, | |
| 928 | + (authControlPlane) => | |
| 929 | + Effect.gen(function* () { | |
| 930 | + const sessions = yield* authControlPlane.listSessions(); | |
| 931 | + yield* Console.log(formatSessionList(sessions, { json: flags.json })); | |
| 932 | + }), | |
| 933 | + { | |
| 934 | + quietLogs: flags.json, | |
| 935 | + }, | |
| 936 | + ), | |
| 937 | + ), | |
| 938 | +); | |
| 939 | + | |
| 940 | +const sessionRevokeCommand = Command.make("revoke", { | |
| 941 | + ...authLocationFlags, | |
| 942 | + sessionId: Argument.string("session-id").pipe( | |
| 943 | + Argument.withDescription("Session id to revoke."), | |
| 944 | + Argument.withSchema(AuthSessionId), | |
| 945 | + ), | |
| 946 | +}).pipe( | |
| 947 | + Command.withDescription("Revoke an active session."), | |
| 948 | + Command.withHandler((flags) => | |
| 949 | + runWithAuthControlPlane(flags, (authControlPlane) => | |
| 950 | + Effect.gen(function* () { | |
| 951 | + const revoked = yield* authControlPlane.revokeSession(flags.sessionId); | |
| 952 | + yield* Console.log( | |
| 953 | + revoked | |
| 954 | + ? `Revoked session ${flags.sessionId}.\n` | |
| 955 | + : `No active session found for ${flags.sessionId}.\n`, | |
| 956 | + ); | |
| 957 | + }), | |
| 958 | + ), | |
| 959 | + ), | |
| 960 | +); | |
| 961 | + | |
| 962 | +const sessionCommand = Command.make("session").pipe( | |
| 963 | + Command.withDescription("Manage bearer sessions."), | |
| 964 | + Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), | |
| 965 | +); | |
| 966 | + | |
| 967 | +const authCommand = Command.make("auth").pipe( | |
| 968 | + Command.withDescription("Manage the local auth control plane for headless deployments."), | |
| 969 | + Command.withSubcommands([pairingCommand, sessionCommand]), | |
| 970 | +); | |
| 971 | + | |
| 972 | +const projectAddCommand = Command.make("add", { | |
| 973 | + ...projectLocationFlags, | |
| 974 | + workspaceRoot: Argument.string("path").pipe( | |
| 975 | + Argument.withDescription("Workspace root to add as a project."), | |
| 976 | + ), | |
| 977 | + title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), | |
| 978 | +}).pipe( | |
| 979 | + Command.withDescription("Add a project."), | |
| 980 | + Command.withHandler((flags) => | |
| 981 | + runProjectMutation( | |
| 982 | + flags, | |
| 983 | + Effect.fn("projectAddMutation")(function* ({ | |
| 984 | + snapshot, | |
| 985 | + dispatch, | |
| 986 | + }: { | |
| 987 | + readonly snapshot: OrchestrationReadModel; | |
| 988 | + readonly dispatch: ( | |
| 989 | + command: ProjectCliDispatchCommand, | |
| 990 | + ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | |
| 991 | + }) { | |
| 992 | + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); | |
| 993 | + const existingProject = snapshot.projects.find( | |
| 994 | + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, | |
| 995 | + ); | |
| 996 | + if (existingProject) { | |
| 997 | + return yield* Effect.fail( | |
| 998 | + new Error(`An active project already exists for '${workspaceRoot}'.`), | |
| 999 | + ); | |
| 1000 | + } | |
| 1001 | + | |
| 1002 | + const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); | |
| 1003 | + const projectId = ProjectId.make(crypto.randomUUID()); | |
| 1004 | + yield* dispatch({ | |
| 1005 | + type: "project.create", | |
| 1006 | + commandId: CommandId.make(crypto.randomUUID()), | |
| 1007 | + projectId, | |
| 1008 | + title, | |
| 1009 | + workspaceRoot, | |
| 1010 | + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), | |
| 1011 | + createdAt: new Date().toISOString(), | |
| 1012 | + }); | |
| 1013 | + return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; | |
| 1014 | + }), | |
| 1015 | + ), | |
| 1016 | + ), | |
| 1017 | +); | |
| 1018 | + | |
| 1019 | +const projectRemoveCommand = Command.make("remove", { | |
| 1020 | + ...projectLocationFlags, | |
| 1021 | + project: Argument.string("project").pipe( | |
| 1022 | + Argument.withDescription("Project id or workspace root to remove."), | |
| 1023 | + ), | |
| 1024 | +}).pipe( | |
| 1025 | + Command.withDescription("Remove a project."), | |
| 1026 | + Command.withHandler((flags) => | |
| 1027 | + runProjectMutation( | |
| 1028 | + flags, | |
| 1029 | + Effect.fn("projectRemoveMutation")(function* ({ | |
| 1030 | + snapshot, | |
| 1031 | + dispatch, | |
| 1032 | + }: { | |
| 1033 | + readonly snapshot: OrchestrationReadModel; | |
| 1034 | + readonly dispatch: ( | |
| 1035 | + command: ProjectCliDispatchCommand, | |
| 1036 | + ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | |
| 1037 | + }) { | |
| 1038 | + const project = yield* findActiveProjectTarget({ | |
| 1039 | + snapshot, | |
| 1040 | + identifier: flags.project, | |
| 1041 | + }); | |
| 1042 | + yield* dispatch({ | |
| 1043 | + type: "project.delete", | |
| 1044 | + commandId: CommandId.make(crypto.randomUUID()), | |
| 1045 | + projectId: project.id, | |
| 1046 | + }); | |
| 1047 | + return `Removed project ${project.id} (${project.title}).`; | |
| 1048 | + }), | |
| 1049 | + ), | |
| 1050 | + ), | |
| 1051 | +); | |
| 1052 | + | |
| 1053 | +const projectRenameCommand = Command.make("rename", { | |
| 1054 | + ...projectLocationFlags, | |
| 1055 | + project: Argument.string("project").pipe( | |
| 1056 | + Argument.withDescription("Project id or workspace root to rename."), | |
| 1057 | + ), | |
| 1058 | + title: Argument.string("title").pipe(Argument.withDescription("New project title.")), | |
| 1059 | +}).pipe( | |
| 1060 | + Command.withDescription("Rename a project."), | |
| 1061 | + Command.withHandler((flags) => | |
| 1062 | + runProjectMutation( | |
| 1063 | + flags, | |
| 1064 | + Effect.fn("projectRenameMutation")(function* ({ | |
| 1065 | + snapshot, | |
| 1066 | + dispatch, | |
| 1067 | + }: { | |
| 1068 | + readonly snapshot: OrchestrationReadModel; | |
| 1069 | + readonly dispatch: ( | |
| 1070 | + command: ProjectCliDispatchCommand, | |
| 1071 | + ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | |
| 1072 | + }) { | |
| 1073 | + const project = yield* findActiveProjectTarget({ | |
| 1074 | + snapshot, | |
| 1075 | + identifier: flags.project, | |
| 1076 | + }); | |
| 1077 | + const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); | |
| 1078 | + if (nextTitle === project.title) { | |
| 1079 | + return `Project ${project.id} is already named ${nextTitle}.`; | |
| 1080 | + } | |
| 1081 | + | |
| 1082 | + yield* dispatch({ | |
| 1083 | + type: "project.meta.update", | |
| 1084 | + commandId: CommandId.make(crypto.randomUUID()), | |
| 1085 | + projectId: project.id, | |
| 1086 | + title: nextTitle, | |
| 1087 | + }); | |
| 1088 | + return `Renamed project ${project.id} to ${nextTitle}.`; | |
| 1089 | + }), | |
| 1090 | + ), | |
| 1091 | + ), | |
| 1092 | +); | |
| 1093 | + | |
| 1094 | +const projectCommand = Command.make("project").pipe( | |
| 1095 | + Command.withDescription("Manage projects."), | |
| 1096 | + Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), | |
| 1097 | +); | |
| 1098 | + | |
| 1099 | +const runServerCommand = ( | |
| 1100 | + flags: CliServerFlags, | |
| 1101 | + options?: { | |
| 1102 | + readonly startupPresentation?: StartupPresentation; | |
| 1103 | + readonly forceAutoBootstrapProjectFromCwd?: boolean; | |
| 1104 | + }, | |
| 1105 | +) => | |
| 1106 | + Effect.gen(function* () { | |
| 1107 | + const logLevel = yield* GlobalFlag.LogLevel; | |
| 1108 | + const config = yield* resolveServerConfig(flags, logLevel, options); | |
| 1109 | + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | |
| 1110 | + }); | |
| 1111 | + | |
| 1112 | +const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( | |
| 358 | 1113 | Command.withDescription("Run the T3 Code server."), |
| 1114 | + Command.withHandler((flags) => runServerCommand(flags)), | |
| 1115 | +); | |
| 1116 | + | |
| 1117 | +const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( | |
| 1118 | + Command.withDescription( | |
| 1119 | + "Run the T3 Code server without opening a browser and print headless pairing details.", | |
| 1120 | + ), | |
| 359 | 1121 | Command.withHandler((flags) => |
| 360 | − Effect.gen(function* () { | |
| 361 | − const logLevel = yield* GlobalFlag.LogLevel; | |
| 362 | − const config = yield* resolveServerConfig(flags, logLevel); | |
| 363 | − return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | |
| 1122 | + runServerCommand(flags, { | |
| 1123 | + startupPresentation: "headless", | |
| 1124 | + forceAutoBootstrapProjectFromCwd: false, | |
| 364 | 1125 | }), |
| 365 | 1126 | ), |
| 366 | 1127 | ); |
| 367 | 1128 | |
| 368 | −export const cli = rootCommand; | |
| 1129 | +export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( | |
| 1130 | + Command.withDescription("Run the T3 Code server."), | |
| 1131 | + Command.withHandler((flags) => runServerCommand(flags)), | |
| 1132 | + Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), | |
| 1133 | +); | |
| @@ -0,0 +1,88 @@ | ||
| 1 | +import { expect, it } from "@effect/vitest"; | |
| 2 | +import { DateTime } from "effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + formatIssuedPairingCredential, | |
| 6 | + formatIssuedSession, | |
| 7 | + formatPairingCredentialList, | |
| 8 | + formatSessionList, | |
| 9 | +} from "./cliAuthFormat.ts"; | |
| 10 | + | |
| 11 | +it("formats issued pairing credentials with the secret and optional pair URL", () => { | |
| 12 | + const output = formatIssuedPairingCredential( | |
| 13 | + { | |
| 14 | + id: "pairing-1", | |
| 15 | + credential: "secret-pairing-token", | |
| 16 | + role: "client", | |
| 17 | + subject: "one-time-token", | |
| 18 | + createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), | |
| 19 | + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 20 | + }, | |
| 21 | + { baseUrl: "https://example.com", json: false }, | |
| 22 | + ); | |
| 23 | + | |
| 24 | + expect(output).toContain("secret-pairing-token"); | |
| 25 | + expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); | |
| 26 | +}); | |
| 27 | + | |
| 28 | +it("formats pairing listings without exposing the secret token", () => { | |
| 29 | + const output = formatPairingCredentialList( | |
| 30 | + [ | |
| 31 | + { | |
| 32 | + id: "pairing-1", | |
| 33 | + credential: "secret-pairing-token", | |
| 34 | + subject: "one-time-token", | |
| 35 | + label: "Phone", | |
| 36 | + role: "client", | |
| 37 | + createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), | |
| 38 | + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 39 | + }, | |
| 40 | + ], | |
| 41 | + { json: false }, | |
| 42 | + ); | |
| 43 | + | |
| 44 | + expect(output).toContain("pairing-1"); | |
| 45 | + expect(output).not.toContain("secret-pairing-token"); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +it("formats issued sessions with the bearer token but omits tokens from listings", () => { | |
| 49 | + const issuedOutput = formatIssuedSession( | |
| 50 | + { | |
| 51 | + sessionId: "session-1" as never, | |
| 52 | + token: "secret-session-token", | |
| 53 | + method: "bearer-session-token", | |
| 54 | + role: "owner", | |
| 55 | + subject: "cli-issued-session", | |
| 56 | + client: { | |
| 57 | + label: "deploy-bot", | |
| 58 | + deviceType: "bot", | |
| 59 | + }, | |
| 60 | + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 61 | + }, | |
| 62 | + { json: false }, | |
| 63 | + ); | |
| 64 | + | |
| 65 | + const listedOutput = formatSessionList( | |
| 66 | + [ | |
| 67 | + { | |
| 68 | + sessionId: "session-1" as never, | |
| 69 | + method: "bearer-session-token", | |
| 70 | + role: "owner", | |
| 71 | + subject: "cli-issued-session", | |
| 72 | + client: { | |
| 73 | + label: "deploy-bot", | |
| 74 | + deviceType: "bot", | |
| 75 | + }, | |
| 76 | + connected: false, | |
| 77 | + current: false, | |
| 78 | + issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), | |
| 79 | + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), | |
| 80 | + lastConnectedAt: null, | |
| 81 | + }, | |
| 82 | + ], | |
| 83 | + { json: false }, | |
| 84 | + ); | |
| 85 | + | |
| 86 | + expect(issuedOutput).toContain("secret-session-token"); | |
| 87 | + expect(listedOutput).not.toContain("secret-session-token"); | |
| 88 | +}); | |
| @@ -0,0 +1,190 @@ | ||
| 1 | +import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; | |
| 2 | +import { DateTime } from "effect"; | |
| 3 | + | |
| 4 | +import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; | |
| 5 | + | |
| 6 | +const newline = "\n"; | |
| 7 | + | |
| 8 | +function serializeOptionalFields(values: ReadonlyArray<string | null | undefined>) { | |
| 9 | + return values.filter((value): value is string => typeof value === "string" && value.length > 0); | |
| 10 | +} | |
| 11 | + | |
| 12 | +function formatClientMetadata(metadata: AuthClientMetadata): string { | |
| 13 | + const details = serializeOptionalFields([ | |
| 14 | + metadata.label, | |
| 15 | + metadata.deviceType !== "unknown" ? metadata.deviceType : undefined, | |
| 16 | + metadata.os, | |
| 17 | + metadata.browser, | |
| 18 | + metadata.ipAddress, | |
| 19 | + ]); | |
| 20 | + return details.length > 0 ? details.join(" | ") : "unlabeled client"; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function toIsoString(value: DateTime.DateTime | DateTime.Utc): string { | |
| 24 | + return DateTime.formatIso(DateTime.toUtc(value)); | |
| 25 | +} | |
| 26 | + | |
| 27 | +export function formatIssuedPairingCredential( | |
| 28 | + credential: IssuedPairingLink, | |
| 29 | + options?: { | |
| 30 | + readonly json?: boolean; | |
| 31 | + readonly baseUrl?: string; | |
| 32 | + }, | |
| 33 | +): string { | |
| 34 | + const pairUrl = | |
| 35 | + options?.baseUrl != null && options.baseUrl.length > 0 | |
| 36 | + ? (() => { | |
| 37 | + const url = new URL("/pair", options.baseUrl); | |
| 38 | + url.searchParams.delete("token"); | |
| 39 | + url.hash = new URLSearchParams([["token", credential.credential]]).toString(); | |
| 40 | + return url.toString(); | |
| 41 | + })() | |
| 42 | + : undefined; | |
| 43 | + | |
| 44 | + if (options?.json) { | |
| 45 | + return `${JSON.stringify( | |
| 46 | + { | |
| 47 | + id: credential.id, | |
| 48 | + credential: credential.credential, | |
| 49 | + ...(credential.label ? { label: credential.label } : {}), | |
| 50 | + role: credential.role, | |
| 51 | + expiresAt: toIsoString(credential.expiresAt), | |
| 52 | + ...(pairUrl ? { pairUrl } : {}), | |
| 53 | + }, | |
| 54 | + null, | |
| 55 | + 2, | |
| 56 | + )}${newline}`; | |
| 57 | + } | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + [ | |
| 61 | + `Issued client pairing token ${credential.id}.`, | |
| 62 | + `Token: ${credential.credential}`, | |
| 63 | + ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []), | |
| 64 | + `Expires at: ${credential.expiresAt}`, | |
| 65 | + ].join(newline) + newline | |
| 66 | + ); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function formatPairingCredentialList( | |
| 70 | + credentials: ReadonlyArray<AuthPairingLink>, | |
| 71 | + options?: { | |
| 72 | + readonly json?: boolean; | |
| 73 | + }, | |
| 74 | +): string { | |
| 75 | + if (options?.json) { | |
| 76 | + return `${JSON.stringify( | |
| 77 | + credentials.map((credential) => ({ | |
| 78 | + id: credential.id, | |
| 79 | + ...(credential.label ? { label: credential.label } : {}), | |
| 80 | + role: credential.role, | |
| 81 | + createdAt: toIsoString(credential.createdAt), | |
| 82 | + expiresAt: toIsoString(credential.expiresAt), | |
| 83 | + })), | |
| 84 | + null, | |
| 85 | + 2, | |
| 86 | + )}${newline}`; | |
| 87 | + } | |
| 88 | + | |
| 89 | + if (credentials.length === 0) { | |
| 90 | + return `No active pairing credentials.${newline}`; | |
| 91 | + } | |
| 92 | + | |
| 93 | + return ( | |
| 94 | + credentials | |
| 95 | + .map((credential) => | |
| 96 | + [ | |
| 97 | + `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, | |
| 98 | + ` role: ${credential.role}`, | |
| 99 | + ` created: ${toIsoString(credential.createdAt)}`, | |
| 100 | + ` expires: ${toIsoString(credential.expiresAt)}`, | |
| 101 | + ].join(newline), | |
| 102 | + ) | |
| 103 | + .join(`${newline}${newline}`) + newline | |
| 104 | + ); | |
| 105 | +} | |
| 106 | + | |
| 107 | +export function formatIssuedSession( | |
| 108 | + session: IssuedBearerSession, | |
| 109 | + options?: { | |
| 110 | + readonly json?: boolean; | |
| 111 | + readonly tokenOnly?: boolean; | |
| 112 | + }, | |
| 113 | +): string { | |
| 114 | + if (options?.tokenOnly) { | |
| 115 | + return `${session.token}${newline}`; | |
| 116 | + } | |
| 117 | + | |
| 118 | + if (options?.json) { | |
| 119 | + return `${JSON.stringify( | |
| 120 | + { | |
| 121 | + sessionId: session.sessionId, | |
| 122 | + token: session.token, | |
| 123 | + method: session.method, | |
| 124 | + role: session.role, | |
| 125 | + subject: session.subject, | |
| 126 | + client: session.client, | |
| 127 | + expiresAt: toIsoString(session.expiresAt), | |
| 128 | + }, | |
| 129 | + null, | |
| 130 | + 2, | |
| 131 | + )}${newline}`; | |
| 132 | + } | |
| 133 | + | |
| 134 | + return ( | |
| 135 | + [ | |
| 136 | + `Issued ${session.role} bearer session ${session.sessionId}.`, | |
| 137 | + `Token: ${session.token}`, | |
| 138 | + `Subject: ${session.subject}`, | |
| 139 | + `Client: ${formatClientMetadata(session.client)}`, | |
| 140 | + `Expires at: ${toIsoString(session.expiresAt)}`, | |
| 141 | + ].join(newline) + newline | |
| 142 | + ); | |
| 143 | +} | |
| 144 | + | |
| 145 | +export function formatSessionList( | |
| 146 | + sessions: ReadonlyArray<AuthClientSession>, | |
| 147 | + options?: { | |
| 148 | + readonly json?: boolean; | |
| 149 | + }, | |
| 150 | +): string { | |
| 151 | + if (options?.json) { | |
| 152 | + return `${JSON.stringify( | |
| 153 | + sessions.map((session) => ({ | |
| 154 | + sessionId: session.sessionId, | |
| 155 | + method: session.method, | |
| 156 | + role: session.role, | |
| 157 | + subject: session.subject, | |
| 158 | + client: session.client, | |
| 159 | + connected: session.connected, | |
| 160 | + issuedAt: toIsoString(session.issuedAt), | |
| 161 | + expiresAt: toIsoString(session.expiresAt), | |
| 162 | + lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null, | |
| 163 | + })), | |
| 164 | + null, | |
| 165 | + 2, | |
| 166 | + )}${newline}`; | |
| 167 | + } | |
| 168 | + | |
| 169 | + if (sessions.length === 0) { | |
| 170 | + return `No active sessions.${newline}`; | |
| 171 | + } | |
| 172 | + | |
| 173 | + return ( | |
| 174 | + sessions | |
| 175 | + .map((session) => | |
| 176 | + [ | |
| 177 | + `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`, | |
| 178 | + ` method: ${session.method}`, | |
| 179 | + ` subject: ${session.subject}`, | |
| 180 | + ` client: ${formatClientMetadata(session.client)}`, | |
| 181 | + ` issued: ${toIsoString(session.issuedAt)}`, | |
| 182 | + ` last connected: ${ | |
| 183 | + session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never" | |
| 184 | + }`, | |
| 185 | + ` expires: ${toIsoString(session.expiresAt)}`, | |
| 186 | + ].join(newline), | |
| 187 | + ) | |
| 188 | + .join(`${newline}${newline}`) + newline | |
| 189 | + ); | |
| 190 | +} | |
| @@ -15,9 +15,9 @@ import { · 14 unmodified lines | ||
| 15 | 15 | normalizeCodexModelSlug, |
| 16 | 16 | readCodexAccountSnapshot, |
| 17 | 17 | resolveCodexModelForAccount, |
| 18 | −} from "./codexAppServerManager"; | |
| 18 | +} from "./codexAppServerManager.ts"; | |
| 19 | 19 | |
| 20 | −const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); | |
| 20 | +const asThreadId = (value: string): ThreadId => ThreadId.make(value); | |
| 21 | 21 | |
| 22 | 22 | function createSendTurnHarness() { |
| 23 | 23 | const manager = new CodexAppServerManager(); |
| @@ -111,9 +111,9 @@ function createPendingUserInputHarness() { · 87 unmodified lines | ||
| 111 | 111 | }, |
| 112 | 112 | pendingUserInputs: new Map([ |
| 113 | 113 | [ |
| 114 | − ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 114 | + ApprovalRequestId.make("req-user-input-1"), | |
| 115 | 115 | { |
| 116 | − requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 116 | + requestId: ApprovalRequestId.make("req-user-input-1"), | |
| 117 | 117 | jsonRpcId: 42, |
| 118 | 118 | threadId: asThreadId("thread_1"), |
| 119 | 119 | }, |
| @@ -470,6 +470,154 @@ describe("startSession", () => { · 350 unmodified lines | ||
| 470 | 470 | manager.stopAll(); |
| 471 | 471 | } |
| 472 | 472 | }); |
| 473 | + | |
| 474 | + it("disposes an existing session before starting a replacement for the same thread", async () => { | |
| 475 | + const manager = new CodexAppServerManager(); | |
| 476 | + const existingContext = { | |
| 477 | + session: { | |
| 478 | + provider: "codex", | |
| 479 | + status: "ready", | |
| 480 | + threadId: asThreadId("thread-1"), | |
| 481 | + runtimeMode: "full-access", | |
| 482 | + createdAt: "2026-02-10T00:00:00.000Z", | |
| 483 | + updatedAt: "2026-02-10T00:00:00.000Z", | |
| 484 | + }, | |
| 485 | + }; | |
| 486 | + | |
| 487 | + ( | |
| 488 | + manager as unknown as { | |
| 489 | + sessions: Map<ThreadId, typeof existingContext>; | |
| 490 | + } | |
| 491 | + ).sessions.set(asThreadId("thread-1"), existingContext); | |
| 492 | + | |
| 493 | + const disposeSession = vi | |
| 494 | + .spyOn( | |
| 495 | + manager as unknown as { | |
| 496 | + disposeSession: ( | |
| 497 | + context: typeof existingContext, | |
| 498 | + options?: { readonly emitLifecycleEvent?: boolean }, | |
| 499 | + ) => void; | |
| 500 | + }, | |
| 501 | + "disposeSession", | |
| 502 | + ) | |
| 503 | + .mockImplementation(() => {}); | |
| 504 | + const assertSupportedCodexCliVersion = vi | |
| 505 | + .spyOn( | |
| 506 | + manager as unknown as { | |
| 507 | + assertSupportedCodexCliVersion: (input: { | |
| 508 | + binaryPath: string; | |
| 509 | + cwd: string; | |
| 510 | + homePath?: string; | |
| 511 | + }) => void; | |
| 512 | + }, | |
| 513 | + "assertSupportedCodexCliVersion", | |
| 514 | + ) | |
| 515 | + .mockImplementation(() => {}); | |
| 516 | + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { | |
| 517 | + throw new Error("cwd missing"); | |
| 518 | + }); | |
| 519 | + | |
| 520 | + try { | |
| 521 | + await expect( | |
| 522 | + manager.startSession({ | |
| 523 | + threadId: asThreadId("thread-1"), | |
| 524 | + provider: "codex", | |
| 525 | + binaryPath: "codex", | |
| 526 | + runtimeMode: "full-access", | |
| 527 | + }), | |
| 528 | + ).rejects.toThrow("cwd missing"); | |
| 529 | + | |
| 530 | + expect(disposeSession).toHaveBeenCalledWith(existingContext, { | |
| 531 | + emitLifecycleEvent: false, | |
| 532 | + }); | |
| 533 | + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); | |
| 534 | + } finally { | |
| 535 | + disposeSession.mockRestore(); | |
| 536 | + assertSupportedCodexCliVersion.mockRestore(); | |
| 537 | + processCwd.mockRestore(); | |
| 538 | + ( | |
| 539 | + manager as unknown as { | |
| 540 | + sessions: Map<ThreadId, typeof existingContext>; | |
| 541 | + } | |
| 542 | + ).sessions.clear(); | |
| 543 | + manager.stopAll(); | |
| 544 | + } | |
| 545 | + }); | |
| 546 | + | |
| 547 | + it("continues replacement start when existing session disposal fails", async () => { | |
| 548 | + const manager = new CodexAppServerManager(); | |
| 549 | + const existingContext = { | |
| 550 | + session: { | |
| 551 | + provider: "codex", | |
| 552 | + status: "ready", | |
| 553 | + threadId: asThreadId("thread-1"), | |
| 554 | + runtimeMode: "full-access", | |
| 555 | + createdAt: "2026-02-10T00:00:00.000Z", | |
| 556 | + updatedAt: "2026-02-10T00:00:00.000Z", | |
| 557 | + }, | |
| 558 | + }; | |
| 559 | + | |
| 560 | + ( | |
| 561 | + manager as unknown as { | |
| 562 | + sessions: Map<ThreadId, typeof existingContext>; | |
| 563 | + } | |
| 564 | + ).sessions.set(asThreadId("thread-1"), existingContext); | |
| 565 | + | |
| 566 | + const disposeSession = vi | |
| 567 | + .spyOn( | |
| 568 | + manager as unknown as { | |
| 569 | + disposeSession: ( | |
| 570 | + context: typeof existingContext, | |
| 571 | + options?: { readonly emitLifecycleEvent?: boolean }, | |
| 572 | + ) => void; | |
| 573 | + }, | |
| 574 | + "disposeSession", | |
| 575 | + ) | |
| 576 | + .mockImplementation(() => { | |
| 577 | + throw new Error("dispose failed"); | |
| 578 | + }); | |
| 579 | + const assertSupportedCodexCliVersion = vi | |
| 580 | + .spyOn( | |
| 581 | + manager as unknown as { | |
| 582 | + assertSupportedCodexCliVersion: (input: { | |
| 583 | + binaryPath: string; | |
| 584 | + cwd: string; | |
| 585 | + homePath?: string; | |
| 586 | + }) => void; | |
| 587 | + }, | |
| 588 | + "assertSupportedCodexCliVersion", | |
| 589 | + ) | |
| 590 | + .mockImplementation(() => {}); | |
| 591 | + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { | |
| 592 | + throw new Error("cwd missing"); | |
| 593 | + }); | |
| 594 | + | |
| 595 | + try { | |
| 596 | + await expect( | |
| 597 | + manager.startSession({ | |
| 598 | + threadId: asThreadId("thread-1"), | |
| 599 | + provider: "codex", | |
| 600 | + binaryPath: "codex", | |
| 601 | + runtimeMode: "full-access", | |
| 602 | + }), | |
| 603 | + ).rejects.toThrow("cwd missing"); | |
| 604 | + | |
| 605 | + expect(disposeSession).toHaveBeenCalledWith(existingContext, { | |
| 606 | + emitLifecycleEvent: false, | |
| 607 | + }); | |
| 608 | + expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled(); | |
| 609 | + } finally { | |
| 610 | + disposeSession.mockRestore(); | |
| 611 | + assertSupportedCodexCliVersion.mockRestore(); | |
| 612 | + processCwd.mockRestore(); | |
| 613 | + ( | |
| 614 | + manager as unknown as { | |
| 615 | + sessions: Map<ThreadId, typeof existingContext>; | |
| 616 | + } | |
| 617 | + ).sessions.clear(); | |
| 618 | + manager.stopAll(); | |
| 619 | + } | |
| 620 | + }); | |
| 473 | 621 | }); |
| 474 | 622 | |
| 475 | 623 | describe("sendTurn", () => { |
| @@ -743,7 +891,7 @@ describe("respondToUserInput", () => { · 267 unmodified lines | ||
| 743 | 891 | |
| 744 | 892 | await manager.respondToUserInput( |
| 745 | 893 | asThreadId("thread_1"), |
| 746 | − ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 894 | + ApprovalRequestId.make("req-user-input-1"), | |
| 747 | 895 | { |
| 748 | 896 | scope: "All request methods", |
| 749 | 897 | compat: "Keep current envelope", |
| @@ -780,7 +928,7 @@ describe("respondToUserInput", () => { · 30 unmodified lines | ||
| 780 | 928 | |
| 781 | 929 | await manager.respondToUserInput( |
| 782 | 930 | asThreadId("thread_1"), |
| 783 | − ApprovalRequestId.makeUnsafe("req-user-input-1"), | |
| 931 | + ApprovalRequestId.make("req-user-input-1"), | |
| 784 | 932 | { |
| 785 | 933 | scope: [], |
| 786 | 934 | }, |
| @@ -19,22 +19,22 @@ import { · 18 unmodified lines | ||
| 19 | 19 | ProviderInteractionMode, |
| 20 | 20 | } from "@t3tools/contracts"; |
| 21 | 21 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 22 | −import { Effect, ServiceMap } from "effect"; | |
| 22 | +import { Effect, Context } from "effect"; | |
| 23 | 23 | |
| 24 | 24 | import { |
| 25 | 25 | formatCodexCliUpgradeMessage, |
| 26 | 26 | isCodexCliVersionSupported, |
| 27 | 27 | parseCodexCliVersion, |
| 28 | −} from "./provider/codexCliVersion"; | |
| 28 | +} from "./provider/codexCliVersion.ts"; | |
| 29 | 29 | import { |
| 30 | 30 | readCodexAccountSnapshot, |
| 31 | 31 | resolveCodexModelForAccount, |
| 32 | 32 | type CodexAccountSnapshot, |
| 33 | −} from "./provider/codexAccount"; | |
| 34 | −import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; | |
| 33 | +} from "./provider/codexAccount.ts"; | |
| 34 | +import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts"; | |
| 35 | 35 | |
| 36 | −export { buildCodexInitializeParams } from "./provider/codexAppServer"; | |
| 37 | −export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; | |
| 36 | +export { buildCodexInitializeParams } from "./provider/codexAppServer.ts"; | |
| 37 | +export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts"; | |
| 38 | 38 | |
| 39 | 39 | type PendingRequestKey = string; |
| 40 | 40 | |
| @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the · 249 unmodified lines | ||
| 290 | 290 | </collaboration_mode>`; |
| 291 | 291 | |
| 292 | 292 | function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { |
| 293 | − readonly approvalPolicy: "on-request" | "never"; | |
| 294 | − readonly sandbox: "workspace-write" | "danger-full-access"; | |
| 293 | + readonly approvalPolicy: "untrusted" | "on-request" | "never"; | |
| 294 | + readonly sandbox: "read-only" | "workspace-write" | "danger-full-access"; | |
| 295 | 295 | } { |
| 296 | − if (runtimeMode === "approval-required") { | |
| 297 | − return { | |
| 298 | − approvalPolicy: "on-request", | |
| 299 | − sandbox: "workspace-write", | |
| 300 | − }; | |
| 296 | + switch (runtimeMode) { | |
| 297 | + case "approval-required": | |
| 298 | + return { | |
| 299 | + approvalPolicy: "untrusted", | |
| 300 | + sandbox: "read-only", | |
| 301 | + }; | |
| 302 | + case "auto-accept-edits": | |
| 303 | + return { | |
| 304 | + approvalPolicy: "on-request", | |
| 305 | + sandbox: "workspace-write", | |
| 306 | + }; | |
| 307 | + case "full-access": | |
| 308 | + return { | |
| 309 | + approvalPolicy: "never", | |
| 310 | + sandbox: "danger-full-access", | |
| 311 | + }; | |
| 301 | 312 | } |
| 302 | − | |
| 303 | − return { | |
| 304 | − approvalPolicy: "never", | |
| 305 | − sandbox: "danger-full-access", | |
| 306 | − }; | |
| 307 | 313 | } |
| 308 | 314 | |
| 309 | 315 | /** |
| @@ -433,7 +439,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 123 unmodified lines | ||
| 433 | 439 | private readonly sessions = new Map<ThreadId, CodexSessionContext>(); |
| 434 | 440 | |
| 435 | 441 | private runPromise: (effect: Effect.Effect<unknown, never>) => Promise<unknown>; |
| 436 | − constructor(services?: ServiceMap.ServiceMap<never>) { | |
| 442 | + constructor(services?: Context.Context<never>) { | |
| 437 | 443 | super(); |
| 438 | 444 | this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise; |
| 439 | 445 | } |
| @@ -444,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines | ||
| 444 | 450 | let context: CodexSessionContext | undefined; |
| 445 | 451 | |
| 446 | 452 | try { |
| 453 | + const existingContext = this.sessions.get(threadId); | |
| 454 | + if (existingContext) { | |
| 455 | + await Effect.logWarning("codex app-server replacing existing session", { | |
| 456 | + threadId, | |
| 457 | + existingStatus: existingContext.session.status, | |
| 458 | + }).pipe(this.runPromise); | |
| 459 | + try { | |
| 460 | + this.disposeSession(existingContext, { | |
| 461 | + emitLifecycleEvent: false, | |
| 462 | + }); | |
| 463 | + } catch (error) { | |
| 464 | + await Effect.logWarning("codex app-server failed to dispose existing session", { | |
| 465 | + threadId, | |
| 466 | + existingStatus: existingContext.session.status, | |
| 467 | + cause: error instanceof Error ? error.message : String(error), | |
| 468 | + }).pipe(this.runPromise); | |
| 469 | + } | |
| 470 | + } | |
| 471 | + | |
| 447 | 472 | const resolvedCwd = input.cwd ?? process.cwd(); |
| 448 | 473 | |
| 449 | 474 | const session: ProviderSession = { |
| @@ -634,7 +659,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 184 unmodified lines | ||
| 634 | 659 | this.stopSession(threadId); |
| 635 | 660 | } else { |
| 636 | 661 | this.emitEvent({ |
| 637 | − id: EventId.makeUnsafe(randomUUID()), | |
| 662 | + id: EventId.make(randomUUID()), | |
| 638 | 663 | kind: "error", |
| 639 | 664 | provider: "codex", |
| 640 | 665 | threadId, |
| @@ -733,7 +758,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 92 unmodified lines | ||
| 733 | 758 | if (!turnIdRaw) { |
| 734 | 759 | throw new Error("turn/start response did not include a turn id."); |
| 735 | 760 | } |
| 736 | − const turnId = TurnId.makeUnsafe(turnIdRaw); | |
| 761 | + const turnId = TurnId.make(turnIdRaw); | |
| 737 | 762 | |
| 738 | 763 | this.updateSession(context, { |
| 739 | 764 | status: "running", |
| @@ -834,7 +859,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 94 unmodified lines | ||
| 834 | 859 | }); |
| 835 | 860 | |
| 836 | 861 | this.emitEvent({ |
| 837 | − id: EventId.makeUnsafe(randomUUID()), | |
| 862 | + id: EventId.make(randomUUID()), | |
| 838 | 863 | kind: "notification", |
| 839 | 864 | provider: "codex", |
| 840 | 865 | threadId: context.session.threadId, |
| @@ -873,7 +898,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 32 unmodified lines | ||
| 873 | 898 | }); |
| 874 | 899 | |
| 875 | 900 | this.emitEvent({ |
| 876 | − id: EventId.makeUnsafe(randomUUID()), | |
| 901 | + id: EventId.make(randomUUID()), | |
| 877 | 902 | kind: "notification", |
| 878 | 903 | provider: "codex", |
| 879 | 904 | threadId: context.session.threadId, |
| @@ -895,6 +920,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 15 unmodified lines | ||
| 895 | 920 | return; |
| 896 | 921 | } |
| 897 | 922 | |
| 923 | + this.disposeSession(context, { | |
| 924 | + emitLifecycleEvent: true, | |
| 925 | + }); | |
| 926 | + } | |
| 927 | + | |
| 928 | + private disposeSession( | |
| 929 | + context: CodexSessionContext, | |
| 930 | + options?: { readonly emitLifecycleEvent?: boolean }, | |
| 931 | + ): void { | |
| 932 | + if (context.stopping) { | |
| 933 | + return; | |
| 934 | + } | |
| 935 | + | |
| 898 | 936 | context.stopping = true; |
| 899 | 937 | |
| 900 | 938 | for (const pending of context.pending.values()) { |
| @@ -915,8 +953,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 14 unmodified lines | ||
| 915 | 953 | status: "closed", |
| 916 | 954 | activeTurnId: undefined, |
| 917 | 955 | }); |
| 918 | − this.emitLifecycleEvent(context, "session/closed", "Session stopped"); | |
| 919 | − this.sessions.delete(threadId); | |
| 956 | + if (options?.emitLifecycleEvent !== false) { | |
| 957 | + this.emitLifecycleEvent(context, "session/closed", "Session stopped"); | |
| 958 | + } | |
| 959 | + this.sessions.delete(context.session.threadId); | |
| 920 | 960 | } |
| 921 | 961 | |
| 922 | 962 | listSessions(): ProviderSession[] { |
| @@ -1055,7 +1095,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 132 unmodified lines | ||
| 1055 | 1095 | : undefined; |
| 1056 | 1096 | |
| 1057 | 1097 | this.emitEvent({ |
| 1058 | − id: EventId.makeUnsafe(randomUUID()), | |
| 1098 | + id: EventId.make(randomUUID()), | |
| 1059 | 1099 | kind: "notification", |
| 1060 | 1100 | provider: "codex", |
| 1061 | 1101 | threadId: context.session.threadId, |
| @@ -1128,7 +1168,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 66 unmodified lines | ||
| 1128 | 1168 | const requestKind = this.requestKindForMethod(request.method); |
| 1129 | 1169 | let requestId: ApprovalRequestId | undefined; |
| 1130 | 1170 | if (requestKind) { |
| 1131 | − requestId = ApprovalRequestId.makeUnsafe(randomUUID()); | |
| 1171 | + requestId = ApprovalRequestId.make(randomUUID()); | |
| 1132 | 1172 | const pendingRequest: PendingApprovalRequest = { |
| 1133 | 1173 | requestId, |
| 1134 | 1174 | jsonRpcId: request.id, |
| @@ -1147,7 +1187,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 12 unmodified lines | ||
| 1147 | 1187 | } |
| 1148 | 1188 | |
| 1149 | 1189 | if (request.method === "item/tool/requestUserInput") { |
| 1150 | − requestId = ApprovalRequestId.makeUnsafe(randomUUID()); | |
| 1190 | + requestId = ApprovalRequestId.make(randomUUID()); | |
| 1151 | 1191 | context.pendingUserInputs.set(requestId, { |
| 1152 | 1192 | requestId, |
| 1153 | 1193 | jsonRpcId: request.id, |
| @@ -1158,7 +1198,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines | ||
| 1158 | 1198 | } |
| 1159 | 1199 | |
| 1160 | 1200 | this.emitEvent({ |
| 1161 | − id: EventId.makeUnsafe(randomUUID()), | |
| 1201 | + id: EventId.make(randomUUID()), | |
| 1162 | 1202 | kind: "request", |
| 1163 | 1203 | provider: "codex", |
| 1164 | 1204 | threadId: context.session.threadId, |
| @@ -1248,7 +1288,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 83 unmodified lines | ||
| 1248 | 1288 | |
| 1249 | 1289 | private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void { |
| 1250 | 1290 | this.emitEvent({ |
| 1251 | − id: EventId.makeUnsafe(randomUUID()), | |
| 1291 | + id: EventId.make(randomUUID()), | |
| 1252 | 1292 | kind: "session", |
| 1253 | 1293 | provider: "codex", |
| 1254 | 1294 | threadId: context.session.threadId, |
| @@ -1260,7 +1300,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 5 unmodified lines | ||
| 1260 | 1300 | |
| 1261 | 1301 | private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void { |
| 1262 | 1302 | this.emitEvent({ |
| 1263 | − id: EventId.makeUnsafe(randomUUID()), | |
| 1303 | + id: EventId.make(randomUUID()), | |
| 1264 | 1304 | kind: "error", |
| 1265 | 1305 | provider: "codex", |
| 1266 | 1306 | threadId: context.session.threadId, |
| @@ -1276,7 +1316,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 9 unmodified lines | ||
| 1276 | 1316 | message: string, |
| 1277 | 1317 | ): void { |
| 1278 | 1318 | this.emitEvent({ |
| 1279 | − id: EventId.makeUnsafe(randomUUID()), | |
| 1319 | + id: EventId.make(randomUUID()), | |
| 1280 | 1320 | kind: "notification", |
| 1281 | 1321 | provider: "codex", |
| 1282 | 1322 | threadId: context.session.threadId, |
| @@ -1335,7 +1375,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 52 unmodified lines | ||
| 1335 | 1375 | const turns = turnsRaw.map((turnValue, index) => { |
| 1336 | 1376 | const turn = this.readObject(turnValue); |
| 1337 | 1377 | const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; |
| 1338 | − const turnId = TurnId.makeUnsafe(turnIdRaw); | |
| 1378 | + const turnId = TurnId.make(turnIdRaw); | |
| 1339 | 1379 | const items = this.readArray(turn, "items") ?? []; |
| 1340 | 1380 | return { |
| 1341 | 1381 | id: turnId, |
| @@ -1584,9 +1624,9 @@ function readResumeThreadId(input: { · 242 unmodified lines | ||
| 1584 | 1624 | } |
| 1585 | 1625 | |
| 1586 | 1626 | function toTurnId(value: string | undefined): TurnId | undefined { |
| 1587 | − return brandIfNonEmpty(value, TurnId.makeUnsafe); | |
| 1627 | + return brandIfNonEmpty(value, TurnId.make); | |
| 1588 | 1628 | } |
| 1589 | 1629 | |
| 1590 | 1630 | function toProviderItemId(value: string | undefined): ProviderItemId | undefined { |
| 1591 | − return brandIfNonEmpty(value, ProviderItemId.makeUnsafe); | |
| 1631 | + return brandIfNonEmpty(value, ProviderItemId.make); | |
| 1592 | 1632 | } |
| @@ -6,13 +6,16 @@ · 5 unmodified lines | ||
| 6 | 6 | * |
| 7 | 7 | * @module ServerConfig |
| 8 | 8 | */ |
| 9 | −import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect"; | |
| 9 | +import { Effect, FileSystem, Layer, LogLevel, Path, Schema, Context } from "effect"; | |
| 10 | 10 | |
| 11 | 11 | export const DEFAULT_PORT = 3773; |
| 12 | 12 | |
| 13 | 13 | export const RuntimeMode = Schema.Literals(["web", "desktop"]); |
| 14 | 14 | export type RuntimeMode = typeof RuntimeMode.Type; |
| 15 | 15 | |
| 16 | +export const StartupPresentation = Schema.Literals(["browser", "headless"]); | |
| 17 | +export type StartupPresentation = typeof StartupPresentation.Type; | |
| 18 | + | |
| 16 | 19 | /** |
| 17 | 20 | * ServerDerivedPaths - Derived paths from the base directory. |
| 18 | 21 | */ |
| @@ -21,6 +24,7 @@ export interface ServerDerivedPaths { · 2 unmodified lines | ||
| 21 | 24 | readonly dbPath: string; |
| 22 | 25 | readonly keybindingsConfigPath: string; |
| 23 | 26 | readonly settingsPath: string; |
| 27 | + readonly providerStatusCacheDir: string; | |
| 24 | 28 | readonly worktreesDir: string; |
| 25 | 29 | readonly attachmentsDir: string; |
| 26 | 30 | readonly logsDir: string; |
| @@ -30,6 +34,9 @@ export interface ServerDerivedPaths { · 3 unmodified lines | ||
| 30 | 34 | readonly providerEventLogPath: string; |
| 31 | 35 | readonly terminalLogsDir: string; |
| 32 | 36 | readonly anonymousIdPath: string; |
| 37 | + readonly environmentIdPath: string; | |
| 38 | + readonly serverRuntimeStatePath: string; | |
| 39 | + readonly secretsDir: string; | |
| 33 | 40 | } |
| 34 | 41 | |
| 35 | 42 | /** |
| @@ -54,7 +61,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { · 18 unmodified lines | ||
| 54 | 61 | readonly staticDir: string | undefined; |
| 55 | 62 | readonly devUrl: URL | undefined; |
| 56 | 63 | readonly noBrowser: boolean; |
| 57 | − readonly authToken: string | undefined; | |
| 64 | + readonly startupPresentation: StartupPresentation; | |
| 65 | + readonly desktopBootstrapToken: string | undefined; | |
| 58 | 66 | readonly autoBootstrapProjectFromCwd: boolean; |
| 59 | 67 | readonly logWebSocketEvents: boolean; |
| 60 | 68 | } |
| @@ -69,11 +77,13 @@ export const deriveServerPaths = Effect.fn(function* ( · 8 unmodified lines | ||
| 69 | 77 | const attachmentsDir = join(stateDir, "attachments"); |
| 70 | 78 | const logsDir = join(stateDir, "logs"); |
| 71 | 79 | const providerLogsDir = join(logsDir, "provider"); |
| 80 | + const providerStatusCacheDir = join(baseDir, "caches"); | |
| 72 | 81 | return { |
| 73 | 82 | stateDir, |
| 74 | 83 | dbPath, |
| 75 | 84 | keybindingsConfigPath: join(stateDir, "keybindings.json"), |
| 76 | 85 | settingsPath: join(stateDir, "settings.json"), |
| 86 | + providerStatusCacheDir, | |
| 77 | 87 | worktreesDir: join(baseDir, "worktrees"), |
| 78 | 88 | attachmentsDir, |
| 79 | 89 | logsDir, |
| @@ -83,6 +93,9 @@ export const deriveServerPaths = Effect.fn(function* ( · 3 unmodified lines | ||
| 83 | 93 | providerEventLogPath: join(providerLogsDir, "events.log"), |
| 84 | 94 | terminalLogsDir: join(logsDir, "terminals"), |
| 85 | 95 | anonymousIdPath: join(stateDir, "anonymous-id"), |
| 96 | + environmentIdPath: join(stateDir, "environment-id"), | |
| 97 | + serverRuntimeStatePath: join(stateDir, "server-runtime.json"), | |
| 98 | + secretsDir: join(stateDir, "secrets"), | |
| 86 | 99 | }; |
| 87 | 100 | }); |
| 88 | 101 | |
| @@ -100,7 +113,9 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server · 11 unmodified lines | ||
| 100 | 113 | fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), |
| 101 | 114 | fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), |
| 102 | 115 | fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), |
| 116 | + fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), | |
| 103 | 117 | fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }), |
| 118 | + fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), | |
| 104 | 119 | ], |
| 105 | 120 | { concurrency: "unbounded" }, |
| 106 | 121 | ); |
| @@ -109,7 +124,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server · 2 unmodified lines | ||
| 109 | 124 | /** |
| 110 | 125 | * ServerConfig - Service tag for server runtime configuration. |
| 111 | 126 | */ |
| 112 | −export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()( | |
| 127 | +export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShape>()( | |
| 113 | 128 | "t3/config/ServerConfig", |
| 114 | 129 | ) { |
| 115 | 130 | static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) => |
| @@ -145,10 +160,11 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS · 29 unmodified lines | ||
| 145 | 160 | logWebSocketEvents: false, |
| 146 | 161 | port: 0, |
| 147 | 162 | host: undefined, |
| 148 | − authToken: undefined, | |
| 163 | + desktopBootstrapToken: undefined, | |
| 149 | 164 | staticDir: undefined, |
| 150 | 165 | devUrl, |
| 151 | 166 | noBrowser: false, |
| 167 | + startupPresentation: "browser", | |
| 152 | 168 | } satisfies ServerConfigShape; |
| 153 | 169 | }), |
| 154 | 170 | ); |
| @@ -0,0 +1,125 @@ | ||
| 1 | +import * as nodePath from "node:path"; | |
| 2 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | +import { expect, it } from "@effect/vitest"; | |
| 4 | +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; | |
| 5 | + | |
| 6 | +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; | |
| 7 | +import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; | |
| 8 | +import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; | |
| 9 | + | |
| 10 | +const makeServerEnvironmentLayer = (baseDir: string) => | |
| 11 | + ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | |
| 12 | + | |
| 13 | +const makeServerConfig = Effect.fn(function* (baseDir: string) { | |
| 14 | + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | |
| 15 | + | |
| 16 | + return { | |
| 17 | + ...derivedPaths, | |
| 18 | + logLevel: "Error", | |
| 19 | + traceMinLevel: "Info", | |
| 20 | + traceTimingEnabled: true, | |
| 21 | + traceBatchWindowMs: 200, | |
| 22 | + traceMaxBytes: 10 * 1024 * 1024, | |
| 23 | + traceMaxFiles: 10, | |
| 24 | + otlpTracesUrl: undefined, | |
| 25 | + otlpMetricsUrl: undefined, | |
| 26 | + otlpExportIntervalMs: 10_000, | |
| 27 | + otlpServiceName: "t3-server", | |
| 28 | + cwd: process.cwd(), | |
| 29 | + baseDir, | |
| 30 | + mode: "web", | |
| 31 | + autoBootstrapProjectFromCwd: false, | |
| 32 | + logWebSocketEvents: false, | |
| 33 | + port: 0, | |
| 34 | + host: undefined, | |
| 35 | + desktopBootstrapToken: undefined, | |
| 36 | + staticDir: undefined, | |
| 37 | + devUrl: undefined, | |
| 38 | + noBrowser: false, | |
| 39 | + startupPresentation: "browser", | |
| 40 | + } satisfies ServerConfigShape; | |
| 41 | +}); | |
| 42 | + | |
| 43 | +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | |
| 44 | + it.effect("persists the environment id across service restarts", () => | |
| 45 | + Effect.gen(function* () { | |
| 46 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 47 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 48 | + prefix: "t3-server-environment-test-", | |
| 49 | + }); | |
| 50 | + | |
| 51 | + const first = yield* Effect.gen(function* () { | |
| 52 | + const serverEnvironment = yield* ServerEnvironment; | |
| 53 | + return yield* serverEnvironment.getDescriptor; | |
| 54 | + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | |
| 55 | + const second = yield* Effect.gen(function* () { | |
| 56 | + const serverEnvironment = yield* ServerEnvironment; | |
| 57 | + return yield* serverEnvironment.getDescriptor; | |
| 58 | + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); | |
| 59 | + | |
| 60 | + expect(first.environmentId).toBe(second.environmentId); | |
| 61 | + expect(second.capabilities.repositoryIdentity).toBe(true); | |
| 62 | + }), | |
| 63 | + ); | |
| 64 | + | |
| 65 | + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => | |
| 66 | + Effect.gen(function* () { | |
| 67 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 68 | + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 69 | + prefix: "t3-server-environment-read-error-test-", | |
| 70 | + }); | |
| 71 | + const serverConfig = yield* makeServerConfig(baseDir); | |
| 72 | + const environmentIdPath = serverConfig.environmentIdPath; | |
| 73 | + yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); | |
| 74 | + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); | |
| 75 | + const writeAttempts: string[] = []; | |
| 76 | + const failingFileSystemLayer = FileSystem.layerNoop({ | |
| 77 | + exists: (path) => Effect.succeed(path === environmentIdPath), | |
| 78 | + readFileString: (path) => | |
| 79 | + path === environmentIdPath | |
| 80 | + ? Effect.fail( | |
| 81 | + PlatformError.systemError({ | |
| 82 | + _tag: "PermissionDenied", | |
| 83 | + module: "FileSystem", | |
| 84 | + method: "readFileString", | |
| 85 | + description: "permission denied", | |
| 86 | + pathOrDescriptor: path, | |
| 87 | + }), | |
| 88 | + ) | |
| 89 | + : Effect.fail( | |
| 90 | + PlatformError.systemError({ | |
| 91 | + _tag: "NotFound", | |
| 92 | + module: "FileSystem", | |
| 93 | + method: "readFileString", | |
| 94 | + description: "not found", | |
| 95 | + pathOrDescriptor: path, | |
| 96 | + }), | |
| 97 | + ), | |
| 98 | + writeFileString: (path) => { | |
| 99 | + writeAttempts.push(path); | |
| 100 | + return Effect.void; | |
| 101 | + }, | |
| 102 | + }); | |
| 103 | + | |
| 104 | + const exit = yield* Effect.gen(function* () { | |
| 105 | + const serverEnvironment = yield* ServerEnvironment; | |
| 106 | + return yield* serverEnvironment.getDescriptor; | |
| 107 | + }).pipe( | |
| 108 | + Effect.provide( | |
| 109 | + ServerEnvironmentLive.pipe( | |
| 110 | + Layer.provide( | |
| 111 | + Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), | |
| 112 | + ), | |
| 113 | + ), | |
| 114 | + ), | |
| 115 | + Effect.exit, | |
| 116 | + ); | |
| 117 | + | |
| 118 | + expect(Exit.isFailure(exit)).toBe(true); | |
| 119 | + expect(writeAttempts).toEqual([]); | |
| 120 | + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( | |
| 121 | + "persisted-environment-id\n", | |
| 122 | + ); | |
| 123 | + }), | |
| 124 | + ); | |
| 125 | +}); | |
| @@ -0,0 +1,92 @@ | ||
| 1 | +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 2 | +import { Effect, FileSystem, Layer, Path, Random } from "effect"; | |
| 3 | + | |
| 4 | +import { ServerConfig } from "../../config.ts"; | |
| 5 | +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; | |
| 6 | +import packageJson from "../../../package.json" with { type: "json" }; | |
| 7 | +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | |
| 8 | + | |
| 9 | +function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { | |
| 10 | + switch (process.platform) { | |
| 11 | + case "darwin": | |
| 12 | + return "darwin"; | |
| 13 | + case "linux": | |
| 14 | + return "linux"; | |
| 15 | + case "win32": | |
| 16 | + return "windows"; | |
| 17 | + default: | |
| 18 | + return "unknown"; | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { | |
| 23 | + switch (process.arch) { | |
| 24 | + case "arm64": | |
| 25 | + return "arm64"; | |
| 26 | + case "x64": | |
| 27 | + return "x64"; | |
| 28 | + default: | |
| 29 | + return "other"; | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 33 | +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { | |
| 34 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 35 | + const path = yield* Path.Path; | |
| 36 | + const serverConfig = yield* ServerConfig; | |
| 37 | + | |
| 38 | + const readPersistedEnvironmentId = Effect.gen(function* () { | |
| 39 | + const exists = yield* fileSystem | |
| 40 | + .exists(serverConfig.environmentIdPath) | |
| 41 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 42 | + if (!exists) { | |
| 43 | + return null; | |
| 44 | + } | |
| 45 | + | |
| 46 | + const raw = yield* fileSystem | |
| 47 | + .readFileString(serverConfig.environmentIdPath) | |
| 48 | + .pipe(Effect.map((value) => value.trim())); | |
| 49 | + | |
| 50 | + return raw.length > 0 ? raw : null; | |
| 51 | + }); | |
| 52 | + | |
| 53 | + const persistEnvironmentId = (value: string) => | |
| 54 | + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); | |
| 55 | + | |
| 56 | + const environmentIdRaw = yield* Effect.gen(function* () { | |
| 57 | + const persisted = yield* readPersistedEnvironmentId; | |
| 58 | + if (persisted) { | |
| 59 | + return persisted; | |
| 60 | + } | |
| 61 | + | |
| 62 | + const generated = yield* Random.nextUUIDv4; | |
| 63 | + yield* persistEnvironmentId(generated); | |
| 64 | + return generated; | |
| 65 | + }); | |
| 66 | + | |
| 67 | + const environmentId = EnvironmentId.make(environmentIdRaw); | |
| 68 | + const cwdBaseName = path.basename(serverConfig.cwd).trim(); | |
| 69 | + const label = yield* resolveServerEnvironmentLabel({ | |
| 70 | + cwdBaseName, | |
| 71 | + }); | |
| 72 | + | |
| 73 | + const descriptor: ExecutionEnvironmentDescriptor = { | |
| 74 | + environmentId, | |
| 75 | + label, | |
| 76 | + platform: { | |
| 77 | + os: platformOs(), | |
| 78 | + arch: platformArch(), | |
| 79 | + }, | |
| 80 | + serverVersion: packageJson.version, | |
| 81 | + capabilities: { | |
| 82 | + repositoryIdentity: true, | |
| 83 | + }, | |
| 84 | + }; | |
| 85 | + | |
| 86 | + return { | |
| 87 | + getEnvironmentId: Effect.succeed(environmentId), | |
| 88 | + getDescriptor: Effect.succeed(descriptor), | |
| 89 | + } satisfies ServerEnvironmentShape; | |
| 90 | +}); | |
| 91 | + | |
| 92 | +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); | |
| @@ -0,0 +1,150 @@ | ||
| 1 | +import { afterEach, describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { Effect, FileSystem } from "effect"; | |
| 3 | +import { vi } from "vitest"; | |
| 4 | + | |
| 5 | +vi.mock("../../processRunner.ts", () => ({ | |
| 6 | + runProcess: vi.fn(), | |
| 7 | +})); | |
| 8 | + | |
| 9 | +import { runProcess } from "../../processRunner.ts"; | |
| 10 | +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; | |
| 11 | + | |
| 12 | +const mockedRunProcess = vi.mocked(runProcess); | |
| 13 | +const NoopFileSystemLayer = FileSystem.layerNoop({}); | |
| 14 | + | |
| 15 | +afterEach(() => { | |
| 16 | + mockedRunProcess.mockReset(); | |
| 17 | +}); | |
| 18 | + | |
| 19 | +describe("resolveServerEnvironmentLabel", () => { | |
| 20 | + it.effect("uses hostname fallback regardless of launch mode", () => | |
| 21 | + Effect.gen(function* () { | |
| 22 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 23 | + cwdBaseName: "t3code", | |
| 24 | + platform: "win32", | |
| 25 | + hostname: "macbook-pro", | |
| 26 | + }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 27 | + | |
| 28 | + expect(result).toBe("macbook-pro"); | |
| 29 | + }), | |
| 30 | + ); | |
| 31 | + | |
| 32 | + it.effect("prefers the macOS ComputerName", () => | |
| 33 | + Effect.gen(function* () { | |
| 34 | + mockedRunProcess.mockResolvedValueOnce({ | |
| 35 | + stdout: " Julius's MacBook Pro \n", | |
| 36 | + stderr: "", | |
| 37 | + code: 0, | |
| 38 | + signal: null, | |
| 39 | + timedOut: false, | |
| 40 | + }); | |
| 41 | + | |
| 42 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 43 | + cwdBaseName: "t3code", | |
| 44 | + platform: "darwin", | |
| 45 | + hostname: "macbook-pro", | |
| 46 | + }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 47 | + | |
| 48 | + expect(result).toBe("Julius's MacBook Pro"); | |
| 49 | + expect(mockedRunProcess).toHaveBeenCalledWith( | |
| 50 | + "scutil", | |
| 51 | + ["--get", "ComputerName"], | |
| 52 | + expect.objectContaining({ allowNonZeroExit: true }), | |
| 53 | + ); | |
| 54 | + }), | |
| 55 | + ); | |
| 56 | + | |
| 57 | + it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => | |
| 58 | + Effect.gen(function* () { | |
| 59 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 60 | + cwdBaseName: "t3code", | |
| 61 | + platform: "linux", | |
| 62 | + hostname: "buildbox", | |
| 63 | + }).pipe( | |
| 64 | + Effect.provide( | |
| 65 | + FileSystem.layerNoop({ | |
| 66 | + exists: (path) => Effect.succeed(path === "/etc/machine-info"), | |
| 67 | + readFileString: (path) => | |
| 68 | + path === "/etc/machine-info" | |
| 69 | + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') | |
| 70 | + : Effect.succeed(""), | |
| 71 | + }), | |
| 72 | + ), | |
| 73 | + ); | |
| 74 | + | |
| 75 | + expect(result).toBe("Build Agent 01"); | |
| 76 | + expect(mockedRunProcess).not.toHaveBeenCalled(); | |
| 77 | + }), | |
| 78 | + ); | |
| 79 | + | |
| 80 | + it.effect("falls back to hostnamectl pretty hostname on Linux", () => | |
| 81 | + Effect.gen(function* () { | |
| 82 | + mockedRunProcess.mockResolvedValueOnce({ | |
| 83 | + stdout: "CI Runner\n", | |
| 84 | + stderr: "", | |
| 85 | + code: 0, | |
| 86 | + signal: null, | |
| 87 | + timedOut: false, | |
| 88 | + }); | |
| 89 | + | |
| 90 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 91 | + cwdBaseName: "t3code", | |
| 92 | + platform: "linux", | |
| 93 | + hostname: "runner-01", | |
| 94 | + }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 95 | + | |
| 96 | + expect(result).toBe("CI Runner"); | |
| 97 | + expect(mockedRunProcess).toHaveBeenCalledWith( | |
| 98 | + "hostnamectl", | |
| 99 | + ["--pretty"], | |
| 100 | + expect.objectContaining({ allowNonZeroExit: true }), | |
| 101 | + ); | |
| 102 | + }), | |
| 103 | + ); | |
| 104 | + | |
| 105 | + it.effect("falls back to the hostname when friendly labels are unavailable", () => | |
| 106 | + Effect.gen(function* () { | |
| 107 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 108 | + cwdBaseName: "t3code", | |
| 109 | + platform: "win32", | |
| 110 | + hostname: "JULIUS-LAPTOP", | |
| 111 | + }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 112 | + | |
| 113 | + expect(result).toBe("JULIUS-LAPTOP"); | |
| 114 | + }), | |
| 115 | + ); | |
| 116 | + | |
| 117 | + it.effect("falls back to the hostname when the friendly-label command is missing", () => | |
| 118 | + Effect.gen(function* () { | |
| 119 | + mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); | |
| 120 | + | |
| 121 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 122 | + cwdBaseName: "t3code", | |
| 123 | + platform: "darwin", | |
| 124 | + hostname: "macbook-pro", | |
| 125 | + }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 126 | + | |
| 127 | + expect(result).toBe("macbook-pro"); | |
| 128 | + }), | |
| 129 | + ); | |
| 130 | + | |
| 131 | + it.effect("falls back to the cwd basename when the hostname is blank", () => | |
| 132 | + Effect.gen(function* () { | |
| 133 | + mockedRunProcess.mockResolvedValueOnce({ | |
| 134 | + stdout: " ", | |
| 135 | + stderr: "", | |
| 136 | + code: 0, | |
| 137 | + signal: null, | |
| 138 | + timedOut: false, | |
| 139 | + }); | |
| 140 | + | |
| 141 | + const result = yield* resolveServerEnvironmentLabel({ | |
| 142 | + cwdBaseName: "t3code", | |
| 143 | + platform: "linux", | |
| 144 | + hostname: " ", | |
| 145 | + }).pipe(Effect.provide(NoopFileSystemLayer)); | |
| 146 | + | |
| 147 | + expect(result).toBe("t3code"); | |
| 148 | + }), | |
| 149 | + ); | |
| 150 | +}); | |
| @@ -0,0 +1,106 @@ | ||
| 1 | +import * as OS from "node:os"; | |
| 2 | + | |
| 3 | +import { Effect, FileSystem } from "effect"; | |
| 4 | + | |
| 5 | +import { runProcess } from "../../processRunner.ts"; | |
| 6 | + | |
| 7 | +interface ResolveServerEnvironmentLabelInput { | |
| 8 | + readonly cwdBaseName: string; | |
| 9 | + readonly platform?: NodeJS.Platform; | |
| 10 | + readonly hostname?: string | null; | |
| 11 | +} | |
| 12 | + | |
| 13 | +function normalizeLabel(value: string | null | undefined): string | null { | |
| 14 | + const trimmed = value?.trim(); | |
| 15 | + return trimmed && trimmed.length > 0 ? trimmed : null; | |
| 16 | +} | |
| 17 | + | |
| 18 | +function parseMachineInfoValue(raw: string, key: string): string | null { | |
| 19 | + for (const line of raw.split(/\r?\n/g)) { | |
| 20 | + const trimmed = line.trim(); | |
| 21 | + if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { | |
| 22 | + continue; | |
| 23 | + } | |
| 24 | + const value = trimmed.slice(key.length + 1).trim(); | |
| 25 | + if ( | |
| 26 | + (value.startsWith('"') && value.endsWith('"')) || | |
| 27 | + (value.startsWith("'") && value.endsWith("'")) | |
| 28 | + ) { | |
| 29 | + return normalizeLabel(value.slice(1, -1)); | |
| 30 | + } | |
| 31 | + return normalizeLabel(value); | |
| 32 | + } | |
| 33 | + return null; | |
| 34 | +} | |
| 35 | + | |
| 36 | +const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { | |
| 37 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 38 | + const exists = yield* fileSystem | |
| 39 | + .exists("/etc/machine-info") | |
| 40 | + .pipe(Effect.orElseSucceed(() => false)); | |
| 41 | + if (!exists) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + return yield* fileSystem | |
| 46 | + .readFileString("/etc/machine-info") | |
| 47 | + .pipe(Effect.orElseSucceed(() => null)); | |
| 48 | +}); | |
| 49 | + | |
| 50 | +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( | |
| 51 | + command: string, | |
| 52 | + args: readonly string[], | |
| 53 | +) { | |
| 54 | + const result = yield* Effect.tryPromise({ | |
| 55 | + try: () => | |
| 56 | + runProcess(command, args, { | |
| 57 | + allowNonZeroExit: true, | |
| 58 | + }), | |
| 59 | + catch: () => null, | |
| 60 | + }).pipe(Effect.orElseSucceed(() => null)); | |
| 61 | + | |
| 62 | + if (!result || result.code !== 0) { | |
| 63 | + return null; | |
| 64 | + } | |
| 65 | + | |
| 66 | + return normalizeLabel(result.stdout); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( | |
| 70 | + platform: NodeJS.Platform, | |
| 71 | +) { | |
| 72 | + if (platform === "darwin") { | |
| 73 | + return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); | |
| 74 | + } | |
| 75 | + | |
| 76 | + if (platform === "linux") { | |
| 77 | + const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); | |
| 78 | + if (machineInfo) { | |
| 79 | + const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); | |
| 80 | + if (prettyHostname) { | |
| 81 | + return prettyHostname; | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); | |
| 86 | + } | |
| 87 | + | |
| 88 | + return null; | |
| 89 | +}); | |
| 90 | + | |
| 91 | +export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( | |
| 92 | + input: ResolveServerEnvironmentLabelInput, | |
| 93 | +) { | |
| 94 | + const platform = input.platform ?? process.platform; | |
| 95 | + const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); | |
| 96 | + if (friendlyHostLabel) { | |
| 97 | + return friendlyHostLabel; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const hostname = normalizeLabel(input.hostname ?? OS.hostname()); | |
| 101 | + if (hostname) { | |
| 102 | + return hostname; | |
| 103 | + } | |
| 104 | + | |
| 105 | + return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; | |
| 106 | +}); | |
| @@ -0,0 +1,12 @@ | ||
| 1 | +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; | |
| 2 | +import { Context } from "effect"; | |
| 3 | +import type { Effect } from "effect"; | |
| 4 | + | |
| 5 | +export interface ServerEnvironmentShape { | |
| 6 | + readonly getEnvironmentId: Effect.Effect<EnvironmentId>; | |
| 7 | + readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export class ServerEnvironment extends Context.Service<ServerEnvironment, ServerEnvironmentShape>()( | |
| 11 | + "t3/environment/Services/ServerEnvironment", | |
| 12 | +) {} | |
| @@ -166,6 +166,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 165 unmodified lines | ||
| 166 | 166 | [ |
| 167 | 167 | "exec", |
| 168 | 168 | "--ephemeral", |
| 169 | + "--skip-git-repo-check", | |
| 169 | 170 | "-s", |
| 170 | 171 | "read-only", |
| 171 | 172 | "--model", |
| @@ -40,6 +40,24 @@ function writeTextFile( · 39 unmodified lines | ||
| 40 | 40 | }); |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | +function removePath( | |
| 44 | + targetPath: string, | |
| 45 | +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 46 | + return Effect.gen(function* () { | |
| 47 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 48 | + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); | |
| 49 | + }); | |
| 50 | +} | |
| 51 | + | |
| 52 | +function makeDirectory( | |
| 53 | + dirPath: string, | |
| 54 | +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 55 | + return Effect.gen(function* () { | |
| 56 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 57 | + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); | |
| 58 | + }); | |
| 59 | +} | |
| 60 | + | |
| 43 | 61 | /** Run a raw git command for test setup (not under test). */ |
| 44 | 62 | function git( |
| 45 | 63 | cwd: string, |
| @@ -219,7 +237,10 @@ it.layer(TestLayer)("git integration", (it) => { · 173 unmodified lines | ||
| 219 | 237 | |
| 220 | 238 | const seenChunks: string[][] = []; |
| 221 | 239 | const core = yield* makeIsolatedGitCore((input) => { |
| 222 | − if (input.args.join(" ") !== "check-ignore --no-index -z --stdin") { | |
| 240 | + if ( | |
| 241 | + input.args.join(" ") !== | |
| 242 | + "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin" | |
| 243 | + ) { | |
| 223 | 244 | return Effect.fail( |
| 224 | 245 | new GitCommandError({ |
| 225 | 246 | operation: input.operation, |
| @@ -252,6 +273,35 @@ it.layer(TestLayer)("git integration", (it) => { · 26 unmodified lines | ||
| 252 | 273 | expect(result).toEqual(expectedPaths); |
| 253 | 274 | }), |
| 254 | 275 | ); |
| 276 | + | |
| 277 | + it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => | |
| 278 | + Effect.gen(function* () { | |
| 279 | + const core = yield* makeIsolatedGitCore((input) => { | |
| 280 | + expect(input.args).toEqual([ | |
| 281 | + "-c", | |
| 282 | + "core.fsmonitor=false", | |
| 283 | + "-c", | |
| 284 | + "core.untrackedCache=false", | |
| 285 | + "ls-files", | |
| 286 | + "--cached", | |
| 287 | + "--others", | |
| 288 | + "--exclude-standard", | |
| 289 | + "-z", | |
| 290 | + ]); | |
| 291 | + return Effect.succeed({ | |
| 292 | + code: 0, | |
| 293 | + stdout: "src/index.ts\0README.md\0", | |
| 294 | + stderr: "", | |
| 295 | + stdoutTruncated: false, | |
| 296 | + stderrTruncated: false, | |
| 297 | + }); | |
| 298 | + }); | |
| 299 | + | |
| 300 | + const result = yield* core.listWorkspaceFiles("/virtual/repo"); | |
| 301 | + expect(result.paths).toEqual(["src/index.ts", "README.md"]); | |
| 302 | + expect(result.truncated).toBe(false); | |
| 303 | + }), | |
| 304 | + ); | |
| 255 | 305 | }); |
| 256 | 306 | |
| 257 | 307 | // ── listGitBranches ── |
| @@ -267,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => { · 9 unmodified lines | ||
| 267 | 317 | }), |
| 268 | 318 | ); |
| 269 | 319 | |
| 320 | + it.effect("returns isRepo: false for deleted directories", () => | |
| 321 | + Effect.gen(function* () { | |
| 322 | + const tmp = yield* makeTmpDir(); | |
| 323 | + const deletedDir = path.join(tmp, "deleted-repo"); | |
| 324 | + yield* makeDirectory(deletedDir); | |
| 325 | + yield* removePath(deletedDir); | |
| 326 | + | |
| 327 | + const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); | |
| 328 | + | |
| 329 | + expect(result.isRepo).toBe(false); | |
| 330 | + expect(result.hasOriginRemote).toBe(false); | |
| 331 | + expect(result.branches).toEqual([]); | |
| 332 | + }), | |
| 333 | + ); | |
| 334 | + | |
| 270 | 335 | it.effect("returns the current branch with current: true", () => |
| 271 | 336 | Effect.gen(function* () { |
| 272 | 337 | const tmp = yield* makeTmpDir(); |
| @@ -617,8 +682,8 @@ it.layer(TestLayer)("git integration", (it) => { · 344 unmodified lines | ||
| 617 | 682 | |
| 618 | 683 | it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => |
| 619 | 684 | Effect.gen(function* () { |
| 620 | − const services = yield* Effect.services(); | |
| 621 | − const runPromise = Effect.runPromiseWith(services); | |
| 685 | + const context = yield* Effect.context<never>(); | |
| 686 | + const runPromise = Effect.runPromiseWith(context); | |
| 622 | 687 | |
| 623 | 688 | const remote = yield* makeTmpDir(); |
| 624 | 689 | const source = yield* makeTmpDir(); |
| @@ -761,7 +826,7 @@ it.layer(TestLayer)("git integration", (it) => { · 136 unmodified lines | ||
| 761 | 826 | }), |
| 762 | 827 | ); |
| 763 | 828 | |
| 764 | − it.effect("shares upstream refreshes across worktrees that use the same git common dir", () => | |
| 829 | + it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => | |
| 765 | 830 | Effect.gen(function* () { |
| 766 | 831 | const ok = (stdout = "") => |
| 767 | 832 | Effect.succeed({ |
| @@ -780,7 +845,9 @@ it.layer(TestLayer)("git integration", (it) => { · 12 unmodified lines | ||
| 780 | 845 | input.args[2] === "--symbolic-full-name" && |
| 781 | 846 | input.args[3] === "@{upstream}" |
| 782 | 847 | ) { |
| 783 | − return ok("origin/main\n"); | |
| 848 | + return ok( | |
| 849 | + input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", | |
| 850 | + ); | |
| 784 | 851 | } |
| 785 | 852 | if (input.args[0] === "remote") { |
| 786 | 853 | return ok("origin\n"); |
| @@ -791,10 +858,22 @@ it.layer(TestLayer)("git integration", (it) => { · 4 unmodified lines | ||
| 791 | 858 | if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { |
| 792 | 859 | fetchCount += 1; |
| 793 | 860 | expect(input.cwd).toBe("/repo"); |
| 861 | + expect(input.args).toEqual([ | |
| 862 | + "--git-dir", | |
| 863 | + "/repo/.git", | |
| 864 | + "fetch", | |
| 865 | + "--quiet", | |
| 866 | + "--no-tags", | |
| 867 | + "origin", | |
| 868 | + ]); | |
| 794 | 869 | return ok(); |
| 795 | 870 | } |
| 796 | 871 | if (input.operation === "GitCore.statusDetails.status") { |
| 797 | − return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); | |
| 872 | + return ok( | |
| 873 | + input.cwd === "/repo/worktrees/pr-123" | |
| 874 | + ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" | |
| 875 | + : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", | |
| 876 | + ); | |
| 798 | 877 | } |
| 799 | 878 | if ( |
| 800 | 879 | input.operation === "GitCore.statusDetails.unstagedNumstat" || |
| @@ -821,70 +900,80 @@ it.layer(TestLayer)("git integration", (it) => { · 20 unmodified lines | ||
| 821 | 900 | }), |
| 822 | 901 | ); |
| 823 | 902 | |
| 824 | − it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => | |
| 825 | − Effect.gen(function* () { | |
| 826 | − const ok = (stdout = "") => | |
| 827 | − Effect.succeed({ | |
| 828 | − code: 0, | |
| 829 | − stdout, | |
| 830 | − stderr: "", | |
| 831 | − stdoutTruncated: false, | |
| 832 | − stderrTruncated: false, | |
| 833 | − }); | |
| 903 | + it.effect( | |
| 904 | + "briefly backs off failed upstream refreshes across sibling worktrees on one remote", | |
| 905 | + () => | |
| 906 | + Effect.gen(function* () { | |
| 907 | + const ok = (stdout = "") => | |
| 908 | + Effect.succeed({ | |
| 909 | + code: 0, | |
| 910 | + stdout, | |
| 911 | + stderr: "", | |
| 912 | + stdoutTruncated: false, | |
| 913 | + stderrTruncated: false, | |
| 914 | + }); | |
| 834 | 915 | |
| 835 | − let fetchCount = 0; | |
| 836 | − const core = yield* makeIsolatedGitCore((input) => { | |
| 837 | − if ( | |
| 838 | − input.args[0] === "rev-parse" && | |
| 839 | − input.args[1] === "--abbrev-ref" && | |
| 840 | − input.args[2] === "--symbolic-full-name" && | |
| 841 | − input.args[3] === "@{upstream}" | |
| 842 | − ) { | |
| 843 | − return ok("origin/main\n"); | |
| 844 | − } | |
| 845 | − if (input.args[0] === "remote") { | |
| 846 | − return ok("origin\n"); | |
| 847 | − } | |
| 848 | − if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { | |
| 849 | − return ok("/repo/.git\n"); | |
| 850 | − } | |
| 851 | − if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | |
| 852 | − fetchCount += 1; | |
| 916 | + let fetchCount = 0; | |
| 917 | + const core = yield* makeIsolatedGitCore((input) => { | |
| 918 | + if ( | |
| 919 | + input.args[0] === "rev-parse" && | |
| 920 | + input.args[1] === "--abbrev-ref" && | |
| 921 | + input.args[2] === "--symbolic-full-name" && | |
| 922 | + input.args[3] === "@{upstream}" | |
| 923 | + ) { | |
| 924 | + return ok( | |
| 925 | + input.cwd === "/repo/worktrees/pr-123" | |
| 926 | + ? "origin/feature/pr-123\n" | |
| 927 | + : "origin/main\n", | |
| 928 | + ); | |
| 929 | + } | |
| 930 | + if (input.args[0] === "remote") { | |
| 931 | + return ok("origin\n"); | |
| 932 | + } | |
| 933 | + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { | |
| 934 | + return ok("/repo/.git\n"); | |
| 935 | + } | |
| 936 | + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | |
| 937 | + fetchCount += 1; | |
| 938 | + return Effect.fail( | |
| 939 | + new GitCommandError({ | |
| 940 | + operation: input.operation, | |
| 941 | + command: `git ${input.args.join(" ")}`, | |
| 942 | + cwd: input.cwd, | |
| 943 | + detail: "simulated fetch timeout", | |
| 944 | + }), | |
| 945 | + ); | |
| 946 | + } | |
| 947 | + if (input.operation === "GitCore.statusDetails.status") { | |
| 948 | + return ok( | |
| 949 | + input.cwd === "/repo/worktrees/pr-123" | |
| 950 | + ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" | |
| 951 | + : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", | |
| 952 | + ); | |
| 953 | + } | |
| 954 | + if ( | |
| 955 | + input.operation === "GitCore.statusDetails.unstagedNumstat" || | |
| 956 | + input.operation === "GitCore.statusDetails.stagedNumstat" | |
| 957 | + ) { | |
| 958 | + return ok(); | |
| 959 | + } | |
| 960 | + if (input.operation === "GitCore.statusDetails.defaultRef") { | |
| 961 | + return ok("refs/remotes/origin/main\n"); | |
| 962 | + } | |
| 853 | 963 | return Effect.fail( |
| 854 | 964 | new GitCommandError({ |
| 855 | 965 | operation: input.operation, |
| 856 | 966 | command: `git ${input.args.join(" ")}`, |
| 857 | 967 | cwd: input.cwd, |
| 858 | − detail: "simulated fetch timeout", | |
| 968 | + detail: "Unexpected git command in refresh failure cooldown test.", | |
| 859 | 969 | }), |
| 860 | 970 | ); |
| 861 | − } | |
| 862 | − if (input.operation === "GitCore.statusDetails.status") { | |
| 863 | − return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); | |
| 864 | − } | |
| 865 | − if ( | |
| 866 | − input.operation === "GitCore.statusDetails.unstagedNumstat" || | |
| 867 | − input.operation === "GitCore.statusDetails.stagedNumstat" | |
| 868 | − ) { | |
| 869 | − return ok(); | |
| 870 | − } | |
| 871 | − if (input.operation === "GitCore.statusDetails.defaultRef") { | |
| 872 | − return ok("refs/remotes/origin/main\n"); | |
| 873 | − } | |
| 874 | − return Effect.fail( | |
| 875 | − new GitCommandError({ | |
| 876 | − operation: input.operation, | |
| 877 | − command: `git ${input.args.join(" ")}`, | |
| 878 | − cwd: input.cwd, | |
| 879 | − detail: "Unexpected git command in refresh failure cooldown test.", | |
| 880 | − }), | |
| 881 | − ); | |
| 882 | − }); | |
| 971 | + }); | |
| 883 | 972 | |
| 884 | − yield* core.statusDetails("/repo/worktrees/main"); | |
| 885 | − yield* core.statusDetails("/repo/worktrees/pr-123"); | |
| 886 | − expect(fetchCount).toBe(1); | |
| 887 | − }), | |
| 973 | + yield* core.statusDetails("/repo/worktrees/main"); | |
| 974 | + yield* core.statusDetails("/repo/worktrees/pr-123"); | |
| 975 | + expect(fetchCount).toBe(1); | |
| 976 | + }), | |
| 888 | 977 | ); |
| 889 | 978 | |
| 890 | 979 | it.effect("throws when branch does not exist", () => |
| @@ -949,11 +1038,12 @@ it.layer(TestLayer)("git integration", (it) => { · 58 unmodified lines | ||
| 949 | 1038 | yield* git(source, ["checkout", defaultBranch]); |
| 950 | 1039 | yield* git(source, ["branch", "-D", featureBranch]); |
| 951 | 1040 | |
| 952 | − yield* (yield* GitCore).checkoutBranch({ | |
| 1041 | + const checkoutResult = yield* (yield* GitCore).checkoutBranch({ | |
| 953 | 1042 | cwd: source, |
| 954 | 1043 | branch: `${remoteName}/${featureBranch}`, |
| 955 | 1044 | }); |
| 956 | 1045 | |
| 1046 | + expect(checkoutResult.branch).toBe("upstream/feature"); | |
| 957 | 1047 | expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); |
| 958 | 1048 | const realGitCore = yield* GitCore; |
| 959 | 1049 | let fetchArgs: readonly string[] | null = null; |
| @@ -981,7 +1071,6 @@ it.layer(TestLayer)("git integration", (it) => { · 21 unmodified lines | ||
| 981 | 1071 | "--quiet", |
| 982 | 1072 | "--no-tags", |
| 983 | 1073 | remoteName, |
| 984 | − `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, | |
| 985 | 1074 | ]); |
| 986 | 1075 | }), |
| 987 | 1076 | ); |
| @@ -1593,6 +1682,37 @@ it.layer(TestLayer)("git integration", (it) => { · 605 unmodified lines | ||
| 1593 | 1682 | }), |
| 1594 | 1683 | ); |
| 1595 | 1684 | |
| 1685 | + it.effect("returns a non-repo status for deleted directories", () => | |
| 1686 | + Effect.gen(function* () { | |
| 1687 | + const tmp = yield* makeTmpDir(); | |
| 1688 | + const deletedDir = path.join(tmp, "deleted-repo"); | |
| 1689 | + yield* makeDirectory(deletedDir); | |
| 1690 | + yield* removePath(deletedDir); | |
| 1691 | + const core = yield* GitCore; | |
| 1692 | + | |
| 1693 | + const status = yield* core.statusDetails(deletedDir); | |
| 1694 | + const localStatus = yield* core.statusDetailsLocal(deletedDir); | |
| 1695 | + | |
| 1696 | + expect(status).toEqual({ | |
| 1697 | + isRepo: false, | |
| 1698 | + hasOriginRemote: false, | |
| 1699 | + isDefaultBranch: false, | |
| 1700 | + branch: null, | |
| 1701 | + upstreamRef: null, | |
| 1702 | + hasWorkingTreeChanges: false, | |
| 1703 | + workingTree: { | |
| 1704 | + files: [], | |
| 1705 | + insertions: 0, | |
| 1706 | + deletions: 0, | |
| 1707 | + }, | |
| 1708 | + hasUpstream: false, | |
| 1709 | + aheadCount: 0, | |
| 1710 | + behindCount: 0, | |
| 1711 | + }); | |
| 1712 | + expect(localStatus).toEqual(status); | |
| 1713 | + }), | |
| 1714 | + ); | |
| 1715 | + | |
| 1596 | 1716 | it.effect("computes ahead count against base branch when no upstream is configured", () => |
| 1597 | 1717 | Effect.gen(function* () { |
| 1598 | 1718 | const tmp = yield* makeTmpDir(); |
| @@ -27,6 +27,7 @@ import { · 26 unmodified lines | ||
| 27 | 27 | type ExecuteGitProgress, |
| 28 | 28 | type GitCommitOptions, |
| 29 | 29 | type GitCoreShape, |
| 30 | + type GitStatusDetails, | |
| 30 | 31 | type ExecuteGitInput, |
| 31 | 32 | type ExecuteGitResult, |
| 32 | 33 | } from "../Services/GitCore.ts"; |
| @@ -47,23 +48,39 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; · 14 unmodified lines | ||
| 47 | 48 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; |
| 48 | 49 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; |
| 49 | 50 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; |
| 51 | +const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ | |
| 52 | + "-c", | |
| 53 | + "core.fsmonitor=false", | |
| 54 | + "-c", | |
| 55 | + "core.untrackedCache=false", | |
| 56 | +] as const; | |
| 50 | 57 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 51 | 58 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 52 | 59 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); |
| 53 | 60 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| 54 | 61 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; |
| 55 | 62 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; |
| 63 | +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({ | |
| 64 | + isRepo: false, | |
| 65 | + hasOriginRemote: false, | |
| 66 | + isDefaultBranch: false, | |
| 67 | + branch: null, | |
| 68 | + upstreamRef: null, | |
| 69 | + hasWorkingTreeChanges: false, | |
| 70 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 71 | + hasUpstream: false, | |
| 72 | + aheadCount: 0, | |
| 73 | + behindCount: 0, | |
| 74 | +}); | |
| 56 | 75 | |
| 57 | 76 | type TraceTailState = { |
| 58 | 77 | processedChars: number; |
| 59 | 78 | remainder: string; |
| 60 | 79 | }; |
| 61 | 80 | |
| 62 | −class StatusUpstreamRefreshCacheKey extends Data.Class<{ | |
| 81 | +class StatusRemoteRefreshCacheKey extends Data.Class<{ | |
| 63 | 82 | gitCommonDir: string; |
| 64 | − upstreamRef: string; | |
| 65 | 83 | remoteName: string; |
| 66 | − upstreamBranch: string; | |
| 67 | 84 | }> {} |
| 68 | 85 | |
| 69 | 86 | interface ExecuteGitOptions { |
| @@ -353,6 +370,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string { · 283 unmodified lines | ||
| 353 | 370 | return `git ${args.join(" ")}`; |
| 354 | 371 | } |
| 355 | 372 | |
| 373 | +function isMissingGitCwdError(error: GitCommandError): boolean { | |
| 374 | + const normalized = `${error.detail}\n${error.message}`.toLowerCase(); | |
| 375 | + return ( | |
| 376 | + normalized.includes("no such file or directory") || | |
| 377 | + normalized.includes("notfound: filesystem.access") || | |
| 378 | + normalized.includes("enoent") || | |
| 379 | + normalized.includes("not a directory") | |
| 380 | + ); | |
| 381 | +} | |
| 382 | + | |
| 356 | 383 | function toGitCommandError( |
| 357 | 384 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, |
| 358 | 385 | detail: string, |
| @@ -890,17 +917,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 531 unmodified lines | ||
| 890 | 917 | ); |
| 891 | 918 | }); |
| 892 | 919 | |
| 893 | − const fetchUpstreamRefForStatus = ( | |
| 920 | + const fetchRemoteForStatus = ( | |
| 894 | 921 | gitCommonDir: string, |
| 895 | − upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 922 | + remoteName: string, | |
| 896 | 923 | ): Effect.Effect<void, GitCommandError> => { |
| 897 | − const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 898 | 924 | const fetchCwd = |
| 899 | 925 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; |
| 900 | 926 | return executeGit( |
| 901 | − "GitCore.fetchUpstreamRefForStatus", | |
| 927 | + "GitCore.fetchRemoteForStatus", | |
| 902 | 928 | fetchCwd, |
| 903 | − ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 929 | + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], | |
| 904 | 930 | { |
| 905 | 931 | allowNonZeroExit: true, |
| 906 | 932 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), |
| @@ -916,20 +942,15 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 9 unmodified lines | ||
| 916 | 942 | return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); |
| 917 | 943 | }); |
| 918 | 944 | |
| 919 | − const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( | |
| 920 | − cacheKey: StatusUpstreamRefreshCacheKey, | |
| 945 | + const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( | |
| 946 | + cacheKey: StatusRemoteRefreshCacheKey, | |
| 921 | 947 | ) { |
| 922 | − yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, { | |
| 923 | − upstreamRef: cacheKey.upstreamRef, | |
| 924 | − remoteName: cacheKey.remoteName, | |
| 925 | − upstreamBranch: cacheKey.upstreamBranch, | |
| 926 | − }); | |
| 948 | + yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); | |
| 927 | 949 | return true as const; |
| 928 | 950 | }); |
| 929 | 951 | |
| 930 | − const statusUpstreamRefreshCache = yield* Cache.makeWith({ | |
| 952 | + const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { | |
| 931 | 953 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, |
| 932 | − lookup: refreshStatusUpstreamCacheEntry, | |
| 933 | 954 | // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. |
| 934 | 955 | timeToLive: (exit) => |
| 935 | 956 | Exit.isSuccess(exit) |
| @@ -944,12 +965,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 8 unmodified lines | ||
| 944 | 965 | if (!upstream) return; |
| 945 | 966 | const gitCommonDir = yield* resolveGitCommonDir(cwd); |
| 946 | 967 | yield* Cache.get( |
| 947 | − statusUpstreamRefreshCache, | |
| 948 | − new StatusUpstreamRefreshCacheKey({ | |
| 968 | + statusRemoteRefreshCache, | |
| 969 | + new StatusRemoteRefreshCacheKey({ | |
| 949 | 970 | gitCommonDir, |
| 950 | − upstreamRef: upstream.upstreamRef, | |
| 951 | 971 | remoteName: upstream.remoteName, |
| 952 | − upstreamBranch: upstream.upstreamBranch, | |
| 953 | 972 | }), |
| 954 | 973 | ); |
| 955 | 974 | }); |
| @@ -1177,9 +1196,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 221 unmodified lines | ||
| 1177 | 1196 | return branchLastCommit; |
| 1178 | 1197 | }); |
| 1179 | 1198 | |
| 1180 | − const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { | |
| 1181 | − yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); | |
| 1182 | − | |
| 1199 | + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { | |
| 1183 | 1200 | const statusResult = yield* executeGit( |
| 1184 | 1201 | "GitCore.statusDetails.status", |
| 1185 | 1202 | cwd, |
| @@ -1187,7 +1204,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line | ||
| 1187 | 1204 | { |
| 1188 | 1205 | allowNonZeroExit: true, |
| 1189 | 1206 | }, |
| 1190 | − ); | |
| 1207 | + ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); | |
| 1208 | + | |
| 1209 | + if (statusResult === null) { | |
| 1210 | + return NON_REPOSITORY_STATUS_DETAILS; | |
| 1211 | + } | |
| 1191 | 1212 | |
| 1192 | 1213 | if (statusResult.code !== 0) { |
| 1193 | 1214 | const stderr = statusResult.stderr.trim(); |
| @@ -1312,6 +1333,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 118 unmodified lines | ||
| 1312 | 1333 | }; |
| 1313 | 1334 | }); |
| 1314 | 1335 | |
| 1336 | + const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( | |
| 1337 | + function* (cwd) { | |
| 1338 | + return yield* readStatusDetailsLocal(cwd); | |
| 1339 | + }, | |
| 1340 | + ); | |
| 1341 | + | |
| 1342 | + const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { | |
| 1343 | + yield* refreshStatusUpstreamIfStale(cwd).pipe( | |
| 1344 | + Effect.catchIf(isMissingGitCwdError, () => Effect.void), | |
| 1345 | + Effect.ignoreCause({ log: true }), | |
| 1346 | + ); | |
| 1347 | + return yield* readStatusDetailsLocal(cwd); | |
| 1348 | + }); | |
| 1349 | + | |
| 1315 | 1350 | const status: GitCoreShape["status"] = (input) => |
| 1316 | 1351 | statusDetails(input.cwd).pipe( |
| 1317 | 1352 | Effect.map((details) => ({ |
| @@ -1610,7 +1645,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 292 unmodified lines | ||
| 1610 | 1645 | executeGit( |
| 1611 | 1646 | "GitCore.listWorkspaceFiles", |
| 1612 | 1647 | cwd, |
| 1613 | − ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], | |
| 1648 | + [ | |
| 1649 | + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, | |
| 1650 | + "ls-files", | |
| 1651 | + "--cached", | |
| 1652 | + "--others", | |
| 1653 | + "--exclude-standard", | |
| 1654 | + "-z", | |
| 1655 | + ], | |
| 1614 | 1656 | { |
| 1615 | 1657 | allowNonZeroExit: true, |
| 1616 | 1658 | timeoutMs: 20_000, |
| @@ -1628,7 +1670,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 11 unmodified lines | ||
| 1628 | 1670 | createGitCommandError( |
| 1629 | 1671 | "GitCore.listWorkspaceFiles", |
| 1630 | 1672 | cwd, |
| 1631 | − ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], | |
| 1673 | + [ | |
| 1674 | + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, | |
| 1675 | + "ls-files", | |
| 1676 | + "--cached", | |
| 1677 | + "--others", | |
| 1678 | + "--exclude-standard", | |
| 1679 | + "-z", | |
| 1680 | + ], | |
| 1632 | 1681 | result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed", |
| 1633 | 1682 | ), |
| 1634 | 1683 | ), |
| @@ -1648,7 +1697,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 13 unmodified lines | ||
| 1648 | 1697 | const result = yield* executeGit( |
| 1649 | 1698 | "GitCore.filterIgnoredPaths", |
| 1650 | 1699 | cwd, |
| 1651 | − ["check-ignore", "--no-index", "-z", "--stdin"], | |
| 1700 | + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | |
| 1652 | 1701 | { |
| 1653 | 1702 | stdin: `${chunk.join("\0")}\0`, |
| 1654 | 1703 | allowNonZeroExit: true, |
| @@ -1662,7 +1711,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 7 unmodified lines | ||
| 1662 | 1711 | return yield* createGitCommandError( |
| 1663 | 1712 | "GitCore.filterIgnoredPaths", |
| 1664 | 1713 | cwd, |
| 1665 | − ["check-ignore", "--no-index", "-z", "--stdin"], | |
| 1714 | + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | |
| 1666 | 1715 | result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed", |
| 1667 | 1716 | ); |
| 1668 | 1717 | } |
| @@ -1691,6 +1740,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 22 unmodified lines | ||
| 1691 | 1740 | timeoutMs: 10_000, |
| 1692 | 1741 | allowNonZeroExit: true, |
| 1693 | 1742 | }, |
| 1743 | + ).pipe( | |
| 1744 | + Effect.catchIf(isMissingGitCwdError, () => | |
| 1745 | + Effect.succeed({ | |
| 1746 | + code: 128, | |
| 1747 | + stdout: "", | |
| 1748 | + stderr: "fatal: not a git repository", | |
| 1749 | + stdoutTruncated: false, | |
| 1750 | + stderrTruncated: false, | |
| 1751 | + }), | |
| 1752 | + ), | |
| 1694 | 1753 | ); |
| 1695 | 1754 | |
| 1696 | 1755 | if (localBranchResult.code !== 0) { |
| @@ -1973,7 +2032,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 276 unmodified lines | ||
| 1973 | 2032 | "GitCore.removeWorktree", |
| 1974 | 2033 | input.cwd, |
| 1975 | 2034 | args, |
| 1976 | − `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, | |
| 2035 | + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, | |
| 1977 | 2036 | error, |
| 1978 | 2037 | ), |
| 1979 | 2038 | ), |
| @@ -2000,12 +2059,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 20 unmodified lines | ||
| 2000 | 2059 | return { branch: targetBranch }; |
| 2001 | 2060 | }); |
| 2002 | 2061 | |
| 2003 | − const createBranch: GitCoreShape["createBranch"] = (input) => | |
| 2004 | − executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | |
| 2005 | − timeoutMs: 10_000, | |
| 2006 | − fallbackErrorMessage: "git branch create failed", | |
| 2007 | − }).pipe(Effect.asVoid); | |
| 2008 | − | |
| 2009 | 2062 | const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( |
| 2010 | 2063 | function* (input) { |
| 2011 | 2064 | const [localInputExists, remoteExists] = yield* Effect.all( |
| @@ -2078,9 +2131,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 66 unmodified lines | ||
| 2078 | 2131 | timeoutMs: 10_000, |
| 2079 | 2132 | fallbackErrorMessage: "git checkout failed", |
| 2080 | 2133 | }); |
| 2134 | + | |
| 2135 | + const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ | |
| 2136 | + "branch", | |
| 2137 | + "--show-current", | |
| 2138 | + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); | |
| 2139 | + | |
| 2140 | + return { branch }; | |
| 2081 | 2141 | }, |
| 2082 | 2142 | ); |
| 2083 | 2143 | |
| 2144 | + const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { | |
| 2145 | + yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | |
| 2146 | + timeoutMs: 10_000, | |
| 2147 | + fallbackErrorMessage: "git branch create failed", | |
| 2148 | + }); | |
| 2149 | + if (input.checkout) { | |
| 2150 | + yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); | |
| 2151 | + } | |
| 2152 | + | |
| 2153 | + return { branch: input.branch }; | |
| 2154 | + }); | |
| 2155 | + | |
| 2084 | 2156 | const initRepo: GitCoreShape["initRepo"] = (input) => |
| 2085 | 2157 | executeGit("GitCore.initRepo", input.cwd, ["init"], { |
| 2086 | 2158 | timeoutMs: 10_000, |
| @@ -2106,6 +2178,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 19 unmodified lines | ||
| 2106 | 2178 | execute, |
| 2107 | 2179 | status, |
| 2108 | 2180 | statusDetails, |
| 2181 | + statusDetailsLocal, | |
| 2109 | 2182 | prepareCommitContext, |
| 2110 | 2183 | commit, |
| 2111 | 2184 | pushCurrentBranch, |
| @@ -6,7 +6,7 @@ vi.mock("../../processRunner", () => ({ · 5 unmodified lines | ||
| 6 | 6 | runProcess: vi.fn(), |
| 7 | 7 | })); |
| 8 | 8 | |
| 9 | −import { runProcess } from "../../processRunner"; | |
| 9 | +import { runProcess } from "../../processRunner.ts"; | |
| 10 | 10 | import { GitHubCli } from "../Services/GitHubCli.ts"; |
| 11 | 11 | import { GitHubCliLive } from "./GitHubCli.ts"; |
| 12 | 12 | |
| @@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { · 63 unmodified lines | ||
| 76 | 76 | }), |
| 77 | 77 | ); |
| 78 | 78 | |
| 79 | + it.effect("trims pull request fields decoded from gh json", () => | |
| 80 | + Effect.gen(function* () { | |
| 81 | + mockedRunProcess.mockResolvedValueOnce({ | |
| 82 | + stdout: JSON.stringify({ | |
| 83 | + number: 42, | |
| 84 | + title: " Add PR thread creation \n", | |
| 85 | + url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", | |
| 86 | + baseRefName: " main ", | |
| 87 | + headRefName: "\tfeature/pr-threads\t", | |
| 88 | + state: "OPEN", | |
| 89 | + mergedAt: null, | |
| 90 | + isCrossRepository: true, | |
| 91 | + headRepository: { | |
| 92 | + nameWithOwner: " octocat/codething-mvp ", | |
| 93 | + }, | |
| 94 | + headRepositoryOwner: { | |
| 95 | + login: " octocat ", | |
| 96 | + }, | |
| 97 | + }), | |
| 98 | + stderr: "", | |
| 99 | + code: 0, | |
| 100 | + signal: null, | |
| 101 | + timedOut: false, | |
| 102 | + }); | |
| 103 | + | |
| 104 | + const result = yield* Effect.gen(function* () { | |
| 105 | + const gh = yield* GitHubCli; | |
| 106 | + return yield* gh.getPullRequest({ | |
| 107 | + cwd: "/repo", | |
| 108 | + reference: "#42", | |
| 109 | + }); | |
| 110 | + }); | |
| 111 | + | |
| 112 | + assert.deepStrictEqual(result, { | |
| 113 | + number: 42, | |
| 114 | + title: "Add PR thread creation", | |
| 115 | + url: "https://github.com/pingdotgg/codething-mvp/pull/42", | |
| 116 | + baseRefName: "main", | |
| 117 | + headRefName: "feature/pr-threads", | |
| 118 | + state: "open", | |
| 119 | + isCrossRepository: true, | |
| 120 | + headRepositoryNameWithOwner: "octocat/codething-mvp", | |
| 121 | + headRepositoryOwnerLogin: "octocat", | |
| 122 | + }); | |
| 123 | + }), | |
| 124 | + ); | |
| 125 | + | |
| 126 | + it.effect("skips invalid entries when parsing pr lists", () => | |
| 127 | + Effect.gen(function* () { | |
| 128 | + mockedRunProcess.mockResolvedValueOnce({ | |
| 129 | + stdout: JSON.stringify([ | |
| 130 | + { | |
| 131 | + number: 0, | |
| 132 | + title: "invalid", | |
| 133 | + url: "https://github.com/pingdotgg/codething-mvp/pull/0", | |
| 134 | + baseRefName: "main", | |
| 135 | + headRefName: "feature/invalid", | |
| 136 | + }, | |
| 137 | + { | |
| 138 | + number: 43, | |
| 139 | + title: " Valid PR ", | |
| 140 | + url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", | |
| 141 | + baseRefName: " main ", | |
| 142 | + headRefName: " feature/pr-list ", | |
| 143 | + headRepository: { | |
| 144 | + nameWithOwner: " ", | |
| 145 | + }, | |
| 146 | + headRepositoryOwner: { | |
| 147 | + login: " ", | |
| 148 | + }, | |
| 149 | + }, | |
| 150 | + ]), | |
| 151 | + stderr: "", | |
| 152 | + code: 0, | |
| 153 | + signal: null, | |
| 154 | + timedOut: false, | |
| 155 | + }); | |
| 156 | + | |
| 157 | + const result = yield* Effect.gen(function* () { | |
| 158 | + const gh = yield* GitHubCli; | |
| 159 | + return yield* gh.listOpenPullRequests({ | |
| 160 | + cwd: "/repo", | |
| 161 | + headSelector: "feature/pr-list", | |
| 162 | + }); | |
| 163 | + }); | |
| 164 | + | |
| 165 | + assert.deepStrictEqual(result, [ | |
| 166 | + { | |
| 167 | + number: 43, | |
| 168 | + title: "Valid PR", | |
| 169 | + url: "https://github.com/pingdotgg/codething-mvp/pull/43", | |
| 170 | + baseRefName: "main", | |
| 171 | + headRefName: "feature/pr-list", | |
| 172 | + state: "open", | |
| 173 | + }, | |
| 174 | + ]); | |
| 175 | + }), | |
| 176 | + ); | |
| 177 | + | |
| 79 | 178 | it.effect("reads repository clone URLs", () => |
| 80 | 179 | Effect.gen(function* () { |
| 81 | 180 | mockedRunProcess.mockResolvedValueOnce({ |
| @@ -1,14 +1,18 @@ | ||
| 1 | −import { Effect, Layer, Schema } from "effect"; | |
| 2 | −import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 1 | +import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | |
| 2 | +import { TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | 3 | |
| 4 | −import { runProcess } from "../../processRunner"; | |
| 4 | +import { runProcess } from "../../processRunner.ts"; | |
| 5 | 5 | import { GitHubCliError } from "@t3tools/contracts"; |
| 6 | 6 | import { |
| 7 | 7 | GitHubCli, |
| 8 | 8 | type GitHubRepositoryCloneUrls, |
| 9 | 9 | type GitHubCliShape, |
| 10 | − type GitHubPullRequestSummary, | |
| 11 | 10 | } from "../Services/GitHubCli.ts"; |
| 11 | +import { | |
| 12 | + decodeGitHubPullRequestJson, | |
| 13 | + decodeGitHubPullRequestListJson, | |
| 14 | + formatGitHubJsonDecodeError, | |
| 15 | +} from "../githubPullRequests.ts"; | |
| 12 | 16 | |
| 13 | 17 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 14 | 18 | |
| @@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown · 48 unmodified lines | ||
| 63 | 67 | }); |
| 64 | 68 | } |
| 65 | 69 | |
| 66 | −function normalizePullRequestState(input: { | |
| 67 | − state?: string | null | undefined; | |
| 68 | − mergedAt?: string | null | undefined; | |
| 69 | −}): "open" | "closed" | "merged" { | |
| 70 | − const mergedAt = input.mergedAt; | |
| 71 | − const state = input.state; | |
| 72 | − if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { | |
| 73 | − return "merged"; | |
| 74 | − } | |
| 75 | − if (state === "CLOSED") { | |
| 76 | − return "closed"; | |
| 77 | − } | |
| 78 | − return "open"; | |
| 79 | −} | |
| 80 | − | |
| 81 | −const RawGitHubPullRequestSchema = Schema.Struct({ | |
| 82 | − number: PositiveInt, | |
| 83 | − title: TrimmedNonEmptyString, | |
| 84 | − url: TrimmedNonEmptyString, | |
| 85 | − baseRefName: TrimmedNonEmptyString, | |
| 86 | − headRefName: TrimmedNonEmptyString, | |
| 87 | − state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 88 | − mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 89 | − isCrossRepository: Schema.optional(Schema.Boolean), | |
| 90 | − headRepository: Schema.optional( | |
| 91 | − Schema.NullOr( | |
| 92 | − Schema.Struct({ | |
| 93 | − nameWithOwner: Schema.String, | |
| 94 | − }), | |
| 95 | − ), | |
| 96 | − ), | |
| 97 | − headRepositoryOwner: Schema.optional( | |
| 98 | − Schema.NullOr( | |
| 99 | − Schema.Struct({ | |
| 100 | − login: Schema.String, | |
| 101 | − }), | |
| 102 | − ), | |
| 103 | − ), | |
| 104 | −}); | |
| 105 | − | |
| 106 | 70 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ |
| 107 | 71 | nameWithOwner: TrimmedNonEmptyString, |
| 108 | 72 | url: TrimmedNonEmptyString, |
| 109 | 73 | sshUrl: TrimmedNonEmptyString, |
| 110 | 74 | }); |
| 111 | 75 | |
| 112 | −function normalizePullRequestSummary( | |
| 113 | − raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>, | |
| 114 | −): GitHubPullRequestSummary { | |
| 115 | − const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null; | |
| 116 | − const headRepositoryOwnerLogin = | |
| 117 | − raw.headRepositoryOwner?.login ?? | |
| 118 | − (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") | |
| 119 | − ? (headRepositoryNameWithOwner.split("/")[0] ?? null) | |
| 120 | − : null); | |
| 121 | − return { | |
| 122 | − number: raw.number, | |
| 123 | − title: raw.title, | |
| 124 | − url: raw.url, | |
| 125 | − baseRefName: raw.baseRefName, | |
| 126 | − headRefName: raw.headRefName, | |
| 127 | − state: normalizePullRequestState(raw), | |
| 128 | − ...(typeof raw.isCrossRepository === "boolean" | |
| 129 | − ? { isCrossRepository: raw.isCrossRepository } | |
| 130 | − : {}), | |
| 131 | − ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 132 | − ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 133 | − }; | |
| 134 | −} | |
| 135 | − | |
| 136 | 76 | function normalizeRepositoryCloneUrls( |
| 137 | 77 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, |
| 138 | 78 | ): GitHubRepositoryCloneUrls { |
| @@ -154,7 +94,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 15 unmodified lines | ||
| 154 | 94 | (error) => |
| 155 | 95 | new GitHubCliError({ |
| 156 | 96 | operation, |
| 157 | − detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail, | |
| 97 | + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | |
| 158 | 98 | cause: error, |
| 159 | 99 | }), |
| 160 | 100 | ), |
| @@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { · 33 unmodified lines | ||
| 194 | 134 | Effect.flatMap((raw) => |
| 195 | 135 | raw.length === 0 |
| 196 | 136 | ? Effect.succeed([]) |
| 197 | − : decodeGitHubJson( | |
| 198 | − raw, | |
| 199 | − Schema.Array(RawGitHubPullRequestSchema), | |
| 200 | − "listOpenPullRequests", | |
| 201 | − "GitHub CLI returned invalid PR list JSON.", | |
| 137 | + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | |
| 138 | + Effect.flatMap((decoded) => { | |
| 139 | + if (!Result.isSuccess(decoded)) { | |
| 140 | + return Effect.fail( | |
| 141 | + new GitHubCliError({ | |
| 142 | + operation: "listOpenPullRequests", | |
| 143 | + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | |
| 144 | + cause: decoded.failure, | |
| 145 | + }), | |
| 146 | + ); | |
| 147 | + } | |
| 148 | + | |
| 149 | + return Effect.succeed( | |
| 150 | + decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), | |
| 151 | + ); | |
| 152 | + }), | |
| 202 | 153 | ), |
| 203 | 154 | ), |
| 204 | − Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)), | |
| 205 | 155 | ), |
| 206 | 156 | getPullRequest: (input) => |
| 207 | 157 | execute({ |
| @@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { · 8 unmodified lines | ||
| 216 | 166 | }).pipe( |
| 217 | 167 | Effect.map((result) => result.stdout.trim()), |
| 218 | 168 | Effect.flatMap((raw) => |
| 219 | − decodeGitHubJson( | |
| 220 | − raw, | |
| 221 | − RawGitHubPullRequestSchema, | |
| 222 | − "getPullRequest", | |
| 223 | − "GitHub CLI returned invalid pull request JSON.", | |
| 169 | + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | |
| 170 | + Effect.flatMap((decoded) => { | |
| 171 | + if (!Result.isSuccess(decoded)) { | |
| 172 | + return Effect.fail( | |
| 173 | + new GitHubCliError({ | |
| 174 | + operation: "getPullRequest", | |
| 175 | + detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | |
| 176 | + cause: decoded.failure, | |
| 177 | + }), | |
| 178 | + ); | |
| 179 | + } | |
| 180 | + | |
| 181 | + return Effect.succeed( | |
| 182 | + (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), | |
| 183 | + ); | |
| 184 | + }), | |
| 224 | 185 | ), |
| 225 | 186 | ), |
| 226 | − Effect.map(normalizePullRequestSummary), | |
| 227 | 187 | ), |
| 228 | 188 | getRepositoryCloneUrls: (input) => |
| 229 | 189 | execute({ |
| @@ -186,6 +186,24 @@ function makeTempDir( · 185 unmodified lines | ||
| 186 | 186 | }); |
| 187 | 187 | } |
| 188 | 188 | |
| 189 | +function removePath( | |
| 190 | + targetPath: string, | |
| 191 | +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 192 | + return Effect.gen(function* () { | |
| 193 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 194 | + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); | |
| 195 | + }); | |
| 196 | +} | |
| 197 | + | |
| 198 | +function makeDirectory( | |
| 199 | + dirPath: string, | |
| 200 | +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | |
| 201 | + return Effect.gen(function* () { | |
| 202 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 203 | + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); | |
| 204 | + }); | |
| 205 | +} | |
| 206 | + | |
| 189 | 207 | function runGit( |
| 190 | 208 | cwd: string, |
| 191 | 209 | args: readonly string[], |
| @@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 502 unmodified lines | ||
| 694 | 712 | }), |
| 695 | 713 | ); |
| 696 | 714 | |
| 715 | + it.effect("status trims PR metadata returned by gh before publishing it", () => | |
| 716 | + Effect.gen(function* () { | |
| 717 | + const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 718 | + yield* initRepo(repoDir); | |
| 719 | + yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); | |
| 720 | + const remoteDir = yield* createBareRemote(); | |
| 721 | + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 722 | + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); | |
| 723 | + | |
| 724 | + const { manager } = yield* makeManager({ | |
| 725 | + ghScenario: { | |
| 726 | + prListSequence: [ | |
| 727 | + JSON.stringify([ | |
| 728 | + { | |
| 729 | + number: 14, | |
| 730 | + title: " Existing PR title \n", | |
| 731 | + url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", | |
| 732 | + baseRefName: " main ", | |
| 733 | + headRefName: "\tfeature/status-trimmed-pr\t", | |
| 734 | + }, | |
| 735 | + ]), | |
| 736 | + ], | |
| 737 | + }, | |
| 738 | + }); | |
| 739 | + | |
| 740 | + const status = yield* manager.status({ cwd: repoDir }); | |
| 741 | + | |
| 742 | + expect(status.pr).toEqual({ | |
| 743 | + number: 14, | |
| 744 | + title: "Existing PR title", | |
| 745 | + url: "https://github.com/pingdotgg/codething-mvp/pull/14", | |
| 746 | + baseBranch: "main", | |
| 747 | + headBranch: "feature/status-trimmed-pr", | |
| 748 | + state: "open", | |
| 749 | + }); | |
| 750 | + }), | |
| 751 | + ); | |
| 752 | + | |
| 753 | + it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => | |
| 754 | + Effect.gen(function* () { | |
| 755 | + const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 756 | + yield* initRepo(repoDir); | |
| 757 | + yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); | |
| 758 | + const remoteDir = yield* createBareRemote(); | |
| 759 | + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 760 | + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); | |
| 761 | + | |
| 762 | + const { manager } = yield* makeManager({ | |
| 763 | + ghScenario: { | |
| 764 | + prListSequence: [ | |
| 765 | + JSON.stringify([ | |
| 766 | + { | |
| 767 | + number: 0, | |
| 768 | + title: "invalid", | |
| 769 | + url: "https://github.com/pingdotgg/codething-mvp/pull/0", | |
| 770 | + baseRefName: "main", | |
| 771 | + headRefName: "feature/invalid", | |
| 772 | + }, | |
| 773 | + { | |
| 774 | + number: 15, | |
| 775 | + title: " Valid PR title ", | |
| 776 | + url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", | |
| 777 | + baseRefName: " main ", | |
| 778 | + headRefName: "\tfeature/status-valid-pr-entry\t", | |
| 779 | + headRepository: { | |
| 780 | + nameWithOwner: " ", | |
| 781 | + }, | |
| 782 | + headRepositoryOwner: { | |
| 783 | + login: " ", | |
| 784 | + }, | |
| 785 | + }, | |
| 786 | + ]), | |
| 787 | + ], | |
| 788 | + }, | |
| 789 | + }); | |
| 790 | + | |
| 791 | + const status = yield* manager.status({ cwd: repoDir }); | |
| 792 | + | |
| 793 | + expect(status.pr).toEqual({ | |
| 794 | + number: 15, | |
| 795 | + title: "Valid PR title", | |
| 796 | + url: "https://github.com/pingdotgg/codething-mvp/pull/15", | |
| 797 | + baseBranch: "main", | |
| 798 | + headBranch: "feature/status-valid-pr-entry", | |
| 799 | + state: "open", | |
| 800 | + }); | |
| 801 | + }), | |
| 802 | + ); | |
| 803 | + | |
| 804 | + it.effect("status preserves lowercase merged and closed PR states from gh json", () => | |
| 805 | + Effect.gen(function* () { | |
| 806 | + const repoDir = yield* makeTempDir("t3code-git-manager-"); | |
| 807 | + yield* initRepo(repoDir); | |
| 808 | + yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); | |
| 809 | + const remoteDir = yield* createBareRemote(); | |
| 810 | + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 811 | + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); | |
| 812 | + | |
| 813 | + const { manager } = yield* makeManager({ | |
| 814 | + ghScenario: { | |
| 815 | + prListSequence: [ | |
| 816 | + JSON.stringify([ | |
| 817 | + { | |
| 818 | + number: 16, | |
| 819 | + title: "Closed PR", | |
| 820 | + url: "https://github.com/pingdotgg/codething-mvp/pull/16", | |
| 821 | + baseRefName: "main", | |
| 822 | + headRefName: "feature/status-lowercase-state", | |
| 823 | + state: "closed", | |
| 824 | + updatedAt: "2026-01-01T00:00:00.000Z", | |
| 825 | + }, | |
| 826 | + { | |
| 827 | + number: 17, | |
| 828 | + title: "Merged PR", | |
| 829 | + url: "https://github.com/pingdotgg/codething-mvp/pull/17", | |
| 830 | + baseRefName: "main", | |
| 831 | + headRefName: "feature/status-lowercase-state", | |
| 832 | + state: "merged", | |
| 833 | + updatedAt: "2026-01-02T00:00:00.000Z", | |
| 834 | + }, | |
| 835 | + ]), | |
| 836 | + ], | |
| 837 | + }, | |
| 838 | + }); | |
| 839 | + | |
| 840 | + const status = yield* manager.status({ cwd: repoDir }); | |
| 841 | + | |
| 842 | + expect(status.pr).toEqual({ | |
| 843 | + number: 17, | |
| 844 | + title: "Merged PR", | |
| 845 | + url: "https://github.com/pingdotgg/codething-mvp/pull/17", | |
| 846 | + baseBranch: "main", | |
| 847 | + headBranch: "feature/status-lowercase-state", | |
| 848 | + state: "merged", | |
| 849 | + }); | |
| 850 | + }), | |
| 851 | + ); | |
| 852 | + | |
| 697 | 853 | it.effect("status returns an explicit non-repo result for non-git directories", () => |
| 698 | 854 | Effect.gen(function* () { |
| 699 | 855 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); |
| @@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 20 unmodified lines | ||
| 720 | 876 | }), |
| 721 | 877 | ); |
| 722 | 878 | |
| 879 | + it.effect("status returns an explicit non-repo result for deleted directories", () => | |
| 880 | + Effect.gen(function* () { | |
| 881 | + const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); | |
| 882 | + const cwd = path.join(rootDir, "deleted-repo"); | |
| 883 | + yield* makeDirectory(cwd); | |
| 884 | + yield* removePath(cwd); | |
| 885 | + const { manager } = yield* makeManager(); | |
| 886 | + | |
| 887 | + const status = yield* manager.status({ cwd }); | |
| 888 | + | |
| 889 | + expect(status).toEqual({ | |
| 890 | + isRepo: false, | |
| 891 | + hasOriginRemote: false, | |
| 892 | + isDefaultBranch: false, | |
| 893 | + branch: null, | |
| 894 | + hasWorkingTreeChanges: false, | |
| 895 | + workingTree: { | |
| 896 | + files: [], | |
| 897 | + insertions: 0, | |
| 898 | + deletions: 0, | |
| 899 | + }, | |
| 900 | + hasUpstream: false, | |
| 901 | + aheadCount: 0, | |
| 902 | + behindCount: 0, | |
| 903 | + pr: null, | |
| 904 | + }); | |
| 905 | + }), | |
| 906 | + ); | |
| 907 | + | |
| 723 | 908 | it.effect("status briefly caches repeated lookups for the same cwd", () => |
| 724 | 909 | Effect.gen(function* () { |
| 725 | 910 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| @@ -854,7 +1039,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 128 unmodified lines | ||
| 854 | 1039 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", |
| 855 | 1040 | ); |
| 856 | 1041 | }), |
| 857 | − 12_000, | |
| 1042 | + 20_000, | |
| 858 | 1043 | ); |
| 859 | 1044 | |
| 860 | 1045 | it.effect( |
| @@ -962,7 +1147,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines | ||
| 962 | 1147 | ), |
| 963 | 1148 | ).toBe(false); |
| 964 | 1149 | }), |
| 965 | − 12_000, | |
| 1150 | + 20_000, | |
| 966 | 1151 | ); |
| 967 | 1152 | |
| 968 | 1153 | it.effect("status returns merged PR state when latest PR was merged", () => |
| @@ -1685,7 +1870,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines | ||
| 1685 | 1870 | false, |
| 1686 | 1871 | ); |
| 1687 | 1872 | }), |
| 1688 | − 12_000, | |
| 1873 | + 20_000, | |
| 1689 | 1874 | ); |
| 1690 | 1875 | |
| 1691 | 1876 | it.effect( |
| @@ -1,16 +1,31 @@ | ||
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { realpathSync } from "node:fs"; |
| 3 | 3 | |
| 4 | −import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; | |
| 4 | +import { | |
| 5 | + Cache, | |
| 6 | + Duration, | |
| 7 | + Effect, | |
| 8 | + Exit, | |
| 9 | + FileSystem, | |
| 10 | + Layer, | |
| 11 | + Option, | |
| 12 | + Path, | |
| 13 | + Ref, | |
| 14 | + Result, | |
| 15 | +} from "effect"; | |
| 5 | 16 | import { |
| 6 | 17 | GitActionProgressEvent, |
| 7 | 18 | GitActionProgressPhase, |
| 8 | 19 | GitCommandError, |
| 9 | 20 | GitRunStackedActionResult, |
| 10 | 21 | GitStackedAction, |
| 22 | + type GitStatusLocalResult, | |
| 23 | + type GitStatusRemoteResult, | |
| 11 | 24 | ModelSelection, |
| 12 | 25 | } from "@t3tools/contracts"; |
| 13 | 26 | import { |
| 27 | + detectGitHostingProviderFromRemoteUrl, | |
| 28 | + mergeGitStatusParts, | |
| 14 | 29 | resolveAutoFeatureBranchName, |
| 15 | 30 | sanitizeBranchFragment, |
| 16 | 31 | sanitizeFeatureBranchName, |
| @@ -23,13 +38,18 @@ import { · 6 unmodified lines | ||
| 23 | 38 | type GitManagerShape, |
| 24 | 39 | type GitRunStackedActionOptions, |
| 25 | 40 | } from "../Services/GitManager.ts"; |
| 26 | −import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; | |
| 41 | +import { GitCore } from "../Services/GitCore.ts"; | |
| 42 | +import type { GitStatusDetails } from "../Services/GitCore.ts"; | |
| 27 | 43 | import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; |
| 28 | 44 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 29 | 45 | import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; |
| 30 | 46 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; |
| 31 | 47 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 32 | 48 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 49 | +import { | |
| 50 | + decodeGitHubPullRequestListJson, | |
| 51 | + formatGitHubJsonDecodeError, | |
| 52 | +} from "../githubPullRequests.ts"; | |
| 33 | 53 | |
| 34 | 54 | const COMMIT_TIMEOUT_MS = 10 * 60_000; |
| 35 | 55 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| @@ -236,85 +256,6 @@ function matchesBranchHeadContext( · 200 unmodified lines | ||
| 236 | 256 | return true; |
| 237 | 257 | } |
| 238 | 258 | |
| 239 | −function parsePullRequestList(raw: unknown): PullRequestInfo[] { | |
| 240 | − if (!Array.isArray(raw)) return []; | |
| 241 | − | |
| 242 | − const parsed: PullRequestInfo[] = []; | |
| 243 | − for (const entry of raw) { | |
| 244 | − if (!entry || typeof entry !== "object") continue; | |
| 245 | − const record = entry as Record<string, unknown>; | |
| 246 | − const number = record.number; | |
| 247 | − const title = record.title; | |
| 248 | − const url = record.url; | |
| 249 | − const baseRefName = record.baseRefName; | |
| 250 | − const headRefName = record.headRefName; | |
| 251 | − const state = record.state; | |
| 252 | − const mergedAt = record.mergedAt; | |
| 253 | − const updatedAt = record.updatedAt; | |
| 254 | − const isCrossRepository = record.isCrossRepository; | |
| 255 | − const headRepositoryRecord = | |
| 256 | − typeof record.headRepository === "object" && record.headRepository !== null | |
| 257 | − ? (record.headRepository as Record<string, unknown>) | |
| 258 | − : null; | |
| 259 | − const headRepositoryOwnerRecord = | |
| 260 | − typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null | |
| 261 | − ? (record.headRepositoryOwner as Record<string, unknown>) | |
| 262 | − : null; | |
| 263 | − const headRepositoryNameWithOwner = | |
| 264 | − typeof record.headRepositoryNameWithOwner === "string" | |
| 265 | − ? record.headRepositoryNameWithOwner | |
| 266 | − : typeof headRepositoryRecord?.nameWithOwner === "string" | |
| 267 | − ? headRepositoryRecord.nameWithOwner | |
| 268 | − : null; | |
| 269 | − const headRepositoryOwnerLogin = | |
| 270 | − typeof record.headRepositoryOwnerLogin === "string" | |
| 271 | − ? record.headRepositoryOwnerLogin | |
| 272 | − : typeof headRepositoryOwnerRecord?.login === "string" | |
| 273 | − ? headRepositoryOwnerRecord.login | |
| 274 | − : null; | |
| 275 | − if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { | |
| 276 | − continue; | |
| 277 | − } | |
| 278 | − if ( | |
| 279 | − typeof title !== "string" || | |
| 280 | − typeof url !== "string" || | |
| 281 | − typeof baseRefName !== "string" || | |
| 282 | − typeof headRefName !== "string" | |
| 283 | − ) { | |
| 284 | − continue; | |
| 285 | − } | |
| 286 | − | |
| 287 | − let normalizedState: "open" | "closed" | "merged"; | |
| 288 | − if ( | |
| 289 | − (typeof mergedAt === "string" && mergedAt.trim().length > 0) || | |
| 290 | − state === "MERGED" || | |
| 291 | − state === "merged" | |
| 292 | − ) { | |
| 293 | − normalizedState = "merged"; | |
| 294 | − } else if (state === "OPEN" || state === "open" || state === undefined || state === null) { | |
| 295 | − normalizedState = "open"; | |
| 296 | − } else if (state === "CLOSED" || state === "closed") { | |
| 297 | − normalizedState = "closed"; | |
| 298 | − } else { | |
| 299 | − continue; | |
| 300 | − } | |
| 301 | − | |
| 302 | − parsed.push({ | |
| 303 | − number, | |
| 304 | − title, | |
| 305 | − url, | |
| 306 | − baseRefName, | |
| 307 | − headRefName, | |
| 308 | − state: normalizedState, | |
| 309 | − updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, | |
| 310 | − ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), | |
| 311 | − ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 312 | − ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 313 | − }); | |
| 314 | − } | |
| 315 | − return parsed; | |
| 316 | −} | |
| 317 | − | |
| 318 | 259 | function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { |
| 319 | 260 | return { |
| 320 | 261 | number: summary.number, |
| @@ -695,26 +636,54 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 374 unmodified lines | ||
| 695 | 636 | |
| 696 | 637 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; |
| 697 | 638 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); |
| 698 | − const readStatus = Effect.fn("readStatus")(function* (cwd: string) { | |
| 699 | − const details = yield* gitCore.statusDetails(cwd).pipe( | |
| 700 | − Effect.catchIf(isNotGitRepositoryError, () => | |
| 701 | − Effect.succeed({ | |
| 702 | − isRepo: false, | |
| 703 | − hasOriginRemote: false, | |
| 704 | − isDefaultBranch: false, | |
| 705 | − branch: null, | |
| 706 | − upstreamRef: null, | |
| 707 | − hasWorkingTreeChanges: false, | |
| 708 | − workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 709 | − hasUpstream: false, | |
| 710 | − aheadCount: 0, | |
| 711 | − behindCount: 0, | |
| 712 | − } satisfies GitStatusDetails), | |
| 713 | − ), | |
| 714 | − ); | |
| 639 | + const nonRepositoryStatusDetails = { | |
| 640 | + isRepo: false, | |
| 641 | + hasOriginRemote: false, | |
| 642 | + isDefaultBranch: false, | |
| 643 | + branch: null, | |
| 644 | + upstreamRef: null, | |
| 645 | + hasWorkingTreeChanges: false, | |
| 646 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 647 | + hasUpstream: false, | |
| 648 | + aheadCount: 0, | |
| 649 | + behindCount: 0, | |
| 650 | + } satisfies GitStatusDetails; | |
| 651 | + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { | |
| 652 | + const details = yield* gitCore | |
| 653 | + .statusDetailsLocal(cwd) | |
| 654 | + .pipe( | |
| 655 | + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), | |
| 656 | + ); | |
| 657 | + const hostingProvider = details.isRepo | |
| 658 | + ? yield* resolveHostingProvider(cwd, details.branch) | |
| 659 | + : null; | |
| 660 | + | |
| 661 | + return { | |
| 662 | + isRepo: details.isRepo, | |
| 663 | + ...(hostingProvider ? { hostingProvider } : {}), | |
| 664 | + hasOriginRemote: details.hasOriginRemote, | |
| 665 | + isDefaultBranch: details.isDefaultBranch, | |
| 666 | + branch: details.branch, | |
| 667 | + hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 668 | + workingTree: details.workingTree, | |
| 669 | + } satisfies GitStatusLocalResult; | |
| 670 | + }); | |
| 671 | + const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { | |
| 672 | + capacity: STATUS_RESULT_CACHE_CAPACITY, | |
| 673 | + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | |
| 674 | + }); | |
| 675 | + const invalidateLocalStatusResultCache = (cwd: string) => | |
| 676 | + Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); | |
| 677 | + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { | |
| 678 | + const details = yield* gitCore | |
| 679 | + .statusDetails(cwd) | |
| 680 | + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); | |
| 681 | + if (details === null || !details.isRepo) { | |
| 682 | + return null; | |
| 683 | + } | |
| 715 | 684 | |
| 716 | 685 | const pr = |
| 717 | − details.isRepo && details.branch !== null | |
| 686 | + details.branch !== null | |
| 718 | 687 | ? yield* findLatestPr(cwd, { |
| 719 | 688 | branch: details.branch, |
| 720 | 689 | upstreamRef: details.upstreamRef, |
| @@ -725,29 +694,37 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 4 unmodified lines | ||
| 725 | 694 | : null; |
| 726 | 695 | |
| 727 | 696 | return { |
| 728 | − isRepo: details.isRepo, | |
| 729 | − hasOriginRemote: details.hasOriginRemote, | |
| 730 | − isDefaultBranch: details.isDefaultBranch, | |
| 731 | − branch: details.branch, | |
| 732 | − hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 733 | − workingTree: details.workingTree, | |
| 734 | 697 | hasUpstream: details.hasUpstream, |
| 735 | 698 | aheadCount: details.aheadCount, |
| 736 | 699 | behindCount: details.behindCount, |
| 737 | 700 | pr, |
| 738 | − }; | |
| 701 | + } satisfies GitStatusRemoteResult; | |
| 739 | 702 | }); |
| 740 | − const statusResultCache = yield* Cache.makeWith({ | |
| 703 | + const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { | |
| 741 | 704 | capacity: STATUS_RESULT_CACHE_CAPACITY, |
| 742 | − lookup: readStatus, | |
| 743 | 705 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), |
| 744 | 706 | }); |
| 745 | − const invalidateStatusResultCache = (cwd: string) => | |
| 746 | − Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); | |
| 707 | + const invalidateRemoteStatusResultCache = (cwd: string) => | |
| 708 | + Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); | |
| 747 | 709 | |
| 748 | 710 | const readConfigValueNullable = (cwd: string, key: string) => |
| 749 | 711 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); |
| 750 | 712 | |
| 713 | + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( | |
| 714 | + cwd: string, | |
| 715 | + branch: string | null, | |
| 716 | + ) { | |
| 717 | + const preferredRemoteName = | |
| 718 | + branch === null | |
| 719 | + ? "origin" | |
| 720 | + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); | |
| 721 | + const remoteUrl = | |
| 722 | + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? | |
| 723 | + (yield* readConfigValueNullable(cwd, "remote.origin.url")); | |
| 724 | + | |
| 725 | + return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; | |
| 726 | + }); | |
| 727 | + | |
| 751 | 728 | const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( |
| 752 | 729 | cwd: string, |
| 753 | 730 | remoteName: string | null, |
| @@ -907,13 +884,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 153 unmodified lines | ||
| 907 | 884 | continue; |
| 908 | 885 | } |
| 909 | 886 | |
| 910 | − const parsedJson = yield* Effect.try({ | |
| 911 | − try: () => JSON.parse(raw) as unknown, | |
| 912 | − catch: (cause) => | |
| 913 | − gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), | |
| 914 | − }); | |
| 887 | + const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | |
| 888 | + Effect.flatMap((decoded) => { | |
| 889 | + if (!Result.isSuccess(decoded)) { | |
| 890 | + return Effect.fail( | |
| 891 | + gitManagerError( | |
| 892 | + "findLatestPr", | |
| 893 | + `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | |
| 894 | + decoded.failure, | |
| 895 | + ), | |
| 896 | + ); | |
| 897 | + } | |
| 915 | 898 | |
| 916 | − for (const pr of parsePullRequestList(parsedJson)) { | |
| 899 | + return Effect.succeed(decoded.success); | |
| 900 | + }), | |
| 901 | + ); | |
| 902 | + | |
| 903 | + for (const pr of pullRequests) { | |
| 917 | 904 | if (!matchesBranchHeadContext(pr, headContext)) { |
| 918 | 905 | continue; |
| 919 | 906 | } |
| @@ -1311,9 +1298,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 391 unmodified lines | ||
| 1311 | 1298 | }; |
| 1312 | 1299 | }); |
| 1313 | 1300 | |
| 1301 | + const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { | |
| 1302 | + return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); | |
| 1303 | + }); | |
| 1304 | + const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( | |
| 1305 | + function* (input) { | |
| 1306 | + return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); | |
| 1307 | + }, | |
| 1308 | + ); | |
| 1314 | 1309 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { |
| 1315 | − return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); | |
| 1310 | + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); | |
| 1311 | + return mergeGitStatusParts(local, remote); | |
| 1312 | + }); | |
| 1313 | + const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( | |
| 1314 | + "invalidateLocalStatus", | |
| 1315 | + )(function* (cwd) { | |
| 1316 | + yield* invalidateLocalStatusResultCache(cwd); | |
| 1316 | 1317 | }); |
| 1318 | + const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( | |
| 1319 | + "invalidateRemoteStatus", | |
| 1320 | + )(function* (cwd) { | |
| 1321 | + yield* invalidateRemoteStatusResultCache(cwd); | |
| 1322 | + }); | |
| 1323 | + const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( | |
| 1324 | + function* (cwd) { | |
| 1325 | + yield* invalidateLocalStatusResultCache(cwd); | |
| 1326 | + yield* invalidateRemoteStatusResultCache(cwd); | |
| 1327 | + }, | |
| 1328 | + ); | |
| 1317 | 1329 | |
| 1318 | 1330 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( |
| 1319 | 1331 | function* (input) { |
| @@ -1488,7 +1500,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 168 unmodified lines | ||
| 1488 | 1500 | branch: worktree.worktree.branch, |
| 1489 | 1501 | worktreePath: worktree.worktree.path, |
| 1490 | 1502 | }; |
| 1491 | − }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); | |
| 1503 | + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); | |
| 1492 | 1504 | }); |
| 1493 | 1505 | |
| 1494 | 1506 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( |
| @@ -1692,7 +1704,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 197 unmodified lines | ||
| 1692 | 1704 | }); |
| 1693 | 1705 | |
| 1694 | 1706 | return yield* runAction().pipe( |
| 1695 | − Effect.ensuring(invalidateStatusResultCache(input.cwd)), | |
| 1707 | + Effect.ensuring(invalidateStatus(input.cwd)), | |
| 1696 | 1708 | Effect.tapError((error) => |
| 1697 | 1709 | Effect.flatMap(Ref.get(currentPhase), (phase) => |
| 1698 | 1710 | progress.emit({ |
| @@ -1707,7 +1719,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 8 unmodified lines | ||
| 1707 | 1719 | ); |
| 1708 | 1720 | |
| 1709 | 1721 | return { |
| 1722 | + localStatus, | |
| 1723 | + remoteStatus, | |
| 1710 | 1724 | status, |
| 1725 | + invalidateLocalStatus, | |
| 1726 | + invalidateRemoteStatus, | |
| 1727 | + invalidateStatus, | |
| 1711 | 1728 | resolvePullRequest, |
| 1712 | 1729 | preparePullRequestThread, |
| 1713 | 1730 | runStackedAction, |
| @@ -0,0 +1,312 @@ | ||
| 1 | +import { assert, it } from "@effect/vitest"; | |
| 2 | +import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; | |
| 3 | +import type { | |
| 4 | + GitStatusLocalResult, | |
| 5 | + GitStatusRemoteResult, | |
| 6 | + GitStatusResult, | |
| 7 | + GitStatusStreamEvent, | |
| 8 | +} from "@t3tools/contracts"; | |
| 9 | +import { describe } from "vitest"; | |
| 10 | + | |
| 11 | +import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; | |
| 12 | +import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; | |
| 13 | +import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; | |
| 14 | + | |
| 15 | +const baseLocalStatus: GitStatusLocalResult = { | |
| 16 | + isRepo: true, | |
| 17 | + hostingProvider: { | |
| 18 | + kind: "github", | |
| 19 | + name: "GitHub", | |
| 20 | + baseUrl: "https://github.com", | |
| 21 | + }, | |
| 22 | + hasOriginRemote: true, | |
| 23 | + isDefaultBranch: false, | |
| 24 | + branch: "feature/status-broadcast", | |
| 25 | + hasWorkingTreeChanges: false, | |
| 26 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 27 | +}; | |
| 28 | + | |
| 29 | +const baseRemoteStatus: GitStatusRemoteResult = { | |
| 30 | + hasUpstream: true, | |
| 31 | + aheadCount: 0, | |
| 32 | + behindCount: 0, | |
| 33 | + pr: null, | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const baseStatus: GitStatusResult = { | |
| 37 | + ...baseLocalStatus, | |
| 38 | + ...baseRemoteStatus, | |
| 39 | +}; | |
| 40 | + | |
| 41 | +function makeTestLayer(state: { | |
| 42 | + currentLocalStatus: GitStatusLocalResult; | |
| 43 | + currentRemoteStatus: GitStatusRemoteResult | null; | |
| 44 | + localStatusCalls: number; | |
| 45 | + remoteStatusCalls: number; | |
| 46 | + localInvalidationCalls: number; | |
| 47 | + remoteInvalidationCalls: number; | |
| 48 | +}) { | |
| 49 | + const gitManager: GitManagerShape = { | |
| 50 | + localStatus: () => | |
| 51 | + Effect.sync(() => { | |
| 52 | + state.localStatusCalls += 1; | |
| 53 | + return state.currentLocalStatus; | |
| 54 | + }), | |
| 55 | + remoteStatus: () => | |
| 56 | + Effect.sync(() => { | |
| 57 | + state.remoteStatusCalls += 1; | |
| 58 | + return state.currentRemoteStatus; | |
| 59 | + }), | |
| 60 | + status: () => Effect.die("status should not be called in this test"), | |
| 61 | + invalidateLocalStatus: () => | |
| 62 | + Effect.sync(() => { | |
| 63 | + state.localInvalidationCalls += 1; | |
| 64 | + }), | |
| 65 | + invalidateRemoteStatus: () => | |
| 66 | + Effect.sync(() => { | |
| 67 | + state.remoteInvalidationCalls += 1; | |
| 68 | + }), | |
| 69 | + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), | |
| 70 | + resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), | |
| 71 | + preparePullRequestThread: () => | |
| 72 | + Effect.die("preparePullRequestThread should not be called in this test"), | |
| 73 | + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), | |
| 74 | + }; | |
| 75 | + | |
| 76 | + return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); | |
| 77 | +} | |
| 78 | + | |
| 79 | +describe("GitStatusBroadcasterLive", () => { | |
| 80 | + it.effect("reuses the cached git status across repeated reads", () => { | |
| 81 | + const state = { | |
| 82 | + currentLocalStatus: baseLocalStatus, | |
| 83 | + currentRemoteStatus: baseRemoteStatus, | |
| 84 | + localStatusCalls: 0, | |
| 85 | + remoteStatusCalls: 0, | |
| 86 | + localInvalidationCalls: 0, | |
| 87 | + remoteInvalidationCalls: 0, | |
| 88 | + }; | |
| 89 | + | |
| 90 | + return Effect.gen(function* () { | |
| 91 | + const broadcaster = yield* GitStatusBroadcaster; | |
| 92 | + | |
| 93 | + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); | |
| 94 | + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); | |
| 95 | + | |
| 96 | + assert.deepStrictEqual(first, baseStatus); | |
| 97 | + assert.deepStrictEqual(second, baseStatus); | |
| 98 | + assert.equal(state.localStatusCalls, 1); | |
| 99 | + assert.equal(state.remoteStatusCalls, 1); | |
| 100 | + assert.equal(state.localInvalidationCalls, 0); | |
| 101 | + assert.equal(state.remoteInvalidationCalls, 0); | |
| 102 | + }).pipe(Effect.provide(makeTestLayer(state))); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it.effect("refreshes the cached snapshot after explicit invalidation", () => { | |
| 106 | + const state = { | |
| 107 | + currentLocalStatus: baseLocalStatus, | |
| 108 | + currentRemoteStatus: baseRemoteStatus, | |
| 109 | + localStatusCalls: 0, | |
| 110 | + remoteStatusCalls: 0, | |
| 111 | + localInvalidationCalls: 0, | |
| 112 | + remoteInvalidationCalls: 0, | |
| 113 | + }; | |
| 114 | + | |
| 115 | + return Effect.gen(function* () { | |
| 116 | + const broadcaster = yield* GitStatusBroadcaster; | |
| 117 | + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | |
| 118 | + | |
| 119 | + state.currentLocalStatus = { | |
| 120 | + ...baseLocalStatus, | |
| 121 | + branch: "feature/updated-status", | |
| 122 | + }; | |
| 123 | + state.currentRemoteStatus = { | |
| 124 | + ...baseRemoteStatus, | |
| 125 | + aheadCount: 2, | |
| 126 | + }; | |
| 127 | + const refreshed = yield* broadcaster.refreshStatus("/repo"); | |
| 128 | + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); | |
| 129 | + | |
| 130 | + assert.deepStrictEqual(initial, baseStatus); | |
| 131 | + assert.deepStrictEqual(refreshed, { | |
| 132 | + ...state.currentLocalStatus, | |
| 133 | + ...state.currentRemoteStatus, | |
| 134 | + }); | |
| 135 | + assert.deepStrictEqual(cached, { | |
| 136 | + ...state.currentLocalStatus, | |
| 137 | + ...state.currentRemoteStatus, | |
| 138 | + }); | |
| 139 | + assert.equal(state.localStatusCalls, 2); | |
| 140 | + assert.equal(state.remoteStatusCalls, 2); | |
| 141 | + assert.equal(state.localInvalidationCalls, 1); | |
| 142 | + assert.equal(state.remoteInvalidationCalls, 1); | |
| 143 | + }).pipe(Effect.provide(makeTestLayer(state))); | |
| 144 | + }); | |
| 145 | + | |
| 146 | + it.effect("refreshes only the cached local snapshot when requested", () => { | |
| 147 | + const state = { | |
| 148 | + currentLocalStatus: baseLocalStatus, | |
| 149 | + currentRemoteStatus: baseRemoteStatus, | |
| 150 | + localStatusCalls: 0, | |
| 151 | + remoteStatusCalls: 0, | |
| 152 | + localInvalidationCalls: 0, | |
| 153 | + remoteInvalidationCalls: 0, | |
| 154 | + }; | |
| 155 | + | |
| 156 | + return Effect.gen(function* () { | |
| 157 | + const broadcaster = yield* GitStatusBroadcaster; | |
| 158 | + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | |
| 159 | + | |
| 160 | + state.currentLocalStatus = { | |
| 161 | + ...baseLocalStatus, | |
| 162 | + branch: "feature/local-only-refresh", | |
| 163 | + hasWorkingTreeChanges: true, | |
| 164 | + }; | |
| 165 | + | |
| 166 | + const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); | |
| 167 | + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); | |
| 168 | + | |
| 169 | + assert.deepStrictEqual(initial, baseStatus); | |
| 170 | + assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); | |
| 171 | + assert.deepStrictEqual(cached, { | |
| 172 | + ...state.currentLocalStatus, | |
| 173 | + ...baseRemoteStatus, | |
| 174 | + }); | |
| 175 | + assert.equal(state.localStatusCalls, 2); | |
| 176 | + assert.equal(state.remoteStatusCalls, 1); | |
| 177 | + assert.equal(state.localInvalidationCalls, 1); | |
| 178 | + assert.equal(state.remoteInvalidationCalls, 0); | |
| 179 | + }).pipe(Effect.provide(makeTestLayer(state))); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + it.effect("streams a local snapshot first and remote updates later", () => { | |
| 183 | + const state = { | |
| 184 | + currentLocalStatus: baseLocalStatus, | |
| 185 | + currentRemoteStatus: baseRemoteStatus, | |
| 186 | + localStatusCalls: 0, | |
| 187 | + remoteStatusCalls: 0, | |
| 188 | + localInvalidationCalls: 0, | |
| 189 | + remoteInvalidationCalls: 0, | |
| 190 | + }; | |
| 191 | + | |
| 192 | + return Effect.gen(function* () { | |
| 193 | + const broadcaster = yield* GitStatusBroadcaster; | |
| 194 | + const snapshotDeferred = yield* Deferred.make<GitStatusStreamEvent>(); | |
| 195 | + const remoteUpdatedDeferred = yield* Deferred.make<GitStatusStreamEvent>(); | |
| 196 | + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { | |
| 197 | + if (event._tag === "snapshot") { | |
| 198 | + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | |
| 199 | + } | |
| 200 | + if (event._tag === "remoteUpdated") { | |
| 201 | + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | |
| 202 | + } | |
| 203 | + return Effect.void; | |
| 204 | + }).pipe(Effect.forkScoped); | |
| 205 | + | |
| 206 | + const snapshot = yield* Deferred.await(snapshotDeferred); | |
| 207 | + yield* broadcaster.refreshStatus("/repo"); | |
| 208 | + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); | |
| 209 | + | |
| 210 | + assert.deepStrictEqual(snapshot, { | |
| 211 | + _tag: "snapshot", | |
| 212 | + local: baseLocalStatus, | |
| 213 | + remote: null, | |
| 214 | + } satisfies GitStatusStreamEvent); | |
| 215 | + assert.deepStrictEqual(remoteUpdated, { | |
| 216 | + _tag: "remoteUpdated", | |
| 217 | + remote: baseRemoteStatus, | |
| 218 | + } satisfies GitStatusStreamEvent); | |
| 219 | + }).pipe(Effect.provide(makeTestLayer(state))); | |
| 220 | + }); | |
| 221 | + | |
| 222 | + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { | |
| 223 | + const state = { | |
| 224 | + currentLocalStatus: baseLocalStatus, | |
| 225 | + currentRemoteStatus: baseRemoteStatus, | |
| 226 | + localStatusCalls: 0, | |
| 227 | + remoteStatusCalls: 0, | |
| 228 | + localInvalidationCalls: 0, | |
| 229 | + remoteInvalidationCalls: 0, | |
| 230 | + }; | |
| 231 | + let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null; | |
| 232 | + let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; | |
| 233 | + const testLayer = GitStatusBroadcasterLive.pipe( | |
| 234 | + Layer.provide( | |
| 235 | + Layer.succeed(GitManager, { | |
| 236 | + localStatus: () => | |
| 237 | + Effect.sync(() => { | |
| 238 | + state.localStatusCalls += 1; | |
| 239 | + return state.currentLocalStatus; | |
| 240 | + }), | |
| 241 | + remoteStatus: () => | |
| 242 | + Effect.sync(() => { | |
| 243 | + state.remoteStatusCalls += 1; | |
| 244 | + }).pipe( | |
| 245 | + Effect.andThen( | |
| 246 | + remoteStartedDeferred | |
| 247 | + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) | |
| 248 | + : Effect.void, | |
| 249 | + ), | |
| 250 | + Effect.andThen(Effect.never as Effect.Effect<GitStatusRemoteResult | null, never>), | |
| 251 | + Effect.onInterrupt(() => | |
| 252 | + remoteInterruptedDeferred | |
| 253 | + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) | |
| 254 | + : Effect.void, | |
| 255 | + ), | |
| 256 | + ), | |
| 257 | + status: () => Effect.die("status should not be called in this test"), | |
| 258 | + invalidateLocalStatus: () => | |
| 259 | + Effect.sync(() => { | |
| 260 | + state.localInvalidationCalls += 1; | |
| 261 | + }), | |
| 262 | + invalidateRemoteStatus: () => | |
| 263 | + Effect.sync(() => { | |
| 264 | + state.remoteInvalidationCalls += 1; | |
| 265 | + }), | |
| 266 | + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), | |
| 267 | + resolvePullRequest: () => | |
| 268 | + Effect.die("resolvePullRequest should not be called in this test"), | |
| 269 | + preparePullRequestThread: () => | |
| 270 | + Effect.die("preparePullRequestThread should not be called in this test"), | |
| 271 | + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), | |
| 272 | + } satisfies GitManagerShape), | |
| 273 | + ), | |
| 274 | + ); | |
| 275 | + | |
| 276 | + return Effect.gen(function* () { | |
| 277 | + const remoteInterrupted = yield* Deferred.make<void>(); | |
| 278 | + const remoteStarted = yield* Deferred.make<void>(); | |
| 279 | + remoteInterruptedDeferred = remoteInterrupted; | |
| 280 | + remoteStartedDeferred = remoteStarted; | |
| 281 | + | |
| 282 | + const broadcaster = yield* GitStatusBroadcaster; | |
| 283 | + const firstSnapshot = yield* Deferred.make<GitStatusStreamEvent>(); | |
| 284 | + const secondSnapshot = yield* Deferred.make<GitStatusStreamEvent>(); | |
| 285 | + const firstScope = yield* Scope.make(); | |
| 286 | + const secondScope = yield* Scope.make(); | |
| 287 | + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | |
| 288 | + event._tag === "snapshot" | |
| 289 | + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) | |
| 290 | + : Effect.void, | |
| 291 | + ).pipe(Effect.forkIn(firstScope)); | |
| 292 | + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | |
| 293 | + event._tag === "snapshot" | |
| 294 | + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) | |
| 295 | + : Effect.void, | |
| 296 | + ).pipe(Effect.forkIn(secondScope)); | |
| 297 | + | |
| 298 | + yield* Deferred.await(firstSnapshot); | |
| 299 | + yield* Deferred.await(secondSnapshot); | |
| 300 | + yield* Deferred.await(remoteStarted); | |
| 301 | + | |
| 302 | + assert.equal(state.remoteStatusCalls, 1); | |
| 303 | + | |
| 304 | + yield* Scope.close(firstScope, Exit.void); | |
| 305 | + assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); | |
| 306 | + | |
| 307 | + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); | |
| 308 | + yield* Deferred.await(remoteInterrupted); | |
| 309 | + assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); | |
| 310 | + }).pipe(Effect.provide(testLayer)); | |
| 311 | + }); | |
| 312 | +}); | |
| @@ -0,0 +1,311 @@ | ||
| 1 | +import { realpathSync } from "node:fs"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + Duration, | |
| 5 | + Effect, | |
| 6 | + Exit, | |
| 7 | + Fiber, | |
| 8 | + Layer, | |
| 9 | + PubSub, | |
| 10 | + Ref, | |
| 11 | + Scope, | |
| 12 | + Stream, | |
| 13 | + SynchronizedRef, | |
| 14 | +} from "effect"; | |
| 15 | +import type { | |
| 16 | + GitStatusInput, | |
| 17 | + GitStatusLocalResult, | |
| 18 | + GitStatusRemoteResult, | |
| 19 | + GitStatusStreamEvent, | |
| 20 | +} from "@t3tools/contracts"; | |
| 21 | +import { mergeGitStatusParts } from "@t3tools/shared/git"; | |
| 22 | + | |
| 23 | +import { | |
| 24 | + GitStatusBroadcaster, | |
| 25 | + type GitStatusBroadcasterShape, | |
| 26 | +} from "../Services/GitStatusBroadcaster.ts"; | |
| 27 | +import { GitManager } from "../Services/GitManager.ts"; | |
| 28 | + | |
| 29 | +const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | |
| 30 | + | |
| 31 | +interface GitStatusChange { | |
| 32 | + readonly cwd: string; | |
| 33 | + readonly event: GitStatusStreamEvent; | |
| 34 | +} | |
| 35 | + | |
| 36 | +interface CachedValue<T> { | |
| 37 | + readonly fingerprint: string; | |
| 38 | + readonly value: T; | |
| 39 | +} | |
| 40 | + | |
| 41 | +interface CachedGitStatus { | |
| 42 | + readonly local: CachedValue<GitStatusLocalResult> | null; | |
| 43 | + readonly remote: CachedValue<GitStatusRemoteResult | null> | null; | |
| 44 | +} | |
| 45 | + | |
| 46 | +interface ActiveRemotePoller { | |
| 47 | + readonly fiber: Fiber.Fiber<void, never>; | |
| 48 | + readonly subscriberCount: number; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function normalizeCwd(cwd: string): string { | |
| 52 | + try { | |
| 53 | + return realpathSync.native(cwd); | |
| 54 | + } catch { | |
| 55 | + return cwd; | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +function fingerprintStatusPart(status: unknown): string { | |
| 60 | + return JSON.stringify(status); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export const GitStatusBroadcasterLive = Layer.effect( | |
| 64 | + GitStatusBroadcaster, | |
| 65 | + Effect.gen(function* () { | |
| 66 | + const gitManager = yield* GitManager; | |
| 67 | + const changesPubSub = yield* Effect.acquireRelease( | |
| 68 | + PubSub.unbounded<GitStatusChange>(), | |
| 69 | + (pubsub) => PubSub.shutdown(pubsub), | |
| 70 | + ); | |
| 71 | + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | |
| 72 | + Scope.close(scope, Exit.void), | |
| 73 | + ); | |
| 74 | + const cacheRef = yield* Ref.make(new Map<string, CachedGitStatus>()); | |
| 75 | + const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>()); | |
| 76 | + | |
| 77 | + const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { | |
| 78 | + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( | |
| 82 | + cwd: string, | |
| 83 | + local: GitStatusLocalResult, | |
| 84 | + options?: { publish?: boolean }, | |
| 85 | + ) { | |
| 86 | + const nextLocal = { | |
| 87 | + fingerprint: fingerprintStatusPart(local), | |
| 88 | + value: local, | |
| 89 | + } satisfies CachedValue<GitStatusLocalResult>; | |
| 90 | + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { | |
| 91 | + const previous = cache.get(cwd) ?? { local: null, remote: null }; | |
| 92 | + const nextCache = new Map(cache); | |
| 93 | + nextCache.set(cwd, { | |
| 94 | + ...previous, | |
| 95 | + local: nextLocal, | |
| 96 | + }); | |
| 97 | + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; | |
| 98 | + }); | |
| 99 | + | |
| 100 | + if (options?.publish && shouldPublish) { | |
| 101 | + yield* PubSub.publish(changesPubSub, { | |
| 102 | + cwd, | |
| 103 | + event: { | |
| 104 | + _tag: "localUpdated", | |
| 105 | + local, | |
| 106 | + }, | |
| 107 | + }); | |
| 108 | + } | |
| 109 | + | |
| 110 | + return local; | |
| 111 | + }); | |
| 112 | + | |
| 113 | + const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( | |
| 114 | + cwd: string, | |
| 115 | + remote: GitStatusRemoteResult | null, | |
| 116 | + options?: { publish?: boolean }, | |
| 117 | + ) { | |
| 118 | + const nextRemote = { | |
| 119 | + fingerprint: fingerprintStatusPart(remote), | |
| 120 | + value: remote, | |
| 121 | + } satisfies CachedValue<GitStatusRemoteResult | null>; | |
| 122 | + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { | |
| 123 | + const previous = cache.get(cwd) ?? { local: null, remote: null }; | |
| 124 | + const nextCache = new Map(cache); | |
| 125 | + nextCache.set(cwd, { | |
| 126 | + ...previous, | |
| 127 | + remote: nextRemote, | |
| 128 | + }); | |
| 129 | + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; | |
| 130 | + }); | |
| 131 | + | |
| 132 | + if (options?.publish && shouldPublish) { | |
| 133 | + yield* PubSub.publish(changesPubSub, { | |
| 134 | + cwd, | |
| 135 | + event: { | |
| 136 | + _tag: "remoteUpdated", | |
| 137 | + remote, | |
| 138 | + }, | |
| 139 | + }); | |
| 140 | + } | |
| 141 | + | |
| 142 | + return remote; | |
| 143 | + }); | |
| 144 | + | |
| 145 | + const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { | |
| 146 | + const local = yield* gitManager.localStatus({ cwd }); | |
| 147 | + return yield* updateCachedLocalStatus(cwd, local); | |
| 148 | + }); | |
| 149 | + | |
| 150 | + const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { | |
| 151 | + const remote = yield* gitManager.remoteStatus({ cwd }); | |
| 152 | + return yield* updateCachedRemoteStatus(cwd, remote); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { | |
| 156 | + const cached = yield* getCachedStatus(cwd); | |
| 157 | + if (cached?.local) { | |
| 158 | + return cached.local.value; | |
| 159 | + } | |
| 160 | + return yield* loadLocalStatus(cwd); | |
| 161 | + }); | |
| 162 | + | |
| 163 | + const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { | |
| 164 | + const cached = yield* getCachedStatus(cwd); | |
| 165 | + if (cached?.remote) { | |
| 166 | + return cached.remote.value; | |
| 167 | + } | |
| 168 | + return yield* loadRemoteStatus(cwd); | |
| 169 | + }); | |
| 170 | + | |
| 171 | + const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( | |
| 172 | + input: GitStatusInput, | |
| 173 | + ) { | |
| 174 | + const normalizedCwd = normalizeCwd(input.cwd); | |
| 175 | + const [local, remote] = yield* Effect.all([ | |
| 176 | + getOrLoadLocalStatus(normalizedCwd), | |
| 177 | + getOrLoadRemoteStatus(normalizedCwd), | |
| 178 | + ]); | |
| 179 | + return mergeGitStatusParts(local, remote); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( | |
| 183 | + "refreshLocalStatus", | |
| 184 | + )(function* (cwd) { | |
| 185 | + const normalizedCwd = normalizeCwd(cwd); | |
| 186 | + yield* gitManager.invalidateLocalStatus(normalizedCwd); | |
| 187 | + const local = yield* gitManager.localStatus({ cwd: normalizedCwd }); | |
| 188 | + return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true }); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { | |
| 192 | + yield* gitManager.invalidateRemoteStatus(cwd); | |
| 193 | + const remote = yield* gitManager.remoteStatus({ cwd }); | |
| 194 | + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); | |
| 195 | + }); | |
| 196 | + | |
| 197 | + const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( | |
| 198 | + function* (cwd) { | |
| 199 | + const normalizedCwd = normalizeCwd(cwd); | |
| 200 | + const [local, remote] = yield* Effect.all([ | |
| 201 | + refreshLocalStatus(normalizedCwd), | |
| 202 | + refreshRemoteStatus(normalizedCwd), | |
| 203 | + ]); | |
| 204 | + return mergeGitStatusParts(local, remote); | |
| 205 | + }, | |
| 206 | + ); | |
| 207 | + | |
| 208 | + const makeRemoteRefreshLoop = (cwd: string) => { | |
| 209 | + const logRefreshFailure = (error: Error) => | |
| 210 | + Effect.logWarning("git remote status refresh failed", { | |
| 211 | + cwd, | |
| 212 | + detail: error.message, | |
| 213 | + }); | |
| 214 | + | |
| 215 | + return refreshRemoteStatus(cwd).pipe( | |
| 216 | + Effect.catch(logRefreshFailure), | |
| 217 | + Effect.andThen( | |
| 218 | + Effect.forever( | |
| 219 | + Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( | |
| 220 | + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), | |
| 221 | + ), | |
| 222 | + ), | |
| 223 | + ), | |
| 224 | + ); | |
| 225 | + }; | |
| 226 | + | |
| 227 | + const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { | |
| 228 | + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { | |
| 229 | + const existing = activePollers.get(cwd); | |
| 230 | + if (existing) { | |
| 231 | + const nextPollers = new Map(activePollers); | |
| 232 | + nextPollers.set(cwd, { | |
| 233 | + ...existing, | |
| 234 | + subscriberCount: existing.subscriberCount + 1, | |
| 235 | + }); | |
| 236 | + return Effect.succeed([undefined, nextPollers] as const); | |
| 237 | + } | |
| 238 | + | |
| 239 | + return makeRemoteRefreshLoop(cwd).pipe( | |
| 240 | + Effect.forkIn(broadcasterScope), | |
| 241 | + Effect.map((fiber) => { | |
| 242 | + const nextPollers = new Map(activePollers); | |
| 243 | + nextPollers.set(cwd, { | |
| 244 | + fiber, | |
| 245 | + subscriberCount: 1, | |
| 246 | + }); | |
| 247 | + return [undefined, nextPollers] as const; | |
| 248 | + }), | |
| 249 | + ); | |
| 250 | + }); | |
| 251 | + }); | |
| 252 | + | |
| 253 | + const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { | |
| 254 | + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { | |
| 255 | + const existing = activePollers.get(cwd); | |
| 256 | + if (!existing) { | |
| 257 | + return [null, activePollers] as const; | |
| 258 | + } | |
| 259 | + | |
| 260 | + if (existing.subscriberCount > 1) { | |
| 261 | + const nextPollers = new Map(activePollers); | |
| 262 | + nextPollers.set(cwd, { | |
| 263 | + ...existing, | |
| 264 | + subscriberCount: existing.subscriberCount - 1, | |
| 265 | + }); | |
| 266 | + return [null, nextPollers] as const; | |
| 267 | + } | |
| 268 | + | |
| 269 | + const nextPollers = new Map(activePollers); | |
| 270 | + nextPollers.delete(cwd); | |
| 271 | + return [existing.fiber, nextPollers] as const; | |
| 272 | + }); | |
| 273 | + | |
| 274 | + if (pollerToInterrupt) { | |
| 275 | + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); | |
| 276 | + } | |
| 277 | + }); | |
| 278 | + | |
| 279 | + const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => | |
| 280 | + Stream.unwrap( | |
| 281 | + Effect.gen(function* () { | |
| 282 | + const normalizedCwd = normalizeCwd(input.cwd); | |
| 283 | + const subscription = yield* PubSub.subscribe(changesPubSub); | |
| 284 | + const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); | |
| 285 | + const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; | |
| 286 | + yield* retainRemotePoller(normalizedCwd); | |
| 287 | + | |
| 288 | + const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); | |
| 289 | + | |
| 290 | + return Stream.concat( | |
| 291 | + Stream.make({ | |
| 292 | + _tag: "snapshot" as const, | |
| 293 | + local: initialLocal, | |
| 294 | + remote: initialRemote, | |
| 295 | + }), | |
| 296 | + Stream.fromSubscription(subscription).pipe( | |
| 297 | + Stream.filter((event) => event.cwd === normalizedCwd), | |
| 298 | + Stream.map((event) => event.event), | |
| 299 | + ), | |
| 300 | + ).pipe(Stream.ensuring(release)); | |
| 301 | + }), | |
| 302 | + ); | |
| 303 | + | |
| 304 | + return { | |
| 305 | + getStatus, | |
| 306 | + refreshLocalStatus, | |
| 307 | + refreshStatus, | |
| 308 | + streamStatus, | |
| 309 | + } satisfies GitStatusBroadcasterShape; | |
| 310 | + }), | |
| 311 | +); | |
| @@ -9,7 +9,7 @@ · 8 unmodified lines | ||
| 9 | 9 | * |
| 10 | 10 | * @module RoutingTextGeneration |
| 11 | 11 | */ |
| 12 | −import { Effect, Layer, ServiceMap } from "effect"; | |
| 12 | +import { Effect, Layer, Context } from "effect"; | |
| 13 | 13 | |
| 14 | 14 | import { |
| 15 | 15 | TextGeneration, |
| @@ -23,11 +23,11 @@ import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; · 7 unmodified lines | ||
| 23 | 23 | // Internal service tags so both concrete layers can coexist. |
| 24 | 24 | // --------------------------------------------------------------------------- |
| 25 | 25 | |
| 26 | −class CodexTextGen extends ServiceMap.Service<CodexTextGen, TextGenerationShape>()( | |
| 26 | +class CodexTextGen extends Context.Service<CodexTextGen, TextGenerationShape>()( | |
| 27 | 27 | "t3/git/Layers/RoutingTextGeneration/CodexTextGen", |
| 28 | 28 | ) {} |
| 29 | 29 | |
| 30 | −class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShape>()( | |
| 30 | +class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>()( | |
| 31 | 31 | "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", |
| 32 | 32 | ) {} |
| 33 | 33 | |
| @@ -6,11 +6,13 @@ · 5 unmodified lines | ||
| 6 | 6 | * |
| 7 | 7 | * @module GitCore |
| 8 | 8 | */ |
| 9 | −import { ServiceMap } from "effect"; | |
| 10 | −import type { Effect, Scope } from "effect"; | |
| 9 | +import { Context } from "effect"; | |
| 10 | +import type { Effect } from "effect"; | |
| 11 | 11 | import type { |
| 12 | 12 | GitCheckoutInput, |
| 13 | + GitCheckoutResult, | |
| 13 | 14 | GitCreateBranchInput, |
| 15 | + GitCreateBranchResult, | |
| 14 | 16 | GitCreateWorktreeInput, |
| 15 | 17 | GitCreateWorktreeResult, |
| 16 | 18 | GitInitInput, |
| @@ -156,6 +158,11 @@ export interface GitCoreShape { · 139 unmodified lines | ||
| 156 | 158 | */ |
| 157 | 159 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; |
| 158 | 160 | |
| 161 | + /** | |
| 162 | + * Read detailed working tree / branch status without refreshing remote tracking refs. | |
| 163 | + */ | |
| 164 | + readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | |
| 165 | + | |
| 159 | 166 | /** |
| 160 | 167 | * Build staged change context for commit generation. |
| 161 | 168 | */ |
| @@ -278,14 +285,16 @@ export interface GitCoreShape { · 116 unmodified lines | ||
| 278 | 285 | /** |
| 279 | 286 | * Create a local branch. |
| 280 | 287 | */ |
| 281 | − readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>; | |
| 288 | + readonly createBranch: ( | |
| 289 | + input: GitCreateBranchInput, | |
| 290 | + ) => Effect.Effect<GitCreateBranchResult, GitCommandError>; | |
| 282 | 291 | |
| 283 | 292 | /** |
| 284 | 293 | * Checkout an existing branch and refresh its upstream metadata in background. |
| 285 | 294 | */ |
| 286 | 295 | readonly checkoutBranch: ( |
| 287 | 296 | input: GitCheckoutInput, |
| 288 | − ) => Effect.Effect<void, GitCommandError, Scope.Scope>; | |
| 297 | + ) => Effect.Effect<GitCheckoutResult, GitCommandError>; | |
| 289 | 298 | |
| 290 | 299 | /** |
| 291 | 300 | * Initialize a repository in the provided directory. |
| @@ -301,6 +310,4 @@ export interface GitCoreShape { · 9 unmodified lines | ||
| 301 | 310 | /** |
| 302 | 311 | * GitCore - Service tag for low-level Git repository operations. |
| 303 | 312 | */ |
| 304 | −export class GitCore extends ServiceMap.Service<GitCore, GitCoreShape>()( | |
| 305 | − "t3/git/Services/GitCore", | |
| 306 | −) {} | |
| 313 | +export class GitCore extends Context.Service<GitCore, GitCoreShape>()("t3/git/Services/GitCore") {} | |
| @@ -5,10 +5,10 @@ · 4 unmodified lines | ||
| 5 | 5 | * |
| 6 | 6 | * @module GitHubCli |
| 7 | 7 | */ |
| 8 | −import { ServiceMap } from "effect"; | |
| 8 | +import { Context } from "effect"; | |
| 9 | 9 | import type { Effect } from "effect"; |
| 10 | 10 | |
| 11 | −import type { ProcessRunResult } from "../../processRunner"; | |
| 11 | +import type { ProcessRunResult } from "../../processRunner.ts"; | |
| 12 | 12 | import type { GitHubCliError } from "@t3tools/contracts"; |
| 13 | 13 | |
| 14 | 14 | export interface GitHubPullRequestSummary { |
| @@ -98,6 +98,6 @@ export interface GitHubCliShape { · 83 unmodified lines | ||
| 98 | 98 | /** |
| 99 | 99 | * GitHubCli - Service tag for GitHub CLI process execution. |
| 100 | 100 | */ |
| 101 | −export class GitHubCli extends ServiceMap.Service<GitHubCli, GitHubCliShape>()( | |
| 101 | +export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()( | |
| 102 | 102 | "t3/git/Services/GitHubCli", |
| 103 | 103 | ) {} |
| @@ -14,10 +14,12 @@ import { · 13 unmodified lines | ||
| 14 | 14 | GitResolvePullRequestResult, |
| 15 | 15 | GitRunStackedActionInput, |
| 16 | 16 | GitRunStackedActionResult, |
| 17 | + GitStatusLocalResult, | |
| 18 | + GitStatusRemoteResult, | |
| 17 | 19 | GitStatusInput, |
| 18 | 20 | GitStatusResult, |
| 19 | 21 | } from "@t3tools/contracts"; |
| 20 | −import { ServiceMap } from "effect"; | |
| 22 | +import { Context } from "effect"; | |
| 21 | 23 | import type { Effect } from "effect"; |
| 22 | 24 | import type { GitManagerServiceError } from "@t3tools/contracts"; |
| 23 | 25 | |
| @@ -41,6 +43,35 @@ export interface GitManagerShape { · 17 unmodified lines | ||
| 41 | 43 | input: GitStatusInput, |
| 42 | 44 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; |
| 43 | 45 | |
| 46 | + /** | |
| 47 | + * Read local repository status without remote hosting enrichment. | |
| 48 | + */ | |
| 49 | + readonly localStatus: ( | |
| 50 | + input: GitStatusInput, | |
| 51 | + ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>; | |
| 52 | + | |
| 53 | + /** | |
| 54 | + * Read remote tracking / PR status for a repository. | |
| 55 | + */ | |
| 56 | + readonly remoteStatus: ( | |
| 57 | + input: GitStatusInput, | |
| 58 | + ) => Effect.Effect<GitStatusRemoteResult | null, GitManagerServiceError>; | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * Clear any cached local status snapshot for a repository. | |
| 62 | + */ | |
| 63 | + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * Clear any cached remote status snapshot for a repository. | |
| 67 | + */ | |
| 68 | + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | |
| 69 | + | |
| 70 | + /** | |
| 71 | + * Clear any cached status snapshot for a repository so the next read is fresh. | |
| 72 | + */ | |
| 73 | + readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; | |
| 74 | + | |
| 44 | 75 | /** |
| 45 | 76 | * Resolve a pull request by URL/number against the current repository. |
| 46 | 77 | */ |
| @@ -68,6 +99,6 @@ export interface GitManagerShape { · 21 unmodified lines | ||
| 68 | 99 | /** |
| 69 | 100 | * GitManager - Service tag for stacked Git workflow orchestration. |
| 70 | 101 | */ |
| 71 | −export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()( | |
| 102 | +export class GitManager extends Context.Service<GitManager, GitManagerShape>()( | |
| 72 | 103 | "t3/git/Services/GitManager", |
| 73 | 104 | ) {} |
| @@ -0,0 +1,27 @@ | ||
| 1 | +import { Context } from "effect"; | |
| 2 | +import type { Effect, Stream } from "effect"; | |
| 3 | +import type { | |
| 4 | + GitManagerServiceError, | |
| 5 | + GitStatusInput, | |
| 6 | + GitStatusLocalResult, | |
| 7 | + GitStatusResult, | |
| 8 | + GitStatusStreamEvent, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | + | |
| 11 | +export interface GitStatusBroadcasterShape { | |
| 12 | + readonly getStatus: ( | |
| 13 | + input: GitStatusInput, | |
| 14 | + ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | |
| 15 | + readonly refreshLocalStatus: ( | |
| 16 | + cwd: string, | |
| 17 | + ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>; | |
| 18 | + readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | |
| 19 | + readonly streamStatus: ( | |
| 20 | + input: GitStatusInput, | |
| 21 | + ) => Stream.Stream<GitStatusStreamEvent, GitManagerServiceError>; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export class GitStatusBroadcaster extends Context.Service< | |
| 25 | + GitStatusBroadcaster, | |
| 26 | + GitStatusBroadcasterShape | |
| 27 | +>()("t3/git/Services/GitStatusBroadcaster") {} | |
| @@ -6,7 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | * |
| 7 | 7 | * @module TextGeneration |
| 8 | 8 | */ |
| 9 | −import { ServiceMap } from "effect"; | |
| 9 | +import { Context } from "effect"; | |
| 10 | 10 | import type { Effect } from "effect"; |
| 11 | 11 | import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; |
| 12 | 12 | |
| @@ -118,6 +118,6 @@ export interface TextGenerationShape { · 105 unmodified lines | ||
| 118 | 118 | /** |
| 119 | 119 | * TextGeneration - Service tag for commit and PR text generation. |
| 120 | 120 | */ |
| 121 | −export class TextGeneration extends ServiceMap.Service<TextGeneration, TextGenerationShape>()( | |
| 121 | +export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()( | |
| 122 | 122 | "t3/git/Services/TextGeneration", |
| 123 | 123 | ) {} |
| @@ -0,0 +1,128 @@ | ||
| 1 | +import { Cause, Exit, Result, Schema } from "effect"; | |
| 2 | +import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | |
| 3 | +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | |
| 4 | + | |
| 5 | +export interface NormalizedGitHubPullRequestRecord { | |
| 6 | + readonly number: number; | |
| 7 | + readonly title: string; | |
| 8 | + readonly url: string; | |
| 9 | + readonly baseRefName: string; | |
| 10 | + readonly headRefName: string; | |
| 11 | + readonly state: "open" | "closed" | "merged"; | |
| 12 | + readonly updatedAt: string | null; | |
| 13 | + readonly isCrossRepository?: boolean; | |
| 14 | + readonly headRepositoryNameWithOwner?: string | null; | |
| 15 | + readonly headRepositoryOwnerLogin?: string | null; | |
| 16 | +} | |
| 17 | + | |
| 18 | +const GitHubPullRequestSchema = Schema.Struct({ | |
| 19 | + number: PositiveInt, | |
| 20 | + title: TrimmedNonEmptyString, | |
| 21 | + url: TrimmedNonEmptyString, | |
| 22 | + baseRefName: TrimmedNonEmptyString, | |
| 23 | + headRefName: TrimmedNonEmptyString, | |
| 24 | + state: Schema.optional(Schema.NullOr(Schema.String)), | |
| 25 | + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 26 | + updatedAt: Schema.optional(Schema.NullOr(Schema.String)), | |
| 27 | + isCrossRepository: Schema.optional(Schema.Boolean), | |
| 28 | + headRepository: Schema.optional( | |
| 29 | + Schema.NullOr( | |
| 30 | + Schema.Struct({ | |
| 31 | + nameWithOwner: Schema.String, | |
| 32 | + }), | |
| 33 | + ), | |
| 34 | + ), | |
| 35 | + headRepositoryOwner: Schema.optional( | |
| 36 | + Schema.NullOr( | |
| 37 | + Schema.Struct({ | |
| 38 | + login: Schema.String, | |
| 39 | + }), | |
| 40 | + ), | |
| 41 | + ), | |
| 42 | +}); | |
| 43 | + | |
| 44 | +function trimOptionalString(value: string | null | undefined): string | null { | |
| 45 | + const trimmed = value?.trim() ?? ""; | |
| 46 | + return trimmed.length > 0 ? trimmed : null; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function normalizeGitHubPullRequestState(input: { | |
| 50 | + state?: string | null | undefined; | |
| 51 | + mergedAt?: string | null | undefined; | |
| 52 | +}): "open" | "closed" | "merged" { | |
| 53 | + const normalizedState = input.state?.trim().toUpperCase(); | |
| 54 | + if ( | |
| 55 | + (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) || | |
| 56 | + normalizedState === "MERGED" | |
| 57 | + ) { | |
| 58 | + return "merged"; | |
| 59 | + } | |
| 60 | + if (normalizedState === "CLOSED") { | |
| 61 | + return "closed"; | |
| 62 | + } | |
| 63 | + return "open"; | |
| 64 | +} | |
| 65 | + | |
| 66 | +function normalizeGitHubPullRequestRecord( | |
| 67 | + raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>, | |
| 68 | +): NormalizedGitHubPullRequestRecord { | |
| 69 | + const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner); | |
| 70 | + const headRepositoryOwnerLogin = | |
| 71 | + trimOptionalString(raw.headRepositoryOwner?.login) ?? | |
| 72 | + (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") | |
| 73 | + ? (headRepositoryNameWithOwner.split("/")[0] ?? null) | |
| 74 | + : null); | |
| 75 | + | |
| 76 | + return { | |
| 77 | + number: raw.number, | |
| 78 | + title: raw.title, | |
| 79 | + url: raw.url, | |
| 80 | + baseRefName: raw.baseRefName, | |
| 81 | + headRefName: raw.headRefName, | |
| 82 | + state: normalizeGitHubPullRequestState(raw), | |
| 83 | + updatedAt: | |
| 84 | + typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null, | |
| 85 | + ...(typeof raw.isCrossRepository === "boolean" | |
| 86 | + ? { isCrossRepository: raw.isCrossRepository } | |
| 87 | + : {}), | |
| 88 | + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | |
| 89 | + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | |
| 90 | + }; | |
| 91 | +} | |
| 92 | + | |
| 93 | +const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown)); | |
| 94 | +const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); | |
| 95 | +const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); | |
| 96 | + | |
| 97 | +export const formatGitHubJsonDecodeError = formatSchemaError; | |
| 98 | + | |
| 99 | +export function decodeGitHubPullRequestListJson( | |
| 100 | + raw: string, | |
| 101 | +): Result.Result< | |
| 102 | + ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 103 | + Cause.Cause<Schema.SchemaError> | |
| 104 | +> { | |
| 105 | + const result = decodeGitHubPullRequestList(raw); | |
| 106 | + if (Result.isSuccess(result)) { | |
| 107 | + const pullRequests: NormalizedGitHubPullRequestRecord[] = []; | |
| 108 | + for (const entry of result.success) { | |
| 109 | + const decodedEntry = decodeGitHubPullRequestEntry(entry); | |
| 110 | + if (Exit.isFailure(decodedEntry)) { | |
| 111 | + continue; | |
| 112 | + } | |
| 113 | + pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value)); | |
| 114 | + } | |
| 115 | + return Result.succeed(pullRequests); | |
| 116 | + } | |
| 117 | + return Result.fail(result.failure); | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function decodeGitHubPullRequestJson( | |
| 121 | + raw: string, | |
| 122 | +): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> { | |
| 123 | + const result = decodeGitHubPullRequest(raw); | |
| 124 | + if (Result.isSuccess(result)) { | |
| 125 | + return Result.succeed(normalizeGitHubPullRequestRecord(result.success)); | |
| 126 | + } | |
| 127 | + return Result.fail(result.failure); | |
| 128 | +} | |
| @@ -0,0 +1,27 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | + | |
| 3 | +import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | |
| 4 | + | |
| 5 | +describe("http dev routing", () => { | |
| 6 | + it("treats localhost and loopback addresses as local", () => { | |
| 7 | + expect(isLoopbackHostname("127.0.0.1")).toBe(true); | |
| 8 | + expect(isLoopbackHostname("localhost")).toBe(true); | |
| 9 | + expect(isLoopbackHostname("::1")).toBe(true); | |
| 10 | + expect(isLoopbackHostname("[::1]")).toBe(true); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("does not treat LAN addresses as local", () => { | |
| 14 | + expect(isLoopbackHostname("192.168.86.35")).toBe(false); | |
| 15 | + expect(isLoopbackHostname("10.0.0.24")).toBe(false); | |
| 16 | + expect(isLoopbackHostname("example.local")).toBe(false); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it("preserves path and query when redirecting to the dev server", () => { | |
| 20 | + const devUrl = new URL("http://127.0.0.1:5173/"); | |
| 21 | + const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token"); | |
| 22 | + | |
| 23 | + expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe( | |
| 24 | + "http://127.0.0.1:5173/pair?token=test-token", | |
| 25 | + ); | |
| 26 | + }); | |
| 27 | +}); | |
| @@ -1,23 +1,133 @@ | ||
| 1 | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | −import { Effect, FileSystem, Option, Path } from "effect"; | |
| 3 | −import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 2 | +import { Data, Effect, FileSystem, Option, Path } from "effect"; | |
| 3 | +import { cast } from "effect/Function"; | |
| 4 | +import { | |
| 5 | + HttpBody, | |
| 6 | + HttpClient, | |
| 7 | + HttpClientResponse, | |
| 8 | + HttpRouter, | |
| 9 | + HttpServerResponse, | |
| 10 | + HttpServerRequest, | |
| 11 | +} from "effect/unstable/http"; | |
| 12 | +import { OtlpTracer } from "effect/unstable/observability"; | |
| 4 | 13 | |
| 5 | 14 | import { |
| 6 | 15 | ATTACHMENTS_ROUTE_PREFIX, |
| 7 | 16 | normalizeAttachmentRelativePath, |
| 8 | 17 | resolveAttachmentRelativePath, |
| 9 | −} from "./attachmentPaths"; | |
| 10 | −import { resolveAttachmentPathById } from "./attachmentStore"; | |
| 11 | −import { ServerConfig } from "./config"; | |
| 12 | −import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; | |
| 18 | +} from "./attachmentPaths.ts"; | |
| 19 | +import { resolveAttachmentPathById } from "./attachmentStore.ts"; | |
| 20 | +import { resolveStaticDir, ServerConfig } from "./config.ts"; | |
| 21 | +import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; | |
| 22 | +import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; | |
| 23 | +import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts"; | |
| 24 | +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | |
| 25 | +import { respondToAuthError } from "./auth/http.ts"; | |
| 26 | +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | |
| 13 | 27 | |
| 14 | 28 | const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; |
| 15 | 29 | const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`; |
| 30 | +const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | |
| 31 | +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | |
| 32 | + | |
| 33 | +export const browserApiCorsLayer = HttpRouter.cors({ | |
| 34 | + allowedMethods: ["GET", "POST", "OPTIONS"], | |
| 35 | + allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], | |
| 36 | + maxAge: 600, | |
| 37 | +}); | |
| 38 | + | |
| 39 | +export function isLoopbackHostname(hostname: string): boolean { | |
| 40 | + const normalizedHostname = hostname | |
| 41 | + .trim() | |
| 42 | + .toLowerCase() | |
| 43 | + .replace(/^\[(.*)\]$/, "$1"); | |
| 44 | + return LOOPBACK_HOSTNAMES.has(normalizedHostname); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { | |
| 48 | + const redirectUrl = new URL(devUrl.toString()); | |
| 49 | + redirectUrl.pathname = requestUrl.pathname; | |
| 50 | + redirectUrl.search = requestUrl.search; | |
| 51 | + redirectUrl.hash = requestUrl.hash; | |
| 52 | + return redirectUrl.toString(); | |
| 53 | +} | |
| 54 | + | |
| 55 | +const requireAuthenticatedRequest = Effect.gen(function* () { | |
| 56 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 57 | + const serverAuth = yield* ServerAuth; | |
| 58 | + yield* serverAuth.authenticateHttpRequest(request); | |
| 59 | +}); | |
| 60 | + | |
| 61 | +export const serverEnvironmentRouteLayer = HttpRouter.add( | |
| 62 | + "GET", | |
| 63 | + "/.well-known/t3/environment", | |
| 64 | + Effect.gen(function* () { | |
| 65 | + const descriptor = yield* Effect.service(ServerEnvironment).pipe( | |
| 66 | + Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), | |
| 67 | + ); | |
| 68 | + return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 }); | |
| 69 | + }), | |
| 70 | +); | |
| 71 | + | |
| 72 | +class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ | |
| 73 | + readonly cause: unknown; | |
| 74 | + readonly bodyJson: OtlpTracer.TraceData; | |
| 75 | +}> {} | |
| 76 | + | |
| 77 | +export const otlpTracesProxyRouteLayer = HttpRouter.add( | |
| 78 | + "POST", | |
| 79 | + OTLP_TRACES_PROXY_PATH, | |
| 80 | + Effect.gen(function* () { | |
| 81 | + yield* requireAuthenticatedRequest; | |
| 82 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 83 | + const config = yield* ServerConfig; | |
| 84 | + const otlpTracesUrl = config.otlpTracesUrl; | |
| 85 | + const browserTraceCollector = yield* BrowserTraceCollector; | |
| 86 | + const httpClient = yield* HttpClient.HttpClient; | |
| 87 | + const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json); | |
| 88 | + | |
| 89 | + yield* Effect.try({ | |
| 90 | + try: () => decodeOtlpTraceRecords(bodyJson), | |
| 91 | + catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }), | |
| 92 | + }).pipe( | |
| 93 | + Effect.flatMap((records) => browserTraceCollector.record(records)), | |
| 94 | + Effect.catch((cause) => | |
| 95 | + Effect.logWarning("Failed to decode browser OTLP traces", { | |
| 96 | + cause, | |
| 97 | + bodyJson, | |
| 98 | + }), | |
| 99 | + ), | |
| 100 | + ); | |
| 101 | + | |
| 102 | + if (otlpTracesUrl === undefined) { | |
| 103 | + return HttpServerResponse.empty({ status: 204 }); | |
| 104 | + } | |
| 105 | + | |
| 106 | + return yield* httpClient | |
| 107 | + .post(otlpTracesUrl, { | |
| 108 | + body: HttpBody.jsonUnsafe(bodyJson), | |
| 109 | + }) | |
| 110 | + .pipe( | |
| 111 | + Effect.flatMap(HttpClientResponse.filterStatusOk), | |
| 112 | + Effect.as(HttpServerResponse.empty({ status: 204 })), | |
| 113 | + Effect.tapError((cause) => | |
| 114 | + Effect.logWarning("Failed to export browser OTLP traces", { | |
| 115 | + cause, | |
| 116 | + otlpTracesUrl, | |
| 117 | + }), | |
| 118 | + ), | |
| 119 | + Effect.catch(() => | |
| 120 | + Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), | |
| 121 | + ), | |
| 122 | + ); | |
| 123 | + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 124 | +); | |
| 16 | 125 | |
| 17 | 126 | export const attachmentsRouteLayer = HttpRouter.add( |
| 18 | 127 | "GET", |
| 19 | 128 | `${ATTACHMENTS_ROUTE_PREFIX}/*`, |
| 20 | 129 | Effect.gen(function* () { |
| 130 | + yield* requireAuthenticatedRequest; | |
| 21 | 131 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 22 | 132 | const url = HttpServerRequest.toURL(request); |
| 23 | 133 | if (Option.isNone(url)) { |
| @@ -66,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( · 42 unmodified lines | ||
| 66 | 176 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 67 | 177 | ), |
| 68 | 178 | ); |
| 69 | − }), | |
| 179 | + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 70 | 180 | ); |
| 71 | 181 | |
| 72 | 182 | export const projectFaviconRouteLayer = HttpRouter.add( |
| 73 | 183 | "GET", |
| 74 | 184 | "/api/project-favicon", |
| 75 | 185 | Effect.gen(function* () { |
| 186 | + yield* requireAuthenticatedRequest; | |
| 76 | 187 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 77 | 188 | const url = HttpServerRequest.toURL(request); |
| 78 | 189 | if (Option.isNone(url)) { |
| @@ -106,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( · 27 unmodified lines | ||
| 106 | 217 | Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), |
| 107 | 218 | ), |
| 108 | 219 | ); |
| 109 | − }), | |
| 220 | + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), | |
| 110 | 221 | ); |
| 111 | 222 | |
| 112 | 223 | export const staticAndDevRouteLayer = HttpRouter.add( |
| @@ -115,16 +226,20 @@ export const staticAndDevRouteLayer = HttpRouter.add( · 2 unmodified lines | ||
| 115 | 226 | Effect.gen(function* () { |
| 116 | 227 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 117 | 228 | const url = HttpServerRequest.toURL(request); |
| 229 | + | |
| 118 | 230 | if (Option.isNone(url)) { |
| 119 | 231 | return HttpServerResponse.text("Bad Request", { status: 400 }); |
| 120 | 232 | } |
| 121 | 233 | |
| 122 | 234 | const config = yield* ServerConfig; |
| 123 | − if (config.devUrl) { | |
| 124 | − return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); | |
| 235 | + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { | |
| 236 | + return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { | |
| 237 | + status: 302, | |
| 238 | + }); | |
| 125 | 239 | } |
| 126 | 240 | |
| 127 | − if (!config.staticDir) { | |
| 241 | + const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); | |
| 242 | + if (!staticDir) { | |
| 128 | 243 | return HttpServerResponse.text("No static directory configured and no dev URL set.", { |
| 129 | 244 | status: 503, |
| 130 | 245 | }); |
| @@ -132,7 +247,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( · 1 unmodified line | ||
| 132 | 247 | |
| 133 | 248 | const fileSystem = yield* FileSystem.FileSystem; |
| 134 | 249 | const path = yield* Path.Path; |
| 135 | − const staticRoot = path.resolve(config.staticDir); | |
| 250 | + const staticRoot = path.resolve(staticDir); | |
| 136 | 251 | const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; |
| 137 | 252 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); |
| 138 | 253 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); |
| @@ -2,8 +2,8 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c · 1 unmodified line | ||
| 2 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 3 | import { assert, it } from "@effect/vitest"; |
| 4 | 4 | import { assertFailure } from "@effect/vitest/utils"; |
| 5 | −import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 6 | −import { ServerConfig } from "./config"; | |
| 5 | +import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; | |
| 6 | +import { ServerConfig } from "./config.ts"; | |
| 7 | 7 | |
| 8 | 8 | import { |
| 9 | 9 | DEFAULT_KEYBINDINGS, |
| @@ -13,7 +13,7 @@ import { · 3 unmodified lines | ||
| 13 | 13 | compileResolvedKeybindingRule, |
| 14 | 14 | compileResolvedKeybindingsConfig, |
| 15 | 15 | parseKeybindingShortcut, |
| 16 | −} from "./keybindings"; | |
| 16 | +} from "./keybindings.ts"; | |
| 17 | 17 | import { KeybindingsConfigError } from "@t3tools/contracts"; |
| 18 | 18 | |
| 19 | 19 | const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); |
| @@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { · 129 unmodified lines | ||
| 149 | 149 | }), |
| 150 | 150 | ); |
| 151 | 151 | |
| 152 | + it.effect("formats invalid resolved keybinding rules with the custom message", () => | |
| 153 | + Effect.sync(() => { | |
| 154 | + const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({ | |
| 155 | + key: "mod+shift+d+o", | |
| 156 | + command: "terminal.new", | |
| 157 | + }); | |
| 158 | + | |
| 159 | + if (result._tag !== "Failure") { | |
| 160 | + assert.fail("Expected invalid keybinding decode to fail"); | |
| 161 | + } | |
| 162 | + | |
| 163 | + const detail = Cause.pretty(result.cause); | |
| 164 | + assert.isTrue(detail.includes("Invalid keybinding rule")); | |
| 165 | + assert.isFalse(detail.includes("Invalid data")); | |
| 166 | + }), | |
| 167 | + ); | |
| 168 | + | |
| 152 | 169 | it.effect("bootstraps default keybindings when config file is missing", () => |
| 153 | 170 | Effect.gen(function* () { |
| 154 | 171 | const fs = yield* FileSystem.FileSystem; |
| @@ -19,7 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | THREAD_JUMP_KEYBINDING_COMMANDS, |
| 20 | 20 | type ServerConfigIssue, |
| 21 | 21 | } from "@t3tools/contracts"; |
| 22 | −import { Mutable } from "effect/Types"; | |
| 22 | +import type { Mutable } from "effect/Types"; | |
| 23 | 23 | import { |
| 24 | 24 | Array, |
| 25 | 25 | Cache, |
| @@ -39,12 +39,12 @@ import { · 13 unmodified lines | ||
| 39 | 39 | SchemaIssue, |
| 40 | 40 | SchemaTransformation, |
| 41 | 41 | Ref, |
| 42 | − ServiceMap, | |
| 42 | + Context, | |
| 43 | 43 | Scope, |
| 44 | 44 | Stream, |
| 45 | 45 | } from "effect"; |
| 46 | 46 | import * as Semaphore from "effect/Semaphore"; |
| 47 | −import { ServerConfig } from "./config"; | |
| 47 | +import { ServerConfig } from "./config.ts"; | |
| 48 | 48 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 49 | 49 | |
| 50 | 50 | type WhenToken = |
| @@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ · 10 unmodified lines | ||
| 61 | 61 | { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, |
| 62 | 62 | { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, |
| 63 | 63 | { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, |
| 64 | + { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, | |
| 64 | 65 | { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, |
| 65 | 66 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 66 | 67 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| @@ -322,7 +323,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( · 255 unmodified lines | ||
| 322 | 323 | Predicate.isNotNull, |
| 323 | 324 | () => |
| 324 | 325 | new SchemaIssue.InvalidValue(Option.some(rule), { |
| 325 | − title: "Invalid keybinding rule", | |
| 326 | + message: "Invalid keybinding rule", | |
| 326 | 327 | }), |
| 327 | 328 | ), |
| 328 | 329 | Effect.map((resolved) => resolved), |
| @@ -334,7 +335,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( · 5 unmodified lines | ||
| 334 | 335 | if (!key) { |
| 335 | 336 | return yield* Effect.fail( |
| 336 | 337 | new SchemaIssue.InvalidValue(Option.some(resolved), { |
| 337 | − title: "Resolved shortcut cannot be encoded to key string", | |
| 338 | + message: "Resolved shortcut cannot be encoded to key string", | |
| 338 | 339 | }), |
| 339 | 340 | ); |
| 340 | 341 | } |
| @@ -521,7 +522,7 @@ export interface KeybindingsShape { · 180 unmodified lines | ||
| 521 | 522 | /** |
| 522 | 523 | * Keybindings - Service tag for keybinding configuration operations. |
| 523 | 524 | */ |
| 524 | −export class Keybindings extends ServiceMap.Service<Keybindings, KeybindingsShape>()( | |
| 525 | +export class Keybindings extends Context.Service<Keybindings, KeybindingsShape>()( | |
| 525 | 526 | "t3/keybindings", |
| 526 | 527 | ) {} |
| 527 | 528 | |
| @@ -4,6 +4,8 @@ import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/obse · 3 unmodified lines | ||
| 4 | 4 | import { ServerConfig } from "../../config.ts"; |
| 5 | 5 | import { ServerLoggerLive } from "../../serverLogger.ts"; |
| 6 | 6 | import { makeLocalFileTracer } from "../LocalFileTracer.ts"; |
| 7 | +import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts"; | |
| 8 | +import { makeTraceSink } from "../TraceSink.ts"; | |
| 7 | 9 | |
| 8 | 10 | const otlpSerializationLayer = OtlpSerialization.layerJson; |
| 9 | 11 | |
| @@ -16,9 +18,14 @@ export const ObservabilityLive = Layer.unwrap( · 6 unmodified lines | ||
| 16 | 18 | Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled), |
| 17 | 19 | ); |
| 18 | 20 | |
| 19 | − const tracerLayer = Layer.effect( | |
| 20 | − Tracer.Tracer, | |
| 21 | + const tracerLayer = Layer.unwrap( | |
| 21 | 22 | Effect.gen(function* () { |
| 23 | + const sink = yield* makeTraceSink({ | |
| 24 | + filePath: config.serverTracePath, | |
| 25 | + maxBytes: config.traceMaxBytes, | |
| 26 | + maxFiles: config.traceMaxFiles, | |
| 27 | + batchWindowMs: config.traceBatchWindowMs, | |
| 28 | + }); | |
| 22 | 29 | const delegate = |
| 23 | 30 | config.otlpTracesUrl === undefined |
| 24 | 31 | ? undefined |
| @@ -34,13 +41,26 @@ export const ObservabilityLive = Layer.unwrap( · 9 unmodified lines | ||
| 34 | 41 | }, |
| 35 | 42 | }); |
| 36 | 43 | |
| 37 | − return yield* makeLocalFileTracer({ | |
| 44 | + const tracer = yield* makeLocalFileTracer({ | |
| 38 | 45 | filePath: config.serverTracePath, |
| 39 | 46 | maxBytes: config.traceMaxBytes, |
| 40 | 47 | maxFiles: config.traceMaxFiles, |
| 41 | 48 | batchWindowMs: config.traceBatchWindowMs, |
| 49 | + sink, | |
| 42 | 50 | ...(delegate ? { delegate } : {}), |
| 43 | 51 | }); |
| 52 | + | |
| 53 | + return Layer.mergeAll( | |
| 54 | + Layer.succeed(Tracer.Tracer, tracer), | |
| 55 | + Layer.succeed(BrowserTraceCollector, { | |
| 56 | + record: (records) => | |
| 57 | + Effect.sync(() => { | |
| 58 | + for (const record of records) { | |
| 59 | + sink.push(record); | |
| 60 | + } | |
| 61 | + }), | |
| 62 | + }), | |
| 63 | + ); | |
| 44 | 64 | }), |
| 45 | 65 | ).pipe(Layer.provideMerge(otlpSerializationLayer)); |
| 46 | 66 | |
| @@ -5,7 +5,7 @@ import path from "node:path"; · 4 unmodified lines | ||
| 5 | 5 | import { assert, describe, it } from "@effect/vitest"; |
| 6 | 6 | import { Effect, Layer, Logger, References, Tracer } from "effect"; |
| 7 | 7 | |
| 8 | −import type { TraceRecord } from "./TraceRecord.ts"; | |
| 8 | +import type { EffectTraceRecord } from "./TraceRecord.ts"; | |
| 9 | 9 | import { makeLocalFileTracer } from "./LocalFileTracer.ts"; |
| 10 | 10 | |
| 11 | 11 | const makeTestLayer = (tracePath: string) => |
| @@ -23,13 +23,13 @@ const makeTestLayer = (tracePath: string) => · 11 unmodified lines | ||
| 23 | 23 | Layer.succeed(References.MinimumLogLevel, "Info"), |
| 24 | 24 | ); |
| 25 | 25 | |
| 26 | −const readTraceRecords = (tracePath: string): Array<TraceRecord> => | |
| 26 | +const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> => | |
| 27 | 27 | fs |
| 28 | 28 | .readFileSync(tracePath, "utf8") |
| 29 | 29 | .trim() |
| 30 | 30 | .split("\n") |
| 31 | 31 | .filter((line) => line.length > 0) |
| 32 | − .map((line) => JSON.parse(line) as TraceRecord); | |
| 32 | + .map((line) => JSON.parse(line) as EffectTraceRecord); | |
| 33 | 33 | |
| 34 | 34 | describe("LocalFileTracer", () => { |
| 35 | 35 | it.effect("writes nested spans to disk and captures log messages as span events", () => |
| @@ -2,7 +2,8 @@ import type * as Exit from "effect/Exit"; · 1 unmodified line | ||
| 2 | 2 | import { Effect, Option, Tracer } from "effect"; |
| 3 | 3 | |
| 4 | 4 | import { spanToTraceRecord } from "./TraceRecord.ts"; |
| 5 | −import { makeTraceSink } from "./TraceSink.ts"; | |
| 5 | +import type { EffectTraceRecord } from "./TraceRecord.ts"; | |
| 6 | +import { makeTraceSink, type TraceSink } from "./TraceSink.ts"; | |
| 6 | 7 | |
| 7 | 8 | export interface LocalFileTracerOptions { |
| 8 | 9 | readonly filePath: string; |
| @@ -10,6 +11,7 @@ export interface LocalFileTracerOptions { · 1 unmodified line | ||
| 10 | 11 | readonly maxFiles: number; |
| 11 | 12 | readonly batchWindowMs: number; |
| 12 | 13 | readonly delegate?: Tracer.Tracer; |
| 14 | + readonly sink?: TraceSink; | |
| 13 | 15 | } |
| 14 | 16 | |
| 15 | 17 | class LocalFileSpan implements Tracer.Span { |
| @@ -26,12 +28,16 @@ class LocalFileSpan implements Tracer.Span { · 10 unmodified lines | ||
| 26 | 28 | status: Tracer.SpanStatus; |
| 27 | 29 | attributes: Map<string, unknown>; |
| 28 | 30 | events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>; |
| 31 | + private readonly delegate: Tracer.Span; | |
| 32 | + private readonly push: (record: EffectTraceRecord) => void; | |
| 29 | 33 | |
| 30 | 34 | constructor( |
| 31 | 35 | options: Parameters<Tracer.Tracer["span"]>[0], |
| 32 | − private readonly delegate: Tracer.Span, | |
| 33 | − private readonly push: (record: ReturnType<typeof spanToTraceRecord>) => void, | |
| 36 | + delegate: Tracer.Span, | |
| 37 | + push: (record: EffectTraceRecord) => void, | |
| 34 | 38 | ) { |
| 39 | + this.delegate = delegate; | |
| 40 | + this.push = push; | |
| 35 | 41 | this.name = delegate.name; |
| 36 | 42 | this.spanId = delegate.spanId; |
| 37 | 43 | this.traceId = delegate.traceId; |
| @@ -82,12 +88,14 @@ class LocalFileSpan implements Tracer.Span { · 44 unmodified lines | ||
| 82 | 88 | export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* ( |
| 83 | 89 | options: LocalFileTracerOptions, |
| 84 | 90 | ) { |
| 85 | − const sink = yield* makeTraceSink({ | |
| 86 | − filePath: options.filePath, | |
| 87 | − maxBytes: options.maxBytes, | |
| 88 | − maxFiles: options.maxFiles, | |
| 89 | − batchWindowMs: options.batchWindowMs, | |
| 90 | − }); | |
| 91 | + const sink = | |
| 92 | + options.sink ?? | |
| 93 | + (yield* makeTraceSink({ | |
| 94 | + filePath: options.filePath, | |
| 95 | + maxBytes: options.maxBytes, | |
| 96 | + maxFiles: options.maxFiles, | |
| 97 | + batchWindowMs: options.batchWindowMs, | |
| 98 | + })); | |
| 91 | 99 | |
| 92 | 100 | const delegate = |
| 93 | 101 | options.delegate ?? |
| @@ -0,0 +1,13 @@ | ||
| 1 | +import { Context } from "effect"; | |
| 2 | +import type { Effect } from "effect"; | |
| 3 | + | |
| 4 | +import type { TraceRecord } from "../TraceRecord.ts"; | |
| 5 | + | |
| 6 | +export interface BrowserTraceCollectorShape { | |
| 7 | + readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export class BrowserTraceCollector extends Context.Service< | |
| 11 | + BrowserTraceCollector, | |
| 12 | + BrowserTraceCollectorShape | |
| 13 | +>()("t3/observability/Services/BrowserTraceCollector") {} | |
| @@ -1,29 +1,37 @@ | ||
| 1 | 1 | import { Cause, Exit, Option, Tracer } from "effect"; |
| 2 | 2 | |
| 3 | 3 | import { compactTraceAttributes } from "./Attributes.ts"; |
| 4 | +import { OtlpResource, OtlpTracer } from "effect/unstable/observability"; | |
| 4 | 5 | |
| 5 | −export interface TraceRecord { | |
| 6 | − readonly type: "effect-span"; | |
| 6 | +interface TraceRecordEvent { | |
| 7 | + readonly name: string; | |
| 8 | + readonly timeUnixNano: string; | |
| 9 | + readonly attributes: Readonly<Record<string, unknown>>; | |
| 10 | +} | |
| 11 | + | |
| 12 | +interface TraceRecordLink { | |
| 13 | + readonly traceId: string; | |
| 14 | + readonly spanId: string; | |
| 15 | + readonly attributes: Readonly<Record<string, unknown>>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +interface BaseTraceRecord { | |
| 7 | 19 | readonly name: string; |
| 20 | + readonly kind: string; | |
| 8 | 21 | readonly traceId: string; |
| 9 | 22 | readonly spanId: string; |
| 10 | 23 | readonly parentSpanId?: string; |
| 11 | 24 | readonly sampled: boolean; |
| 12 | − readonly kind: Tracer.SpanKind; | |
| 13 | 25 | readonly startTimeUnixNano: string; |
| 14 | 26 | readonly endTimeUnixNano: string; |
| 15 | 27 | readonly durationMs: number; |
| 16 | 28 | readonly attributes: Readonly<Record<string, unknown>>; |
| 17 | − readonly events: ReadonlyArray<{ | |
| 18 | − readonly name: string; | |
| 19 | − readonly timeUnixNano: string; | |
| 20 | − readonly attributes: Readonly<Record<string, unknown>>; | |
| 21 | − }>; | |
| 22 | − readonly links: ReadonlyArray<{ | |
| 23 | − readonly traceId: string; | |
| 24 | − readonly spanId: string; | |
| 25 | − readonly attributes: Readonly<Record<string, unknown>>; | |
| 26 | − }>; | |
| 29 | + readonly events: ReadonlyArray<TraceRecordEvent>; | |
| 30 | + readonly links: ReadonlyArray<TraceRecordLink>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface EffectTraceRecord extends BaseTraceRecord { | |
| 34 | + readonly type: "effect-span"; | |
| 27 | 35 | readonly exit: |
| 28 | 36 | | { |
| 29 | 37 | readonly _tag: "Success"; |
| @@ -38,6 +46,29 @@ export interface TraceRecord { · 8 unmodified lines | ||
| 38 | 46 | }; |
| 39 | 47 | } |
| 40 | 48 | |
| 49 | +interface OtlpTraceRecord extends BaseTraceRecord { | |
| 50 | + readonly type: "otlp-span"; | |
| 51 | + readonly resourceAttributes: Readonly<Record<string, unknown>>; | |
| 52 | + readonly scope: Readonly<{ | |
| 53 | + readonly name?: string; | |
| 54 | + readonly version?: string; | |
| 55 | + readonly attributes: Readonly<Record<string, unknown>>; | |
| 56 | + }>; | |
| 57 | + readonly status?: | |
| 58 | + | { | |
| 59 | + readonly code?: string; | |
| 60 | + readonly message?: string; | |
| 61 | + } | |
| 62 | + | undefined; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export type TraceRecord = EffectTraceRecord | OtlpTraceRecord; | |
| 66 | + | |
| 67 | +type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number]; | |
| 68 | +type OtlpSpanEvent = OtlpSpan["events"][number]; | |
| 69 | +type OtlpSpanLink = OtlpSpan["links"][number]; | |
| 70 | +type OtlpSpanStatus = OtlpSpan["status"]; | |
| 71 | + | |
| 41 | 72 | interface SerializableSpan { |
| 42 | 73 | readonly name: string; |
| 43 | 74 | readonly traceId: string; |
| @@ -53,7 +84,7 @@ interface SerializableSpan { · 9 unmodified lines | ||
| 53 | 84 | >; |
| 54 | 85 | } |
| 55 | 86 | |
| 56 | −function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] { | |
| 87 | +function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] { | |
| 57 | 88 | if (Exit.isSuccess(exit)) { |
| 58 | 89 | return { _tag: "Success" }; |
| 59 | 90 | } |
| @@ -69,7 +100,7 @@ function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] · 9 unmodified lines | ||
| 69 | 100 | }; |
| 70 | 101 | } |
| 71 | 102 | |
| 72 | −export function spanToTraceRecord(span: SerializableSpan): TraceRecord { | |
| 103 | +export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { | |
| 73 | 104 | const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>; |
| 74 | 105 | const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; |
| 75 | 106 | |
| @@ -98,3 +129,159 @@ export function spanToTraceRecord(span: SerializableSpan): TraceRecord { · 22 unmodified lines | ||
| 98 | 129 | exit: formatTraceExit(status.exit), |
| 99 | 130 | }; |
| 100 | 131 | } |
| 132 | + | |
| 133 | +const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = { | |
| 134 | + 1: "internal", | |
| 135 | + 2: "server", | |
| 136 | + 3: "client", | |
| 137 | + 4: "producer", | |
| 138 | + 5: "consumer", | |
| 139 | +}; | |
| 140 | + | |
| 141 | +export function decodeOtlpTraceRecords( | |
| 142 | + payload: OtlpTracer.TraceData, | |
| 143 | +): ReadonlyArray<OtlpTraceRecord> { | |
| 144 | + const records: Array<OtlpTraceRecord> = []; | |
| 145 | + | |
| 146 | + for (const resourceSpan of payload.resourceSpans) { | |
| 147 | + const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []); | |
| 148 | + | |
| 149 | + for (const scopeSpan of resourceSpan.scopeSpans) { | |
| 150 | + for (const span of scopeSpan.spans) { | |
| 151 | + records.push( | |
| 152 | + otlpSpanToTraceRecord({ | |
| 153 | + resourceAttributes, | |
| 154 | + scopeAttributes: decodeAttributes( | |
| 155 | + "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes) | |
| 156 | + ? scopeSpan.scope.attributes | |
| 157 | + : [], | |
| 158 | + ), | |
| 159 | + scopeName: scopeSpan.scope.name, | |
| 160 | + scopeVersion: | |
| 161 | + "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string" | |
| 162 | + ? scopeSpan.scope.version | |
| 163 | + : undefined, | |
| 164 | + span, | |
| 165 | + }), | |
| 166 | + ); | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + return records; | |
| 172 | +} | |
| 173 | + | |
| 174 | +function otlpSpanToTraceRecord(input: { | |
| 175 | + readonly resourceAttributes: Readonly<Record<string, unknown>>; | |
| 176 | + readonly scopeAttributes: Readonly<Record<string, unknown>>; | |
| 177 | + readonly scopeName: string | undefined; | |
| 178 | + readonly scopeVersion: string | undefined; | |
| 179 | + readonly span: OtlpSpan; | |
| 180 | +}): OtlpTraceRecord { | |
| 181 | + return { | |
| 182 | + type: "otlp-span", | |
| 183 | + name: input.span.name, | |
| 184 | + traceId: input.span.traceId, | |
| 185 | + spanId: input.span.spanId, | |
| 186 | + ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}), | |
| 187 | + sampled: true, | |
| 188 | + kind: normalizeSpanKind(input.span.kind), | |
| 189 | + startTimeUnixNano: input.span.startTimeUnixNano, | |
| 190 | + endTimeUnixNano: input.span.endTimeUnixNano, | |
| 191 | + durationMs: | |
| 192 | + Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) / | |
| 193 | + 1_000_000, | |
| 194 | + attributes: decodeAttributes(input.span.attributes), | |
| 195 | + resourceAttributes: input.resourceAttributes, | |
| 196 | + scope: { | |
| 197 | + ...(input.scopeName ? { name: input.scopeName } : {}), | |
| 198 | + ...(input.scopeVersion ? { version: input.scopeVersion } : {}), | |
| 199 | + attributes: input.scopeAttributes, | |
| 200 | + }, | |
| 201 | + events: decodeEvents(input.span.events), | |
| 202 | + links: decodeLinks(input.span.links), | |
| 203 | + status: decodeStatus(input.span.status), | |
| 204 | + }; | |
| 205 | +} | |
| 206 | + | |
| 207 | +function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] { | |
| 208 | + const code = String(input.code); | |
| 209 | + const message = input.message; | |
| 210 | + | |
| 211 | + return { | |
| 212 | + code, | |
| 213 | + ...(message ? { message } : {}), | |
| 214 | + }; | |
| 215 | +} | |
| 216 | + | |
| 217 | +function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> { | |
| 218 | + return input.map((current) => ({ | |
| 219 | + name: current.name, | |
| 220 | + timeUnixNano: current.timeUnixNano, | |
| 221 | + attributes: decodeAttributes(current.attributes), | |
| 222 | + })); | |
| 223 | +} | |
| 224 | + | |
| 225 | +function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> { | |
| 226 | + return input.flatMap((current) => { | |
| 227 | + const traceId = current.traceId; | |
| 228 | + const spanId = current.spanId; | |
| 229 | + return { | |
| 230 | + traceId, | |
| 231 | + spanId, | |
| 232 | + attributes: decodeAttributes(current.attributes), | |
| 233 | + }; | |
| 234 | + }); | |
| 235 | +} | |
| 236 | + | |
| 237 | +function decodeAttributes( | |
| 238 | + input: ReadonlyArray<OtlpResource.KeyValue>, | |
| 239 | +): Readonly<Record<string, unknown>> { | |
| 240 | + const entries: Record<string, unknown> = {}; | |
| 241 | + | |
| 242 | + for (const attribute of input) { | |
| 243 | + entries[attribute.key] = decodeValue(attribute.value); | |
| 244 | + } | |
| 245 | + | |
| 246 | + return compactTraceAttributes(entries); | |
| 247 | +} | |
| 248 | + | |
| 249 | +function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown { | |
| 250 | + if (input == null) { | |
| 251 | + return null; | |
| 252 | + } | |
| 253 | + if ("stringValue" in input) { | |
| 254 | + return input.stringValue; | |
| 255 | + } | |
| 256 | + if ("boolValue" in input) { | |
| 257 | + return input.boolValue; | |
| 258 | + } | |
| 259 | + if ("intValue" in input) { | |
| 260 | + return input.intValue; | |
| 261 | + } | |
| 262 | + if ("doubleValue" in input) { | |
| 263 | + return input.doubleValue; | |
| 264 | + } | |
| 265 | + if ("bytesValue" in input) { | |
| 266 | + return input.bytesValue; | |
| 267 | + } | |
| 268 | + if (input.arrayValue) { | |
| 269 | + return input.arrayValue.values.map((entry) => decodeValue(entry)); | |
| 270 | + } | |
| 271 | + if (input.kvlistValue) { | |
| 272 | + return decodeAttributes(input.kvlistValue.values); | |
| 273 | + } | |
| 274 | + return null; | |
| 275 | +} | |
| 276 | + | |
| 277 | +function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] { | |
| 278 | + return SPAN_KIND_MAP[input] || "internal"; | |
| 279 | +} | |
| 280 | + | |
| 281 | +function parseBigInt(input: string): bigint { | |
| 282 | + try { | |
| 283 | + return BigInt(input); | |
| 284 | + } catch { | |
| 285 | + return 0n; | |
| 286 | + } | |
| 287 | +} | |
| @@ -8,7 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | launchDetached, |
| 9 | 9 | resolveAvailableEditors, |
| 10 | 10 | resolveEditorLaunch, |
| 11 | −} from "./open"; | |
| 11 | +} from "./open.ts"; | |
| 12 | 12 | |
| 13 | 13 | it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { |
| 14 | 14 | it.effect("returns commands for command-based editors", () => |
| @@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 1 unmodified line | ||
| 16 | 16 | const antigravityLaunch = yield* resolveEditorLaunch( |
| 17 | 17 | { cwd: "/tmp/workspace", editor: "antigravity" }, |
| 18 | 18 | "darwin", |
| 19 | + { PATH: "" }, | |
| 19 | 20 | ); |
| 20 | 21 | assert.deepEqual(antigravityLaunch, { |
| 21 | 22 | command: "agy", |
| @@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines | ||
| 25 | 26 | const cursorLaunch = yield* resolveEditorLaunch( |
| 26 | 27 | { cwd: "/tmp/workspace", editor: "cursor" }, |
| 27 | 28 | "darwin", |
| 29 | + { PATH: "" }, | |
| 28 | 30 | ); |
| 29 | 31 | assert.deepEqual(cursorLaunch, { |
| 30 | 32 | command: "cursor", |
| @@ -40,9 +42,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 9 unmodified lines | ||
| 40 | 42 | args: ["/tmp/workspace"], |
| 41 | 43 | }); |
| 42 | 44 | |
| 45 | + const kiroLaunch = yield* resolveEditorLaunch( | |
| 46 | + { cwd: "/tmp/workspace", editor: "kiro" }, | |
| 47 | + "darwin", | |
| 48 | + { PATH: "" }, | |
| 49 | + ); | |
| 50 | + assert.deepEqual(kiroLaunch, { | |
| 51 | + command: "kiro", | |
| 52 | + args: ["ide", "/tmp/workspace"], | |
| 53 | + }); | |
| 54 | + | |
| 43 | 55 | const vscodeLaunch = yield* resolveEditorLaunch( |
| 44 | 56 | { cwd: "/tmp/workspace", editor: "vscode" }, |
| 45 | 57 | "darwin", |
| 58 | + { PATH: "" }, | |
| 46 | 59 | ); |
| 47 | 60 | assert.deepEqual(vscodeLaunch, { |
| 48 | 61 | command: "code", |
| @@ -70,6 +83,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 21 unmodified lines | ||
| 70 | 83 | const zedLaunch = yield* resolveEditorLaunch( |
| 71 | 84 | { cwd: "/tmp/workspace", editor: "zed" }, |
| 72 | 85 | "darwin", |
| 86 | + { PATH: "" }, | |
| 73 | 87 | ); |
| 74 | 88 | assert.deepEqual(zedLaunch, { |
| 75 | 89 | command: "zed", |
| @@ -92,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 16 unmodified lines | ||
| 92 | 106 | const lineOnly = yield* resolveEditorLaunch( |
| 93 | 107 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" }, |
| 94 | 108 | "darwin", |
| 109 | + { PATH: "" }, | |
| 95 | 110 | ); |
| 96 | 111 | assert.deepEqual(lineOnly, { |
| 97 | 112 | command: "cursor", |
| @@ -101,6 +116,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines | ||
| 101 | 116 | const lineAndColumn = yield* resolveEditorLaunch( |
| 102 | 117 | { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" }, |
| 103 | 118 | "darwin", |
| 119 | + { PATH: "" }, | |
| 104 | 120 | ); |
| 105 | 121 | assert.deepEqual(lineAndColumn, { |
| 106 | 122 | command: "cursor", |
| @@ -116,9 +132,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 9 unmodified lines | ||
| 116 | 132 | args: ["--goto", "/tmp/workspace/src/open.ts:71:5"], |
| 117 | 133 | }); |
| 118 | 134 | |
| 135 | + const kiroLineAndColumn = yield* resolveEditorLaunch( | |
| 136 | + { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" }, | |
| 137 | + "darwin", | |
| 138 | + { PATH: "" }, | |
| 139 | + ); | |
| 140 | + assert.deepEqual(kiroLineAndColumn, { | |
| 141 | + command: "kiro", | |
| 142 | + args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"], | |
| 143 | + }); | |
| 144 | + | |
| 119 | 145 | const vscodeLineAndColumn = yield* resolveEditorLaunch( |
| 120 | 146 | { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" }, |
| 121 | 147 | "darwin", |
| 148 | + { PATH: "" }, | |
| 122 | 149 | ); |
| 123 | 150 | assert.deepEqual(vscodeLineAndColumn, { |
| 124 | 151 | command: "code", |
| @@ -146,6 +173,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 21 unmodified lines | ||
| 146 | 173 | const zedLineAndColumn = yield* resolveEditorLaunch( |
| 147 | 174 | { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" }, |
| 148 | 175 | "darwin", |
| 176 | + { PATH: "" }, | |
| 149 | 177 | ); |
| 150 | 178 | assert.deepEqual(zedLineAndColumn, { |
| 151 | 179 | command: "zed", |
| @@ -155,6 +183,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines | ||
| 155 | 183 | const zedLineOnly = yield* resolveEditorLaunch( |
| 156 | 184 | { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" }, |
| 157 | 185 | "darwin", |
| 186 | + { PATH: "" }, | |
| 158 | 187 | ); |
| 159 | 188 | assert.deepEqual(zedLineOnly, { |
| 160 | 189 | command: "zed", |
| @@ -181,11 +210,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 20 unmodified lines | ||
| 181 | 210 | }), |
| 182 | 211 | ); |
| 183 | 212 | |
| 213 | + it.effect("falls back to zeditor when zed is not installed", () => | |
| 214 | + Effect.gen(function* () { | |
| 215 | + const fs = yield* FileSystem.FileSystem; | |
| 216 | + const path = yield* Path.Path; | |
| 217 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" }); | |
| 218 | + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); | |
| 219 | + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); | |
| 220 | + | |
| 221 | + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { | |
| 222 | + PATH: dir, | |
| 223 | + }); | |
| 224 | + | |
| 225 | + assert.deepEqual(result, { | |
| 226 | + command: "zeditor", | |
| 227 | + args: ["/tmp/workspace"], | |
| 228 | + }); | |
| 229 | + }), | |
| 230 | + ); | |
| 231 | + | |
| 232 | + it.effect("falls back to the primary command when no alias is installed", () => | |
| 233 | + Effect.gen(function* () { | |
| 234 | + const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", { | |
| 235 | + PATH: "", | |
| 236 | + }); | |
| 237 | + assert.deepEqual(result, { | |
| 238 | + command: "zed", | |
| 239 | + args: ["/tmp/workspace"], | |
| 240 | + }); | |
| 241 | + }), | |
| 242 | + ); | |
| 243 | + | |
| 184 | 244 | it.effect("maps file-manager editor to OS open commands", () => |
| 185 | 245 | Effect.gen(function* () { |
| 186 | 246 | const launch1 = yield* resolveEditorLaunch( |
| 187 | 247 | { cwd: "/tmp/workspace", editor: "file-manager" }, |
| 188 | 248 | "darwin", |
| 249 | + { PATH: "" }, | |
| 189 | 250 | ); |
| 190 | 251 | assert.deepEqual(launch1, { |
| 191 | 252 | command: "open", |
| @@ -195,6 +256,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines | ||
| 195 | 256 | const launch2 = yield* resolveEditorLaunch( |
| 196 | 257 | { cwd: "C:\\workspace", editor: "file-manager" }, |
| 197 | 258 | "win32", |
| 259 | + { PATH: "" }, | |
| 198 | 260 | ); |
| 199 | 261 | assert.deepEqual(launch2, { |
| 200 | 262 | command: "explorer", |
| @@ -204,6 +266,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines | ||
| 204 | 266 | const launch3 = yield* resolveEditorLaunch( |
| 205 | 267 | { cwd: "/tmp/workspace", editor: "file-manager" }, |
| 206 | 268 | "linux", |
| 269 | + { PATH: "" }, | |
| 207 | 270 | ); |
| 208 | 271 | assert.deepEqual(launch3, { |
| 209 | 272 | command: "xdg-open", |
| @@ -311,6 +374,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 101 unmodified lines | ||
| 311 | 374 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); |
| 312 | 375 | |
| 313 | 376 | yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n"); |
| 377 | + yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n"); | |
| 314 | 378 | yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n"); |
| 315 | 379 | yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n"); |
| 316 | 380 | yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ"); |
| @@ -318,7 +382,32 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 1 unmodified line | ||
| 318 | 382 | PATH: dir, |
| 319 | 383 | PATHEXT: ".COM;.EXE;.BAT;.CMD", |
| 320 | 384 | }); |
| 321 | − assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]); | |
| 385 | + assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]); | |
| 322 | 386 | }), |
| 323 | 387 | ); |
| 388 | + | |
| 389 | + it.effect("includes zed when only the zeditor command is installed", () => | |
| 390 | + Effect.gen(function* () { | |
| 391 | + const fs = yield* FileSystem.FileSystem; | |
| 392 | + const path = yield* Path.Path; | |
| 393 | + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" }); | |
| 394 | + | |
| 395 | + yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n"); | |
| 396 | + yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n"); | |
| 397 | + yield* fs.chmod(path.join(dir, "zeditor"), 0o755); | |
| 398 | + yield* fs.chmod(path.join(dir, "xdg-open"), 0o755); | |
| 399 | + | |
| 400 | + const editors = resolveAvailableEditors("linux", { | |
| 401 | + PATH: dir, | |
| 402 | + }); | |
| 403 | + assert.deepEqual(editors, ["zed", "file-manager"]); | |
| 404 | + }), | |
| 405 | + ); | |
| 406 | + | |
| 407 | + it("omits file-manager when the platform opener is unavailable", () => { | |
| 408 | + const editors = resolveAvailableEditors("linux", { | |
| 409 | + PATH: "", | |
| 410 | + }); | |
| 411 | + assert.deepEqual(editors, []); | |
| 412 | + }); | |
| 324 | 413 | }); |
| @@ -7,17 +7,17 @@ · 6 unmodified lines | ||
| 7 | 7 | * @module Open |
| 8 | 8 | */ |
| 9 | 9 | import { spawn } from "node:child_process"; |
| 10 | −import { accessSync, constants, statSync } from "node:fs"; | |
| 11 | −import { extname, join } from "node:path"; | |
| 12 | 10 | |
| 13 | 11 | import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; |
| 14 | −import { ServiceMap, Effect, Layer } from "effect"; | |
| 12 | +import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; | |
| 13 | +import { Context, Effect, Layer } from "effect"; | |
| 15 | 14 | |
| 16 | 15 | // ============================== |
| 17 | 16 | // Definitions |
| 18 | 17 | // ============================== |
| 19 | 18 | |
| 20 | 19 | export { OpenError }; |
| 20 | +export { isCommandAvailable } from "@t3tools/shared/shell"; | |
| 21 | 21 | |
| 22 | 22 | export interface OpenInEditorInput { |
| 23 | 23 | readonly cwd: string; |
| @@ -29,11 +29,6 @@ interface EditorLaunch { · 5 unmodified lines | ||
| 29 | 29 | readonly args: ReadonlyArray<string>; |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | −interface CommandAvailabilityOptions { | |
| 33 | − readonly platform?: NodeJS.Platform; | |
| 34 | − readonly env?: NodeJS.ProcessEnv; | |
| 35 | −} | |
| 36 | − | |
| 37 | 32 | const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; |
| 38 | 33 | |
| 39 | 34 | function parseTargetPathAndPosition(target: string): { |
| @@ -75,6 +70,26 @@ function resolveCommandEditorArgs( · 35 unmodified lines | ||
| 75 | 70 | } |
| 76 | 71 | } |
| 77 | 72 | |
| 73 | +function resolveEditorArgs( | |
| 74 | + editor: (typeof EDITORS)[number], | |
| 75 | + target: string, | |
| 76 | +): ReadonlyArray<string> { | |
| 77 | + const baseArgs = "baseArgs" in editor ? editor.baseArgs : []; | |
| 78 | + return [...baseArgs, ...resolveCommandEditorArgs(editor, target)]; | |
| 79 | +} | |
| 80 | + | |
| 81 | +function resolveAvailableCommand( | |
| 82 | + commands: ReadonlyArray<string>, | |
| 83 | + options: CommandAvailabilityOptions = {}, | |
| 84 | +): string | null { | |
| 85 | + for (const command of commands) { | |
| 86 | + if (isCommandAvailable(command, options)) { | |
| 87 | + return command; | |
| 88 | + } | |
| 89 | + } | |
| 90 | + return null; | |
| 91 | +} | |
| 92 | + | |
| 78 | 93 | function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { |
| 79 | 94 | switch (platform) { |
| 80 | 95 | case "darwin": |
| @@ -86,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { · 5 unmodified lines | ||
| 86 | 101 | } |
| 87 | 102 | } |
| 88 | 103 | |
| 89 | −function stripWrappingQuotes(value: string): string { | |
| 90 | − return value.replace(/^"+|"+$/g, ""); | |
| 91 | −} | |
| 92 | − | |
| 93 | −function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { | |
| 94 | − return env.PATH ?? env.Path ?? env.path ?? ""; | |
| 95 | −} | |
| 96 | − | |
| 97 | −function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> { | |
| 98 | − const rawValue = env.PATHEXT; | |
| 99 | − const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; | |
| 100 | − if (!rawValue) return fallback; | |
| 101 | − | |
| 102 | − const parsed = rawValue | |
| 103 | − .split(";") | |
| 104 | − .map((entry) => entry.trim()) | |
| 105 | − .filter((entry) => entry.length > 0) | |
| 106 | − .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); | |
| 107 | − return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; | |
| 108 | −} | |
| 109 | − | |
| 110 | −function resolveCommandCandidates( | |
| 111 | − command: string, | |
| 112 | − platform: NodeJS.Platform, | |
| 113 | − windowsPathExtensions: ReadonlyArray<string>, | |
| 114 | −): ReadonlyArray<string> { | |
| 115 | − if (platform !== "win32") return [command]; | |
| 116 | − const extension = extname(command); | |
| 117 | − const normalizedExtension = extension.toUpperCase(); | |
| 118 | − | |
| 119 | − if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { | |
| 120 | − const commandWithoutExtension = command.slice(0, -extension.length); | |
| 121 | − return Array.from( | |
| 122 | − new Set([ | |
| 123 | − command, | |
| 124 | − `${commandWithoutExtension}${normalizedExtension}`, | |
| 125 | − `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, | |
| 126 | − ]), | |
| 127 | − ); | |
| 128 | − } | |
| 129 | − | |
| 130 | − const candidates: string[] = []; | |
| 131 | − for (const extension of windowsPathExtensions) { | |
| 132 | − candidates.push(`${command}${extension}`); | |
| 133 | − candidates.push(`${command}${extension.toLowerCase()}`); | |
| 134 | − } | |
| 135 | − return Array.from(new Set(candidates)); | |
| 136 | −} | |
| 137 | − | |
| 138 | −function isExecutableFile( | |
| 139 | − filePath: string, | |
| 140 | − platform: NodeJS.Platform, | |
| 141 | − windowsPathExtensions: ReadonlyArray<string>, | |
| 142 | −): boolean { | |
| 143 | − try { | |
| 144 | − const stat = statSync(filePath); | |
| 145 | − if (!stat.isFile()) return false; | |
| 146 | − if (platform === "win32") { | |
| 147 | − const extension = extname(filePath); | |
| 148 | − if (extension.length === 0) return false; | |
| 149 | − return windowsPathExtensions.includes(extension.toUpperCase()); | |
| 150 | − } | |
| 151 | − accessSync(filePath, constants.X_OK); | |
| 152 | − return true; | |
| 153 | − } catch { | |
| 154 | − return false; | |
| 155 | − } | |
| 156 | −} | |
| 157 | − | |
| 158 | −function resolvePathDelimiter(platform: NodeJS.Platform): string { | |
| 159 | − return platform === "win32" ? ";" : ":"; | |
| 160 | −} | |
| 161 | − | |
| 162 | −export function isCommandAvailable( | |
| 163 | − command: string, | |
| 164 | − options: CommandAvailabilityOptions = {}, | |
| 165 | −): boolean { | |
| 166 | − const platform = options.platform ?? process.platform; | |
| 167 | − const env = options.env ?? process.env; | |
| 168 | − const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; | |
| 169 | − const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); | |
| 170 | − | |
| 171 | − if (command.includes("/") || command.includes("\\")) { | |
| 172 | − return commandCandidates.some((candidate) => | |
| 173 | − isExecutableFile(candidate, platform, windowsPathExtensions), | |
| 174 | − ); | |
| 175 | − } | |
| 176 | − | |
| 177 | − const pathValue = resolvePathEnvironmentVariable(env); | |
| 178 | − if (pathValue.length === 0) return false; | |
| 179 | − const pathEntries = pathValue | |
| 180 | − .split(resolvePathDelimiter(platform)) | |
| 181 | − .map((entry) => stripWrappingQuotes(entry.trim())) | |
| 182 | − .filter((entry) => entry.length > 0); | |
| 183 | − | |
| 184 | − for (const pathEntry of pathEntries) { | |
| 185 | − for (const candidate of commandCandidates) { | |
| 186 | − if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { | |
| 187 | − return true; | |
| 188 | − } | |
| 189 | − } | |
| 190 | − } | |
| 191 | − return false; | |
| 192 | −} | |
| 193 | − | |
| 194 | 104 | export function resolveAvailableEditors( |
| 195 | 105 | platform: NodeJS.Platform = process.platform, |
| 196 | 106 | env: NodeJS.ProcessEnv = process.env, |
| @@ -198,8 +108,16 @@ export function resolveAvailableEditors( · 1 unmodified line | ||
| 198 | 108 | const available: EditorId[] = []; |
| 199 | 109 | |
| 200 | 110 | for (const editor of EDITORS) { |
| 201 | − const command = editor.command ?? fileManagerCommandForPlatform(platform); | |
| 202 | − if (isCommandAvailable(command, { platform, env })) { | |
| 111 | + if (editor.commands === null) { | |
| 112 | + const command = fileManagerCommandForPlatform(platform); | |
| 113 | + if (isCommandAvailable(command, { platform, env })) { | |
| 114 | + available.push(editor.id); | |
| 115 | + } | |
| 116 | + continue; | |
| 117 | + } | |
| 118 | + | |
| 119 | + const command = resolveAvailableCommand(editor.commands, { platform, env }); | |
| 120 | + if (command !== null) { | |
| 203 | 121 | available.push(editor.id); |
| 204 | 122 | } |
| 205 | 123 | } |
| @@ -227,7 +145,7 @@ export interface OpenShape { · 21 unmodified lines | ||
| 227 | 145 | /** |
| 228 | 146 | * Open - Service tag for browser/editor launch operations. |
| 229 | 147 | */ |
| 230 | −export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} | |
| 148 | +export class Open extends Context.Service<Open, OpenShape>()("t3/open") {} | |
| 231 | 149 | |
| 232 | 150 | // ============================== |
| 233 | 151 | // Implementations |
| @@ -236,6 +154,7 @@ export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} · 2 unmodified lines | ||
| 236 | 154 | export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( |
| 237 | 155 | input: OpenInEditorInput, |
| 238 | 156 | platform: NodeJS.Platform = process.platform, |
| 157 | + env: NodeJS.ProcessEnv = process.env, | |
| 239 | 158 | ): Effect.fn.Return<EditorLaunch, OpenError> { |
| 240 | 159 | yield* Effect.annotateCurrentSpan({ |
| 241 | 160 | "open.editor": input.editor, |
| @@ -247,10 +166,12 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( · 5 unmodified lines | ||
| 247 | 166 | return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); |
| 248 | 167 | } |
| 249 | 168 | |
| 250 | − if (editorDef.command) { | |
| 169 | + if (editorDef.commands) { | |
| 170 | + const command = | |
| 171 | + resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0]; | |
| 251 | 172 | return { |
| 252 | − command: editorDef.command, | |
| 253 | − args: resolveCommandEditorArgs(editorDef, input.cwd), | |
| 173 | + command, | |
| 174 | + args: resolveEditorArgs(editorDef, input.cwd), | |
| 254 | 175 | }; |
| 255 | 176 | } |
| 256 | 177 | |
| @@ -270,11 +191,16 @@ export const launchDetached = (launch: EditorLaunch) => · 13 unmodified lines | ||
| 270 | 191 | yield* Effect.callback<void, OpenError>((resume) => { |
| 271 | 192 | let child; |
| 272 | 193 | try { |
| 273 | − child = spawn(launch.command, [...launch.args], { | |
| 274 | − detached: true, | |
| 275 | − stdio: "ignore", | |
| 276 | − shell: process.platform === "win32", | |
| 277 | − }); | |
| 194 | + const isWin32 = process.platform === "win32"; | |
| 195 | + child = spawn( | |
| 196 | + launch.command, | |
| 197 | + isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args], | |
| 198 | + { | |
| 199 | + detached: true, | |
| 200 | + stdio: "ignore", | |
| 201 | + shell: isWin32, | |
| 202 | + }, | |
| 203 | + ); | |
| 278 | 204 | } catch (error) { |
| 279 | 205 | return resume( |
| 280 | 206 | Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })), |
| @@ -20,6 +20,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines | ||
| 20 | 20 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; |
| 21 | 21 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 22 | 22 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; |
| 23 | +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; | |
| 24 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 23 | 25 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 24 | 26 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 25 | 27 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| @@ -42,8 +44,8 @@ import { ServerConfig } from "../../config.ts"; · 16 unmodified lines | ||
| 42 | 44 | import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts"; |
| 43 | 45 | import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts"; |
| 44 | 46 | |
| 45 | −const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 46 | −const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 47 | +const asProjectId = (value: string): ProjectId => ProjectId.make(value); | |
| 48 | +const asTurnId = (value: string): TurnId => TurnId.make(value); | |
| 47 | 49 | |
| 48 | 50 | type LegacyProviderRuntimeEvent = { |
| 49 | 51 | readonly type: string; |
| @@ -79,7 +81,7 @@ function createProviderServiceHarness( · 29 unmodified lines | ||
| 79 | 81 | provider: providerName, |
| 80 | 82 | status: "ready", |
| 81 | 83 | runtimeMode: "full-access", |
| 82 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 84 | + threadId: ThreadId.make("thread-1"), | |
| 83 | 85 | cwd: sessionCwd, |
| 84 | 86 | createdAt: now, |
| 85 | 87 | updatedAt: now, |
| @@ -96,7 +98,9 @@ function createProviderServiceHarness( · 10 unmodified lines | ||
| 96 | 98 | listSessions, |
| 97 | 99 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| 98 | 100 | rollbackConversation, |
| 99 | − streamEvents: Stream.fromPubSub(runtimeEventPubSub), | |
| 101 | + get streamEvents() { | |
| 102 | + return Stream.fromPubSub(runtimeEventPubSub); | |
| 103 | + }, | |
| 100 | 104 | }; |
| 101 | 105 | |
| 102 | 106 | const emit = (event: LegacyProviderRuntimeEvent): void => { |
| @@ -126,7 +130,7 @@ async function waitForThread( · 23 unmodified lines | ||
| 126 | 130 | activities: ReadonlyArray<{ kind: string }>; |
| 127 | 131 | }> => { |
| 128 | 132 | const readModel = await Effect.runPromise(engine.getReadModel()); |
| 129 | − const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 133 | + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 130 | 134 | if (thread && predicate(thread)) { |
| 131 | 135 | return thread; |
| 132 | 136 | } |
| @@ -240,6 +244,7 @@ describe("CheckpointReactor", () => { · 107 unmodified lines | ||
| 240 | 244 | readonly threadWorktreePath?: string | null; |
| 241 | 245 | readonly providerSessionCwd?: string; |
| 242 | 246 | readonly providerName?: ProviderKind; |
| 247 | + readonly gitStatusRefreshCalls?: Array<string>; | |
| 243 | 248 | }) { |
| 244 | 249 | const cwd = createGitRepository(); |
| 245 | 250 | tempDirs.push(cwd); |
| @@ -254,17 +259,37 @@ describe("CheckpointReactor", () => { · 8 unmodified lines | ||
| 254 | 259 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 255 | 260 | Layer.provide(OrchestrationEventStoreLive), |
| 256 | 261 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 262 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 257 | 263 | Layer.provide(SqlitePersistenceMemory), |
| 258 | 264 | ); |
| 259 | 265 | |
| 260 | 266 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| 261 | 267 | prefix: "t3-checkpoint-reactor-test-", |
| 262 | 268 | }); |
| 269 | + const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, { | |
| 270 | + getStatus: () => Effect.die("getStatus should not be called in this test"), | |
| 271 | + refreshLocalStatus: (cwd: string) => | |
| 272 | + Effect.sync(() => { | |
| 273 | + options?.gitStatusRefreshCalls?.push(cwd); | |
| 274 | + }).pipe( | |
| 275 | + Effect.as({ | |
| 276 | + isRepo: true, | |
| 277 | + hasOriginRemote: false, | |
| 278 | + isDefaultBranch: true, | |
| 279 | + branch: "main", | |
| 280 | + hasWorkingTreeChanges: false, | |
| 281 | + workingTree: { files: [], insertions: 0, deletions: 0 }, | |
| 282 | + }), | |
| 283 | + ), | |
| 284 | + refreshStatus: () => Effect.die("refreshStatus should not be called in this test"), | |
| 285 | + streamStatus: () => Stream.empty, | |
| 286 | + }); | |
| 263 | 287 | |
| 264 | 288 | const layer = CheckpointReactorLive.pipe( |
| 265 | 289 | Layer.provideMerge(orchestrationLayer), |
| 266 | 290 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 267 | 291 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 292 | + Layer.provideMerge(gitStatusBroadcasterLayer), | |
| 268 | 293 | Layer.provideMerge(CheckpointStoreLive), |
| 269 | 294 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), |
| 270 | 295 | Layer.provideMerge(WorkspacePathsLive), |
| @@ -285,7 +310,7 @@ describe("CheckpointReactor", () => { · 14 unmodified lines | ||
| 285 | 310 | await Effect.runPromise( |
| 286 | 311 | engine.dispatch({ |
| 287 | 312 | type: "project.create", |
| 288 | − commandId: CommandId.makeUnsafe("cmd-project-create"), | |
| 313 | + commandId: CommandId.make("cmd-project-create"), | |
| 289 | 314 | projectId: asProjectId("project-1"), |
| 290 | 315 | title: "Test Project", |
| 291 | 316 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, |
| @@ -299,8 +324,8 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 299 | 324 | await Effect.runPromise( |
| 300 | 325 | engine.dispatch({ |
| 301 | 326 | type: "thread.create", |
| 302 | − commandId: CommandId.makeUnsafe("cmd-thread-create"), | |
| 303 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 327 | + commandId: CommandId.make("cmd-thread-create"), | |
| 328 | + threadId: ThreadId.make("thread-1"), | |
| 304 | 329 | projectId: asProjectId("project-1"), |
| 305 | 330 | title: "Thread", |
| 306 | 331 | modelSelection: { |
| @@ -319,21 +344,21 @@ describe("CheckpointReactor", () => { · 12 unmodified lines | ||
| 319 | 344 | await runtime.runPromise( |
| 320 | 345 | checkpointStore.captureCheckpoint({ |
| 321 | 346 | cwd, |
| 322 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 347 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 323 | 348 | }), |
| 324 | 349 | ); |
| 325 | 350 | fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8"); |
| 326 | 351 | await runtime.runPromise( |
| 327 | 352 | checkpointStore.captureCheckpoint({ |
| 328 | 353 | cwd, |
| 329 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 354 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 330 | 355 | }), |
| 331 | 356 | ); |
| 332 | 357 | fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8"); |
| 333 | 358 | await runtime.runPromise( |
| 334 | 359 | checkpointStore.captureCheckpoint({ |
| 335 | 360 | cwd, |
| 336 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 361 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), | |
| 337 | 362 | }), |
| 338 | 363 | ); |
| 339 | 364 | } |
| @@ -353,10 +378,10 @@ describe("CheckpointReactor", () => { · 13 unmodified lines | ||
| 353 | 378 | await Effect.runPromise( |
| 354 | 379 | harness.engine.dispatch({ |
| 355 | 380 | type: "thread.session.set", |
| 356 | − commandId: CommandId.makeUnsafe("cmd-session-set-capture"), | |
| 357 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 381 | + commandId: CommandId.make("cmd-session-set-capture"), | |
| 382 | + threadId: ThreadId.make("thread-1"), | |
| 358 | 383 | session: { |
| 359 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 384 | + threadId: ThreadId.make("thread-1"), | |
| 360 | 385 | status: "ready", |
| 361 | 386 | providerName: "codex", |
| 362 | 387 | runtimeMode: "approval-required", |
| @@ -370,26 +395,26 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 370 | 395 | |
| 371 | 396 | harness.provider.emit({ |
| 372 | 397 | type: "turn.started", |
| 373 | − eventId: EventId.makeUnsafe("evt-turn-started-1"), | |
| 398 | + eventId: EventId.make("evt-turn-started-1"), | |
| 374 | 399 | provider: "codex", |
| 375 | 400 | |
| 376 | 401 | createdAt: new Date().toISOString(), |
| 377 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 402 | + threadId: ThreadId.make("thread-1"), | |
| 378 | 403 | turnId: asTurnId("turn-1"), |
| 379 | 404 | }); |
| 380 | 405 | await waitForGitRefExists( |
| 381 | 406 | harness.cwd, |
| 382 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 407 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 383 | 408 | ); |
| 384 | 409 | |
| 385 | 410 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 386 | 411 | harness.provider.emit({ |
| 387 | 412 | type: "turn.completed", |
| 388 | − eventId: EventId.makeUnsafe("evt-turn-completed-1"), | |
| 413 | + eventId: EventId.make("evt-turn-completed-1"), | |
| 389 | 414 | provider: "codex", |
| 390 | 415 | |
| 391 | 416 | createdAt: new Date().toISOString(), |
| 392 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 417 | + threadId: ThreadId.make("thread-1"), | |
| 393 | 418 | turnId: asTurnId("turn-1"), |
| 394 | 419 | payload: { state: "completed" }, |
| 395 | 420 | }); |
| @@ -401,27 +426,49 @@ describe("CheckpointReactor", () => { · 5 unmodified lines | ||
| 401 | 426 | ); |
| 402 | 427 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 403 | 428 | expect( |
| 404 | − gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), | |
| 429 | + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), | |
| 405 | 430 | ).toBe(true); |
| 406 | 431 | expect( |
| 407 | − gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), | |
| 432 | + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | |
| 408 | 433 | ).toBe(true); |
| 409 | 434 | expect( |
| 410 | 435 | gitShowFileAtRef( |
| 411 | 436 | harness.cwd, |
| 412 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 437 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 413 | 438 | "README.md", |
| 414 | 439 | ), |
| 415 | 440 | ).toBe("v1\n"); |
| 416 | 441 | expect( |
| 417 | 442 | gitShowFileAtRef( |
| 418 | 443 | harness.cwd, |
| 419 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 444 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 420 | 445 | "README.md", |
| 421 | 446 | ), |
| 422 | 447 | ).toBe("v2\n"); |
| 423 | 448 | }); |
| 424 | 449 | |
| 450 | + it("refreshes local git status state on turn completion using the session cwd", async () => { | |
| 451 | + const gitStatusRefreshCalls: string[] = []; | |
| 452 | + const harness = await createHarness({ | |
| 453 | + seedFilesystemCheckpoints: false, | |
| 454 | + gitStatusRefreshCalls, | |
| 455 | + }); | |
| 456 | + | |
| 457 | + harness.provider.emit({ | |
| 458 | + type: "turn.completed", | |
| 459 | + eventId: EventId.make("evt-turn-completed-refresh-local-status"), | |
| 460 | + provider: "codex", | |
| 461 | + createdAt: new Date().toISOString(), | |
| 462 | + threadId: ThreadId.make("thread-1"), | |
| 463 | + turnId: asTurnId("turn-refresh-local-status"), | |
| 464 | + payload: { state: "completed" }, | |
| 465 | + }); | |
| 466 | + | |
| 467 | + await harness.drain(); | |
| 468 | + | |
| 469 | + expect(gitStatusRefreshCalls).toEqual([harness.cwd]); | |
| 470 | + }); | |
| 471 | + | |
| 425 | 472 | it("ignores auxiliary thread turn completion while primary turn is active", async () => { |
| 426 | 473 | const harness = await createHarness({ seedFilesystemCheckpoints: false }); |
| 427 | 474 | const createdAt = new Date().toISOString(); |
| @@ -429,10 +476,10 @@ describe("CheckpointReactor", () => { · 1 unmodified line | ||
| 429 | 476 | await Effect.runPromise( |
| 430 | 477 | harness.engine.dispatch({ |
| 431 | 478 | type: "thread.session.set", |
| 432 | − commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"), | |
| 433 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 479 | + commandId: CommandId.make("cmd-session-set-primary-running"), | |
| 480 | + threadId: ThreadId.make("thread-1"), | |
| 434 | 481 | session: { |
| 435 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 482 | + threadId: ThreadId.make("thread-1"), | |
| 436 | 483 | status: "running", |
| 437 | 484 | providerName: "codex", |
| 438 | 485 | runtimeMode: "approval-required", |
| @@ -446,45 +493,43 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 446 | 493 | |
| 447 | 494 | harness.provider.emit({ |
| 448 | 495 | type: "turn.started", |
| 449 | − eventId: EventId.makeUnsafe("evt-turn-started-main"), | |
| 496 | + eventId: EventId.make("evt-turn-started-main"), | |
| 450 | 497 | provider: "codex", |
| 451 | 498 | |
| 452 | 499 | createdAt: new Date().toISOString(), |
| 453 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 500 | + threadId: ThreadId.make("thread-1"), | |
| 454 | 501 | turnId: asTurnId("turn-main"), |
| 455 | 502 | }); |
| 456 | 503 | await waitForGitRefExists( |
| 457 | 504 | harness.cwd, |
| 458 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 505 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 459 | 506 | ); |
| 460 | 507 | |
| 461 | 508 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 462 | 509 | |
| 463 | 510 | harness.provider.emit({ |
| 464 | 511 | type: "turn.completed", |
| 465 | − eventId: EventId.makeUnsafe("evt-turn-completed-aux"), | |
| 512 | + eventId: EventId.make("evt-turn-completed-aux"), | |
| 466 | 513 | provider: "codex", |
| 467 | 514 | |
| 468 | 515 | createdAt: new Date().toISOString(), |
| 469 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 516 | + threadId: ThreadId.make("thread-1"), | |
| 470 | 517 | turnId: asTurnId("turn-aux"), |
| 471 | 518 | payload: { state: "completed" }, |
| 472 | 519 | }); |
| 473 | 520 | |
| 474 | 521 | await harness.drain(); |
| 475 | 522 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 476 | − const midThread = midReadModel.threads.find( | |
| 477 | − (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | |
| 478 | − ); | |
| 523 | + const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 479 | 524 | expect(midThread?.checkpoints).toHaveLength(0); |
| 480 | 525 | |
| 481 | 526 | harness.provider.emit({ |
| 482 | 527 | type: "turn.completed", |
| 483 | − eventId: EventId.makeUnsafe("evt-turn-completed-main"), | |
| 528 | + eventId: EventId.make("evt-turn-completed-main"), | |
| 484 | 529 | provider: "codex", |
| 485 | 530 | |
| 486 | 531 | createdAt: new Date().toISOString(), |
| 487 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 532 | + threadId: ThreadId.make("thread-1"), | |
| 488 | 533 | turnId: asTurnId("turn-main"), |
| 489 | 534 | payload: { state: "completed" }, |
| 490 | 535 | }); |
| @@ -506,10 +551,10 @@ describe("CheckpointReactor", () => { · 15 unmodified lines | ||
| 506 | 551 | await Effect.runPromise( |
| 507 | 552 | harness.engine.dispatch({ |
| 508 | 553 | type: "thread.session.set", |
| 509 | − commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"), | |
| 510 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 554 | + commandId: CommandId.make("cmd-session-set-capture-claude"), | |
| 555 | + threadId: ThreadId.make("thread-1"), | |
| 511 | 556 | session: { |
| 512 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 557 | + threadId: ThreadId.make("thread-1"), | |
| 513 | 558 | status: "ready", |
| 514 | 559 | providerName: "claudeAgent", |
| 515 | 560 | runtimeMode: "approval-required", |
| @@ -523,24 +568,24 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 523 | 568 | |
| 524 | 569 | harness.provider.emit({ |
| 525 | 570 | type: "turn.started", |
| 526 | − eventId: EventId.makeUnsafe("evt-turn-started-claude-1"), | |
| 571 | + eventId: EventId.make("evt-turn-started-claude-1"), | |
| 527 | 572 | provider: "claudeAgent", |
| 528 | 573 | createdAt: new Date().toISOString(), |
| 529 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 574 | + threadId: ThreadId.make("thread-1"), | |
| 530 | 575 | turnId: asTurnId("turn-claude-1"), |
| 531 | 576 | }); |
| 532 | 577 | await waitForGitRefExists( |
| 533 | 578 | harness.cwd, |
| 534 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 579 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 535 | 580 | ); |
| 536 | 581 | |
| 537 | 582 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 538 | 583 | harness.provider.emit({ |
| 539 | 584 | type: "turn.completed", |
| 540 | − eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"), | |
| 585 | + eventId: EventId.make("evt-turn-completed-claude-1"), | |
| 541 | 586 | provider: "claudeAgent", |
| 542 | 587 | createdAt: new Date().toISOString(), |
| 543 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 588 | + threadId: ThreadId.make("thread-1"), | |
| 544 | 589 | turnId: asTurnId("turn-claude-1"), |
| 545 | 590 | payload: { state: "completed" }, |
| 546 | 591 | }); |
| @@ -553,7 +598,7 @@ describe("CheckpointReactor", () => { · 6 unmodified lines | ||
| 553 | 598 | |
| 554 | 599 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 555 | 600 | expect( |
| 556 | − gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), | |
| 601 | + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | |
| 557 | 602 | ).toBe(true); |
| 558 | 603 | }); |
| 559 | 604 | |
| @@ -564,10 +609,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines | ||
| 564 | 609 | await Effect.runPromise( |
| 565 | 610 | harness.engine.dispatch({ |
| 566 | 611 | type: "thread.session.set", |
| 567 | − commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"), | |
| 568 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 612 | + commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), | |
| 613 | + threadId: ThreadId.make("thread-1"), | |
| 569 | 614 | session: { |
| 570 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 615 | + threadId: ThreadId.make("thread-1"), | |
| 571 | 616 | status: "ready", |
| 572 | 617 | providerName: "codex", |
| 573 | 618 | runtimeMode: "approval-required", |
| @@ -581,11 +626,11 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 581 | 626 | |
| 582 | 627 | harness.provider.emit({ |
| 583 | 628 | type: "turn.completed", |
| 584 | − eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"), | |
| 629 | + eventId: EventId.make("evt-turn-completed-missing-baseline"), | |
| 585 | 630 | provider: "codex", |
| 586 | 631 | |
| 587 | 632 | createdAt: new Date().toISOString(), |
| 588 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 633 | + threadId: ThreadId.make("thread-1"), | |
| 589 | 634 | turnId: asTurnId("turn-missing-baseline"), |
| 590 | 635 | payload: { state: "completed" }, |
| 591 | 636 | }); |
| @@ -614,10 +659,10 @@ describe("CheckpointReactor", () => { · 22 unmodified lines | ||
| 614 | 659 | await Effect.runPromise( |
| 615 | 660 | harness.engine.dispatch({ |
| 616 | 661 | type: "thread.turn.start", |
| 617 | − commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"), | |
| 618 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 662 | + commandId: CommandId.make("cmd-turn-start-for-baseline"), | |
| 663 | + threadId: ThreadId.make("thread-1"), | |
| 619 | 664 | message: { |
| 620 | − messageId: MessageId.makeUnsafe("message-user-1"), | |
| 665 | + messageId: MessageId.make("message-user-1"), | |
| 621 | 666 | role: "user", |
| 622 | 667 | text: "start turn", |
| 623 | 668 | attachments: [], |
| @@ -630,12 +675,12 @@ describe("CheckpointReactor", () => { · 6 unmodified lines | ||
| 630 | 675 | |
| 631 | 676 | await waitForGitRefExists( |
| 632 | 677 | harness.cwd, |
| 633 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 678 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 634 | 679 | ); |
| 635 | 680 | expect( |
| 636 | 681 | gitShowFileAtRef( |
| 637 | 682 | harness.cwd, |
| 638 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 683 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 639 | 684 | "README.md", |
| 640 | 685 | ), |
| 641 | 686 | ).toBe("v1\n"); |
| @@ -652,10 +697,10 @@ describe("CheckpointReactor", () => { · 10 unmodified lines | ||
| 652 | 697 | await Effect.runPromise( |
| 653 | 698 | harness.engine.dispatch({ |
| 654 | 699 | type: "thread.session.set", |
| 655 | − commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"), | |
| 656 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 700 | + commandId: CommandId.make("cmd-session-set-missing-provider-cwd"), | |
| 701 | + threadId: ThreadId.make("thread-1"), | |
| 657 | 702 | session: { |
| 658 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 703 | + threadId: ThreadId.make("thread-1"), | |
| 659 | 704 | status: "running", |
| 660 | 705 | providerName: "codex", |
| 661 | 706 | runtimeMode: "approval-required", |
| @@ -670,23 +715,23 @@ describe("CheckpointReactor", () => { · 8 unmodified lines | ||
| 670 | 715 | fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); |
| 671 | 716 | harness.provider.emit({ |
| 672 | 717 | type: "turn.completed", |
| 673 | − eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), | |
| 718 | + eventId: EventId.make("evt-turn-completed-missing-provider-cwd"), | |
| 674 | 719 | provider: "codex", |
| 675 | 720 | |
| 676 | 721 | createdAt: new Date().toISOString(), |
| 677 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 722 | + threadId: ThreadId.make("thread-1"), | |
| 678 | 723 | turnId: asTurnId("turn-missing-cwd"), |
| 679 | 724 | payload: { state: "completed" }, |
| 680 | 725 | }); |
| 681 | 726 | |
| 682 | 727 | await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); |
| 683 | 728 | expect( |
| 684 | − gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)), | |
| 729 | + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), | |
| 685 | 730 | ).toBe(true); |
| 686 | 731 | expect( |
| 687 | 732 | gitShowFileAtRef( |
| 688 | 733 | harness.cwd, |
| 689 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 734 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 690 | 735 | "README.md", |
| 691 | 736 | ), |
| 692 | 737 | ).toBe("v2\n"); |
| @@ -699,10 +744,10 @@ describe("CheckpointReactor", () => { · 6 unmodified lines | ||
| 699 | 744 | await Effect.runPromise( |
| 700 | 745 | harness.engine.dispatch({ |
| 701 | 746 | type: "thread.session.set", |
| 702 | − commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"), | |
| 703 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 747 | + commandId: CommandId.make("cmd-session-set-checkpoint-captured"), | |
| 748 | + threadId: ThreadId.make("thread-1"), | |
| 704 | 749 | session: { |
| 705 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 750 | + threadId: ThreadId.make("thread-1"), | |
| 706 | 751 | status: "ready", |
| 707 | 752 | providerName: "codex", |
| 708 | 753 | runtimeMode: "approval-required", |
| @@ -716,11 +761,11 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 716 | 761 | |
| 717 | 762 | harness.provider.emit({ |
| 718 | 763 | type: "checkpoint.captured", |
| 719 | − eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"), | |
| 764 | + eventId: EventId.make("evt-checkpoint-captured-3"), | |
| 720 | 765 | provider: "codex", |
| 721 | 766 | |
| 722 | 767 | createdAt: new Date().toISOString(), |
| 723 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 768 | + threadId: ThreadId.make("thread-1"), | |
| 724 | 769 | turnId: asTurnId("turn-3"), |
| 725 | 770 | turnCount: 3, |
| 726 | 771 | status: "completed", |
| @@ -728,7 +773,7 @@ describe("CheckpointReactor", () => { · 1 unmodified line | ||
| 728 | 773 | |
| 729 | 774 | await harness.drain(); |
| 730 | 775 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 731 | − const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | |
| 776 | + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); | |
| 732 | 777 | expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( |
| 733 | 778 | false, |
| 734 | 779 | ); |
| @@ -749,10 +794,10 @@ describe("CheckpointReactor", () => { · 14 unmodified lines | ||
| 749 | 794 | await Effect.runPromise( |
| 750 | 795 | harness.engine.dispatch({ |
| 751 | 796 | type: "thread.session.set", |
| 752 | − commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"), | |
| 753 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 797 | + commandId: CommandId.make("cmd-session-set-non-repo-runtime"), | |
| 798 | + threadId: ThreadId.make("thread-1"), | |
| 754 | 799 | session: { |
| 755 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 800 | + threadId: ThreadId.make("thread-1"), | |
| 756 | 801 | status: "ready", |
| 757 | 802 | providerName: "codex", |
| 758 | 803 | runtimeMode: "approval-required", |
| @@ -766,31 +811,31 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 766 | 811 | |
| 767 | 812 | harness.provider.emit({ |
| 768 | 813 | type: "turn.completed", |
| 769 | − eventId: EventId.makeUnsafe("evt-runtime-capture-failure"), | |
| 814 | + eventId: EventId.make("evt-runtime-capture-failure"), | |
| 770 | 815 | provider: "codex", |
| 771 | 816 | |
| 772 | 817 | createdAt: new Date().toISOString(), |
| 773 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 818 | + threadId: ThreadId.make("thread-1"), | |
| 774 | 819 | turnId: asTurnId("turn-runtime-failure"), |
| 775 | 820 | payload: { state: "completed" }, |
| 776 | 821 | }); |
| 777 | 822 | |
| 778 | 823 | harness.provider.emit({ |
| 779 | 824 | type: "turn.started", |
| 780 | − eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"), | |
| 825 | + eventId: EventId.make("evt-turn-started-after-runtime-failure"), | |
| 781 | 826 | provider: "codex", |
| 782 | 827 | |
| 783 | 828 | createdAt: new Date().toISOString(), |
| 784 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 829 | + threadId: ThreadId.make("thread-1"), | |
| 785 | 830 | turnId: asTurnId("turn-after-runtime-failure"), |
| 786 | 831 | }); |
| 787 | 832 | |
| 788 | 833 | await waitForGitRefExists( |
| 789 | 834 | harness.cwd, |
| 790 | − checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0), | |
| 835 | + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0), | |
| 791 | 836 | ); |
| 792 | 837 | expect( |
| 793 | − gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)), | |
| 838 | + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), | |
| 794 | 839 | ).toBe(true); |
| 795 | 840 | }); |
| 796 | 841 | |
| @@ -801,10 +846,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines | ||
| 801 | 846 | await Effect.runPromise( |
| 802 | 847 | harness.engine.dispatch({ |
| 803 | 848 | type: "thread.session.set", |
| 804 | − commandId: CommandId.makeUnsafe("cmd-session-set"), | |
| 805 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 849 | + commandId: CommandId.make("cmd-session-set"), | |
| 850 | + threadId: ThreadId.make("thread-1"), | |
| 806 | 851 | session: { |
| 807 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 852 | + threadId: ThreadId.make("thread-1"), | |
| 808 | 853 | status: "ready", |
| 809 | 854 | providerName: "codex", |
| 810 | 855 | runtimeMode: "approval-required", |
| @@ -819,11 +864,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines | ||
| 819 | 864 | await Effect.runPromise( |
| 820 | 865 | harness.engine.dispatch({ |
| 821 | 866 | type: "thread.turn.diff.complete", |
| 822 | − commandId: CommandId.makeUnsafe("cmd-diff-1"), | |
| 823 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 867 | + commandId: CommandId.make("cmd-diff-1"), | |
| 868 | + threadId: ThreadId.make("thread-1"), | |
| 824 | 869 | turnId: asTurnId("turn-1"), |
| 825 | 870 | completedAt: createdAt, |
| 826 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 871 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 827 | 872 | status: "ready", |
| 828 | 873 | files: [], |
| 829 | 874 | checkpointTurnCount: 1, |
| @@ -833,11 +878,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines | ||
| 833 | 878 | await Effect.runPromise( |
| 834 | 879 | harness.engine.dispatch({ |
| 835 | 880 | type: "thread.turn.diff.complete", |
| 836 | − commandId: CommandId.makeUnsafe("cmd-diff-2"), | |
| 837 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 881 | + commandId: CommandId.make("cmd-diff-2"), | |
| 882 | + threadId: ThreadId.make("thread-1"), | |
| 838 | 883 | turnId: asTurnId("turn-2"), |
| 839 | 884 | completedAt: createdAt, |
| 840 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 885 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), | |
| 841 | 886 | status: "ready", |
| 842 | 887 | files: [], |
| 843 | 888 | checkpointTurnCount: 2, |
| @@ -848,8 +893,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines | ||
| 848 | 893 | await Effect.runPromise( |
| 849 | 894 | harness.engine.dispatch({ |
| 850 | 895 | type: "thread.checkpoint.revert", |
| 851 | − commandId: CommandId.makeUnsafe("cmd-revert-request"), | |
| 852 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 896 | + commandId: CommandId.make("cmd-revert-request"), | |
| 897 | + threadId: ThreadId.make("thread-1"), | |
| 853 | 898 | turnCount: 1, |
| 854 | 899 | createdAt, |
| 855 | 900 | }), |
| @@ -863,12 +908,12 @@ describe("CheckpointReactor", () => { · 7 unmodified lines | ||
| 863 | 908 | expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); |
| 864 | 909 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); |
| 865 | 910 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ |
| 866 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 911 | + threadId: ThreadId.make("thread-1"), | |
| 867 | 912 | numTurns: 1, |
| 868 | 913 | }); |
| 869 | 914 | expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n"); |
| 870 | 915 | expect( |
| 871 | − gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)), | |
| 916 | + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)), | |
| 872 | 917 | ).toBe(false); |
| 873 | 918 | }); |
| 874 | 919 | |
| @@ -879,10 +924,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines | ||
| 879 | 924 | await Effect.runPromise( |
| 880 | 925 | harness.engine.dispatch({ |
| 881 | 926 | type: "thread.session.set", |
| 882 | − commandId: CommandId.makeUnsafe("cmd-session-set-claude"), | |
| 883 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 927 | + commandId: CommandId.make("cmd-session-set-claude"), | |
| 928 | + threadId: ThreadId.make("thread-1"), | |
| 884 | 929 | session: { |
| 885 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 930 | + threadId: ThreadId.make("thread-1"), | |
| 886 | 931 | status: "ready", |
| 887 | 932 | providerName: "claudeAgent", |
| 888 | 933 | runtimeMode: "approval-required", |
| @@ -897,11 +942,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines | ||
| 897 | 942 | await Effect.runPromise( |
| 898 | 943 | harness.engine.dispatch({ |
| 899 | 944 | type: "thread.turn.diff.complete", |
| 900 | − commandId: CommandId.makeUnsafe("cmd-diff-claude-1"), | |
| 901 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 945 | + commandId: CommandId.make("cmd-diff-claude-1"), | |
| 946 | + threadId: ThreadId.make("thread-1"), | |
| 902 | 947 | turnId: asTurnId("turn-claude-1"), |
| 903 | 948 | completedAt: createdAt, |
| 904 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 949 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 905 | 950 | status: "ready", |
| 906 | 951 | files: [], |
| 907 | 952 | checkpointTurnCount: 1, |
| @@ -911,11 +956,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines | ||
| 911 | 956 | await Effect.runPromise( |
| 912 | 957 | harness.engine.dispatch({ |
| 913 | 958 | type: "thread.turn.diff.complete", |
| 914 | − commandId: CommandId.makeUnsafe("cmd-diff-claude-2"), | |
| 915 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 959 | + commandId: CommandId.make("cmd-diff-claude-2"), | |
| 960 | + threadId: ThreadId.make("thread-1"), | |
| 916 | 961 | turnId: asTurnId("turn-claude-2"), |
| 917 | 962 | completedAt: createdAt, |
| 918 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 963 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), | |
| 919 | 964 | status: "ready", |
| 920 | 965 | files: [], |
| 921 | 966 | checkpointTurnCount: 2, |
| @@ -926,8 +971,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines | ||
| 926 | 971 | await Effect.runPromise( |
| 927 | 972 | harness.engine.dispatch({ |
| 928 | 973 | type: "thread.checkpoint.revert", |
| 929 | − commandId: CommandId.makeUnsafe("cmd-revert-request-claude"), | |
| 930 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 974 | + commandId: CommandId.make("cmd-revert-request-claude"), | |
| 975 | + threadId: ThreadId.make("thread-1"), | |
| 931 | 976 | turnCount: 1, |
| 932 | 977 | createdAt, |
| 933 | 978 | }), |
| @@ -936,7 +981,7 @@ describe("CheckpointReactor", () => { · 2 unmodified lines | ||
| 936 | 981 | await waitForEvent(harness.engine, (event) => event.type === "thread.reverted"); |
| 937 | 982 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1); |
| 938 | 983 | expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({ |
| 939 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 984 | + threadId: ThreadId.make("thread-1"), | |
| 940 | 985 | numTurns: 1, |
| 941 | 986 | }); |
| 942 | 987 | }); |
| @@ -948,10 +993,10 @@ describe("CheckpointReactor", () => { · 5 unmodified lines | ||
| 948 | 993 | await Effect.runPromise( |
| 949 | 994 | harness.engine.dispatch({ |
| 950 | 995 | type: "thread.session.set", |
| 951 | − commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"), | |
| 952 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 996 | + commandId: CommandId.make("cmd-session-set-inline-revert"), | |
| 997 | + threadId: ThreadId.make("thread-1"), | |
| 953 | 998 | session: { |
| 954 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 999 | + threadId: ThreadId.make("thread-1"), | |
| 955 | 1000 | status: "ready", |
| 956 | 1001 | providerName: "codex", |
| 957 | 1002 | runtimeMode: "approval-required", |
| @@ -966,11 +1011,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines | ||
| 966 | 1011 | await Effect.runPromise( |
| 967 | 1012 | harness.engine.dispatch({ |
| 968 | 1013 | type: "thread.turn.diff.complete", |
| 969 | − commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"), | |
| 970 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1014 | + commandId: CommandId.make("cmd-inline-revert-diff-1"), | |
| 1015 | + threadId: ThreadId.make("thread-1"), | |
| 971 | 1016 | turnId: asTurnId("turn-1"), |
| 972 | 1017 | completedAt: createdAt, |
| 973 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1), | |
| 1018 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), | |
| 974 | 1019 | status: "ready", |
| 975 | 1020 | files: [], |
| 976 | 1021 | checkpointTurnCount: 1, |
| @@ -980,11 +1025,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines | ||
| 980 | 1025 | await Effect.runPromise( |
| 981 | 1026 | harness.engine.dispatch({ |
| 982 | 1027 | type: "thread.turn.diff.complete", |
| 983 | − commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"), | |
| 984 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1028 | + commandId: CommandId.make("cmd-inline-revert-diff-2"), | |
| 1029 | + threadId: ThreadId.make("thread-1"), | |
| 985 | 1030 | turnId: asTurnId("turn-2"), |
| 986 | 1031 | completedAt: createdAt, |
| 987 | − checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2), | |
| 1032 | + checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2), | |
| 988 | 1033 | status: "ready", |
| 989 | 1034 | files: [], |
| 990 | 1035 | checkpointTurnCount: 2, |
| @@ -995,8 +1040,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines | ||
| 995 | 1040 | await Effect.runPromise( |
| 996 | 1041 | harness.engine.dispatch({ |
| 997 | 1042 | type: "thread.checkpoint.revert", |
| 998 | − commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"), | |
| 999 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1043 | + commandId: CommandId.make("cmd-sequenced-revert-request-1"), | |
| 1044 | + threadId: ThreadId.make("thread-1"), | |
| 1000 | 1045 | turnCount: 1, |
| 1001 | 1046 | createdAt, |
| 1002 | 1047 | }), |
| @@ -1004,8 +1049,8 @@ describe("CheckpointReactor", () => { · 1 unmodified line | ||
| 1004 | 1049 | await Effect.runPromise( |
| 1005 | 1050 | harness.engine.dispatch({ |
| 1006 | 1051 | type: "thread.checkpoint.revert", |
| 1007 | − commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"), | |
| 1008 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1052 | + commandId: CommandId.make("cmd-sequenced-revert-request-0"), | |
| 1053 | + threadId: ThreadId.make("thread-1"), | |
| 1009 | 1054 | turnCount: 0, |
| 1010 | 1055 | createdAt, |
| 1011 | 1056 | }), |
| @@ -1015,11 +1060,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines | ||
| 1015 | 1060 | |
| 1016 | 1061 | expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2); |
| 1017 | 1062 | expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({ |
| 1018 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1063 | + threadId: ThreadId.make("thread-1"), | |
| 1019 | 1064 | numTurns: 1, |
| 1020 | 1065 | }); |
| 1021 | 1066 | expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({ |
| 1022 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1067 | + threadId: ThreadId.make("thread-1"), | |
| 1023 | 1068 | numTurns: 1, |
| 1024 | 1069 | }); |
| 1025 | 1070 | }); |
| @@ -1031,8 +1076,8 @@ describe("CheckpointReactor", () => { · 5 unmodified lines | ||
| 1031 | 1076 | await Effect.runPromise( |
| 1032 | 1077 | harness.engine.dispatch({ |
| 1033 | 1078 | type: "thread.checkpoint.revert", |
| 1034 | − commandId: CommandId.makeUnsafe("cmd-revert-no-session"), | |
| 1035 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 1079 | + commandId: CommandId.make("cmd-revert-no-session"), | |
| 1080 | + threadId: ThreadId.make("thread-1"), | |
| 1036 | 1081 | turnCount: 1, |
| 1037 | 1082 | createdAt, |
| 1038 | 1083 | }), |
| @@ -21,9 +21,10 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; · 20 unmodified lines | ||
| 21 | 21 | import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts"; |
| 22 | 22 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 23 | 23 | import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; |
| 24 | −import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | |
| 25 | −import { OrchestrationDispatchError } from "../Errors.ts"; | |
| 24 | +import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | |
| 25 | +import type { OrchestrationDispatchError } from "../Errors.ts"; | |
| 26 | 26 | import { isGitRepository } from "../../git/Utils.ts"; |
| 27 | +import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; | |
| 27 | 28 | import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; |
| 28 | 29 | |
| 29 | 30 | type ReactorInput = |
| @@ -37,7 +38,7 @@ type ReactorInput = · 7 unmodified lines | ||
| 37 | 38 | }; |
| 38 | 39 | |
| 39 | 40 | function toTurnId(value: string | undefined): TurnId | null { |
| 40 | − return value === undefined ? null : TurnId.makeUnsafe(String(value)); | |
| 41 | + return value === undefined ? null : TurnId.make(String(value)); | |
| 41 | 42 | } |
| 42 | 43 | |
| 43 | 44 | function sameId(left: string | null | undefined, right: string | null | undefined): boolean { |
| @@ -61,7 +62,7 @@ function checkpointStatusFromRuntime(status: string | undefined): "ready" | "mis · 17 unmodified lines | ||
| 61 | 62 | } |
| 62 | 63 | |
| 63 | 64 | const serverCommandId = (tag: string): CommandId => |
| 64 | − CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | |
| 65 | + CommandId.make(`server:${tag}:${crypto.randomUUID()}`); | |
| 65 | 66 | |
| 66 | 67 | const make = Effect.gen(function* () { |
| 67 | 68 | const orchestrationEngine = yield* OrchestrationEngineService; |
| @@ -69,6 +70,7 @@ const make = Effect.gen(function* () { · 1 unmodified line | ||
| 69 | 70 | const checkpointStore = yield* CheckpointStore; |
| 70 | 71 | const receiptBus = yield* RuntimeReceiptBus; |
| 71 | 72 | const workspaceEntries = yield* WorkspaceEntries; |
| 73 | + const gitStatusBroadcaster = yield* GitStatusBroadcaster; | |
| 72 | 74 | |
| 73 | 75 | const appendRevertFailureActivity = (input: { |
| 74 | 76 | readonly threadId: ThreadId; |
| @@ -81,7 +83,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines | ||
| 81 | 83 | commandId: serverCommandId("checkpoint-revert-failure"), |
| 82 | 84 | threadId: input.threadId, |
| 83 | 85 | activity: { |
| 84 | − id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 86 | + id: EventId.make(crypto.randomUUID()), | |
| 85 | 87 | tone: "error", |
| 86 | 88 | kind: "checkpoint.revert.failed", |
| 87 | 89 | summary: "Checkpoint revert failed", |
| @@ -106,7 +108,7 @@ const make = Effect.gen(function* () { · 18 unmodified lines | ||
| 106 | 108 | commandId: serverCommandId("checkpoint-capture-failure"), |
| 107 | 109 | threadId: input.threadId, |
| 108 | 110 | activity: { |
| 109 | − id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 111 | + id: EventId.make(crypto.randomUUID()), | |
| 110 | 112 | tone: "error", |
| 111 | 113 | kind: "checkpoint.capture.failed", |
| 112 | 114 | summary: "Checkpoint capture failed", |
| @@ -268,7 +270,7 @@ const make = Effect.gen(function* () { · 155 unmodified lines | ||
| 268 | 270 | input.thread.messages |
| 269 | 271 | .toReversed() |
| 270 | 272 | .find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id ?? |
| 271 | − MessageId.makeUnsafe(`assistant:${input.turnId}`); | |
| 273 | + MessageId.make(`assistant:${input.turnId}`); | |
| 272 | 274 | |
| 273 | 275 | yield* orchestrationEngine.dispatch({ |
| 274 | 276 | type: "thread.turn.diff.complete", |
| @@ -305,7 +307,7 @@ const make = Effect.gen(function* () { · 30 unmodified lines | ||
| 305 | 307 | commandId: serverCommandId("checkpoint-captured-activity"), |
| 306 | 308 | threadId: input.threadId, |
| 307 | 309 | activity: { |
| 308 | − id: EventId.makeUnsafe(crypto.randomUUID()), | |
| 310 | + id: EventId.make(crypto.randomUUID()), | |
| 309 | 311 | tone: "info", |
| 310 | 312 | kind: "checkpoint.captured", |
| 311 | 313 | summary: "Checkpoint captured", |
| @@ -498,6 +500,26 @@ const make = Effect.gen(function* () { · 186 unmodified lines | ||
| 498 | 500 | }, |
| 499 | 501 | ); |
| 500 | 502 | |
| 503 | + const refreshLocalGitStatusFromTurnCompletion = Effect.fn( | |
| 504 | + "refreshLocalGitStatusFromTurnCompletion", | |
| 505 | + )(function* (event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>) { | |
| 506 | + const sessionRuntime = yield* resolveSessionRuntimeForThread(event.threadId); | |
| 507 | + if (Option.isNone(sessionRuntime)) { | |
| 508 | + return; | |
| 509 | + } | |
| 510 | + | |
| 511 | + yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( | |
| 512 | + Effect.catch((error) => | |
| 513 | + Effect.logWarning("failed to refresh local git status after turn completion", { | |
| 514 | + threadId: event.threadId, | |
| 515 | + turnId: event.turnId ?? null, | |
| 516 | + cwd: sessionRuntime.value.cwd, | |
| 517 | + detail: error.message, | |
| 518 | + }), | |
| 519 | + ), | |
| 520 | + ); | |
| 521 | + }); | |
| 522 | + | |
| 501 | 523 | const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn( |
| 502 | 524 | "ensurePreTurnBaselineFromDomainTurnStart", |
| 503 | 525 | )(function* ( |
| @@ -736,6 +758,7 @@ const make = Effect.gen(function* () { · 232 unmodified lines | ||
| 736 | 758 | |
| 737 | 759 | if (event.type === "turn.completed") { |
| 738 | 760 | const turnId = toTurnId(event.turnId); |
| 761 | + yield* refreshLocalGitStatusFromTurnCompletion(event); | |
| 739 | 762 | yield* captureCheckpointFromTurnCompletion(event).pipe( |
| 740 | 763 | Effect.catch((error) => |
| 741 | 764 | appendCaptureFailureActivity({ |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | OrchestrationEventStore, |
| 20 | 20 | type OrchestrationEventStoreShape, |
| 21 | 21 | } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 22 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 22 | 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 23 | 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 24 | 25 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -31,10 +32,10 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" · 6 unmodified lines | ||
| 31 | 32 | import { ServerConfig } from "../../config.ts"; |
| 32 | 33 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 33 | 34 | |
| 34 | −const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 35 | −const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 36 | −const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 37 | −const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 35 | +const asProjectId = (value: string): ProjectId => ProjectId.make(value); | |
| 36 | +const asMessageId = (value: string): MessageId => MessageId.make(value); | |
| 37 | +const asTurnId = (value: string): TurnId => TurnId.make(value); | |
| 38 | +const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); | |
| 38 | 39 | |
| 39 | 40 | async function createOrchestrationSystem() { |
| 40 | 41 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 4 unmodified lines | ||
| 45 | 46 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 46 | 47 | Layer.provide(OrchestrationEventStoreLive), |
| 47 | 48 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 49 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 48 | 50 | Layer.provide(SqlitePersistenceMemory), |
| 49 | 51 | Layer.provideMerge(ServerConfigLayer), |
| 50 | 52 | Layer.provideMerge(NodeServices.layer), |
| @@ -113,7 +115,7 @@ describe("OrchestrationEngine", () => { · 62 unmodified lines | ||
| 113 | 115 | ], |
| 114 | 116 | threads: [ |
| 115 | 117 | { |
| 116 | − id: ThreadId.makeUnsafe("thread-bootstrap"), | |
| 118 | + id: ThreadId.make("thread-bootstrap"), | |
| 117 | 119 | projectId: asProjectId("project-bootstrap"), |
| 118 | 120 | title: "Bootstrap Thread", |
| 119 | 121 | modelSelection: { |
| @@ -142,10 +144,20 @@ describe("OrchestrationEngine", () => { · 22 unmodified lines | ||
| 142 | 144 | Layer.provide( |
| 143 | 145 | Layer.succeed(ProjectionSnapshotQuery, { |
| 144 | 146 | getSnapshot: () => Effect.succeed(projectionSnapshot), |
| 147 | + getShellSnapshot: () => | |
| 148 | + Effect.succeed({ | |
| 149 | + snapshotSequence: projectionSnapshot.snapshotSequence, | |
| 150 | + projects: [], | |
| 151 | + threads: [], | |
| 152 | + updatedAt: projectionSnapshot.updatedAt, | |
| 153 | + }), | |
| 145 | 154 | getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), |
| 146 | 155 | getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), |
| 156 | + getProjectShellById: () => Effect.succeed(Option.none()), | |
| 147 | 157 | getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), |
| 148 | 158 | getThreadCheckpointContext: () => Effect.succeed(Option.none()), |
| 159 | + getThreadShellById: () => Effect.succeed(Option.none()), | |
| 160 | + getThreadDetailById: () => Effect.succeed(Option.none()), | |
| 149 | 161 | }), |
| 150 | 162 | ), |
| 151 | 163 | Layer.provide( |
| @@ -181,7 +193,7 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines | ||
| 181 | 193 | await system.run( |
| 182 | 194 | engine.dispatch({ |
| 183 | 195 | type: "project.create", |
| 184 | − commandId: CommandId.makeUnsafe("cmd-project-1-create"), | |
| 196 | + commandId: CommandId.make("cmd-project-1-create"), | |
| 185 | 197 | projectId: asProjectId("project-1"), |
| 186 | 198 | title: "Project 1", |
| 187 | 199 | workspaceRoot: "/tmp/project-1", |
| @@ -195,8 +207,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines | ||
| 195 | 207 | await system.run( |
| 196 | 208 | engine.dispatch({ |
| 197 | 209 | type: "thread.create", |
| 198 | − commandId: CommandId.makeUnsafe("cmd-thread-1-create"), | |
| 199 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 210 | + commandId: CommandId.make("cmd-thread-1-create"), | |
| 211 | + threadId: ThreadId.make("thread-1"), | |
| 200 | 212 | projectId: asProjectId("project-1"), |
| 201 | 213 | title: "Thread", |
| 202 | 214 | modelSelection: { |
| @@ -213,8 +225,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines | ||
| 213 | 225 | await system.run( |
| 214 | 226 | engine.dispatch({ |
| 215 | 227 | type: "thread.turn.start", |
| 216 | − commandId: CommandId.makeUnsafe("cmd-turn-start-1"), | |
| 217 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 228 | + commandId: CommandId.make("cmd-turn-start-1"), | |
| 229 | + threadId: ThreadId.make("thread-1"), | |
| 218 | 230 | message: { |
| 219 | 231 | messageId: asMessageId("msg-1"), |
| 220 | 232 | role: "user", |
| @@ -241,7 +253,7 @@ describe("OrchestrationEngine", () => { · 20 unmodified lines | ||
| 241 | 253 | await system.run( |
| 242 | 254 | engine.dispatch({ |
| 243 | 255 | type: "project.create", |
| 244 | − commandId: CommandId.makeUnsafe("cmd-project-archive-create"), | |
| 256 | + commandId: CommandId.make("cmd-project-archive-create"), | |
| 245 | 257 | projectId: asProjectId("project-archive"), |
| 246 | 258 | title: "Project Archive", |
| 247 | 259 | workspaceRoot: "/tmp/project-archive", |
| @@ -255,8 +267,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines | ||
| 255 | 267 | await system.run( |
| 256 | 268 | engine.dispatch({ |
| 257 | 269 | type: "thread.create", |
| 258 | − commandId: CommandId.makeUnsafe("cmd-thread-archive-create"), | |
| 259 | − threadId: ThreadId.makeUnsafe("thread-archive"), | |
| 270 | + commandId: CommandId.make("cmd-thread-archive-create"), | |
| 271 | + threadId: ThreadId.make("thread-archive"), | |
| 260 | 272 | projectId: asProjectId("project-archive"), |
| 261 | 273 | title: "Archive me", |
| 262 | 274 | modelSelection: { |
| @@ -274,8 +286,8 @@ describe("OrchestrationEngine", () => { · 11 unmodified lines | ||
| 274 | 286 | await system.run( |
| 275 | 287 | engine.dispatch({ |
| 276 | 288 | type: "thread.archive", |
| 277 | − commandId: CommandId.makeUnsafe("cmd-thread-archive"), | |
| 278 | − threadId: ThreadId.makeUnsafe("thread-archive"), | |
| 289 | + commandId: CommandId.make("cmd-thread-archive"), | |
| 290 | + threadId: ThreadId.make("thread-archive"), | |
| 279 | 291 | }), |
| 280 | 292 | ); |
| 281 | 293 | expect( |
| @@ -287,8 +299,8 @@ describe("OrchestrationEngine", () => { · 5 unmodified lines | ||
| 287 | 299 | await system.run( |
| 288 | 300 | engine.dispatch({ |
| 289 | 301 | type: "thread.unarchive", |
| 290 | − commandId: CommandId.makeUnsafe("cmd-thread-unarchive"), | |
| 291 | − threadId: ThreadId.makeUnsafe("thread-archive"), | |
| 302 | + commandId: CommandId.make("cmd-thread-unarchive"), | |
| 303 | + threadId: ThreadId.make("thread-archive"), | |
| 292 | 304 | }), |
| 293 | 305 | ); |
| 294 | 306 | expect( |
| @@ -308,7 +320,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines | ||
| 308 | 320 | await system.run( |
| 309 | 321 | engine.dispatch({ |
| 310 | 322 | type: "project.create", |
| 311 | − commandId: CommandId.makeUnsafe("cmd-project-replay-create"), | |
| 323 | + commandId: CommandId.make("cmd-project-replay-create"), | |
| 312 | 324 | projectId: asProjectId("project-replay"), |
| 313 | 325 | title: "Replay Project", |
| 314 | 326 | workspaceRoot: "/tmp/project-replay", |
| @@ -322,8 +334,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines | ||
| 322 | 334 | await system.run( |
| 323 | 335 | engine.dispatch({ |
| 324 | 336 | type: "thread.create", |
| 325 | − commandId: CommandId.makeUnsafe("cmd-thread-replay-create"), | |
| 326 | − threadId: ThreadId.makeUnsafe("thread-replay"), | |
| 337 | + commandId: CommandId.make("cmd-thread-replay-create"), | |
| 338 | + threadId: ThreadId.make("thread-replay"), | |
| 327 | 339 | projectId: asProjectId("project-replay"), |
| 328 | 340 | title: "replay", |
| 329 | 341 | modelSelection: { |
| @@ -340,8 +352,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines | ||
| 340 | 352 | await system.run( |
| 341 | 353 | engine.dispatch({ |
| 342 | 354 | type: "thread.delete", |
| 343 | − commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"), | |
| 344 | − threadId: ThreadId.makeUnsafe("thread-replay"), | |
| 355 | + commandId: CommandId.make("cmd-thread-replay-delete"), | |
| 356 | + threadId: ThreadId.make("thread-replay"), | |
| 345 | 357 | }), |
| 346 | 358 | ); |
| 347 | 359 | |
| @@ -366,7 +378,7 @@ describe("OrchestrationEngine", () => { · 18 unmodified lines | ||
| 366 | 378 | await system.run( |
| 367 | 379 | engine.dispatch({ |
| 368 | 380 | type: "project.create", |
| 369 | − commandId: CommandId.makeUnsafe("cmd-project-stream-create"), | |
| 381 | + commandId: CommandId.make("cmd-project-stream-create"), | |
| 370 | 382 | projectId: asProjectId("project-stream"), |
| 371 | 383 | title: "Stream Project", |
| 372 | 384 | workspaceRoot: "/tmp/project-stream", |
| @@ -390,8 +402,8 @@ describe("OrchestrationEngine", () => { · 17 unmodified lines | ||
| 390 | 402 | yield* Effect.sleep("10 millis"); |
| 391 | 403 | yield* engine.dispatch({ |
| 392 | 404 | type: "thread.create", |
| 393 | − commandId: CommandId.makeUnsafe("cmd-stream-thread-create"), | |
| 394 | − threadId: ThreadId.makeUnsafe("thread-stream"), | |
| 405 | + commandId: CommandId.make("cmd-stream-thread-create"), | |
| 406 | + threadId: ThreadId.make("thread-stream"), | |
| 395 | 407 | projectId: asProjectId("project-stream"), |
| 396 | 408 | title: "domain-stream", |
| 397 | 409 | modelSelection: { |
| @@ -406,8 +418,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines | ||
| 406 | 418 | }); |
| 407 | 419 | yield* engine.dispatch({ |
| 408 | 420 | type: "thread.meta.update", |
| 409 | − commandId: CommandId.makeUnsafe("cmd-stream-thread-update"), | |
| 410 | − threadId: ThreadId.makeUnsafe("thread-stream"), | |
| 421 | + commandId: CommandId.make("cmd-stream-thread-update"), | |
| 422 | + threadId: ThreadId.make("thread-stream"), | |
| 411 | 423 | title: "domain-stream-updated", |
| 412 | 424 | }); |
| 413 | 425 | eventTypes.push((yield* Queue.take(eventQueue)).type); |
| @@ -427,7 +439,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines | ||
| 427 | 439 | await system.run( |
| 428 | 440 | engine.dispatch({ |
| 429 | 441 | type: "project.create", |
| 430 | − commandId: CommandId.makeUnsafe("cmd-project-ack-create"), | |
| 442 | + commandId: CommandId.make("cmd-project-ack-create"), | |
| 431 | 443 | projectId: asProjectId("project-ack"), |
| 432 | 444 | title: "Ack Project", |
| 433 | 445 | workspaceRoot: "/tmp/project-ack", |
| @@ -442,8 +454,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines | ||
| 442 | 454 | await system.run( |
| 443 | 455 | engine.dispatch({ |
| 444 | 456 | type: "thread.create", |
| 445 | − commandId: CommandId.makeUnsafe("cmd-thread-ack-create"), | |
| 446 | − threadId: ThreadId.makeUnsafe("thread-ack"), | |
| 457 | + commandId: CommandId.make("cmd-thread-ack-create"), | |
| 458 | + threadId: ThreadId.make("thread-ack"), | |
| 447 | 459 | projectId: asProjectId("project-ack"), |
| 448 | 460 | title: "Ack Thread", |
| 449 | 461 | modelSelection: { |
| @@ -479,8 +491,8 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines | ||
| 479 | 491 | system.run( |
| 480 | 492 | engine.dispatch({ |
| 481 | 493 | type: "thread.create", |
| 482 | − commandId: CommandId.makeUnsafe("cmd-thread-missing-project"), | |
| 483 | − threadId: ThreadId.makeUnsafe("thread-missing-project"), | |
| 494 | + commandId: CommandId.make("cmd-thread-missing-project"), | |
| 495 | + threadId: ThreadId.make("thread-missing-project"), | |
| 484 | 496 | projectId: asProjectId("project-missing"), |
| 485 | 497 | title: "Missing Project Thread", |
| 486 | 498 | modelSelection: { |
| @@ -516,7 +528,7 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines | ||
| 516 | 528 | await system.run( |
| 517 | 529 | engine.dispatch({ |
| 518 | 530 | type: "project.create", |
| 519 | − commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"), | |
| 531 | + commandId: CommandId.make("cmd-project-turn-diff-create"), | |
| 520 | 532 | projectId: asProjectId("project-turn-diff"), |
| 521 | 533 | title: "Turn Diff Project", |
| 522 | 534 | workspaceRoot: "/tmp/project-turn-diff", |
| @@ -530,8 +542,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines | ||
| 530 | 542 | await system.run( |
| 531 | 543 | engine.dispatch({ |
| 532 | 544 | type: "thread.create", |
| 533 | − commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"), | |
| 534 | − threadId: ThreadId.makeUnsafe("thread-turn-diff"), | |
| 545 | + commandId: CommandId.make("cmd-thread-turn-diff-create"), | |
| 546 | + threadId: ThreadId.make("thread-turn-diff"), | |
| 535 | 547 | projectId: asProjectId("project-turn-diff"), |
| 536 | 548 | title: "Turn diff thread", |
| 537 | 549 | modelSelection: { |
| @@ -548,8 +560,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines | ||
| 548 | 560 | await system.run( |
| 549 | 561 | engine.dispatch({ |
| 550 | 562 | type: "thread.turn.diff.complete", |
| 551 | − commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"), | |
| 552 | − threadId: ThreadId.makeUnsafe("thread-turn-diff"), | |
| 563 | + commandId: CommandId.make("cmd-turn-diff-complete"), | |
| 564 | + threadId: ThreadId.make("thread-turn-diff"), | |
| 553 | 565 | turnId: asTurnId("turn-1"), |
| 554 | 566 | completedAt: createdAt, |
| 555 | 567 | checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), |
| @@ -588,7 +600,7 @@ describe("OrchestrationEngine", () => { · 32 unmodified lines | ||
| 588 | 600 | |
| 589 | 601 | const flakyStore: OrchestrationEventStoreShape = { |
| 590 | 602 | append(event) { |
| 591 | − if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) { | |
| 603 | + if (shouldFailFirstAppend && event.commandId === CommandId.make("cmd-flaky-1")) { | |
| 592 | 604 | shouldFailFirstAppend = false; |
| 593 | 605 | return Effect.fail( |
| 594 | 606 | new PersistenceSqlError({ |
| @@ -623,6 +635,7 @@ describe("OrchestrationEngine", () => { · 28 unmodified lines | ||
| 623 | 635 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 624 | 636 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 625 | 637 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 638 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 626 | 639 | Layer.provide(SqlitePersistenceMemory), |
| 627 | 640 | Layer.provideMerge(ServerConfigLayer), |
| 628 | 641 | Layer.provideMerge(NodeServices.layer), |
| @@ -634,7 +647,7 @@ describe("OrchestrationEngine", () => { · 5 unmodified lines | ||
| 634 | 647 | await runtime.runPromise( |
| 635 | 648 | engine.dispatch({ |
| 636 | 649 | type: "project.create", |
| 637 | − commandId: CommandId.makeUnsafe("cmd-project-flaky-create"), | |
| 650 | + commandId: CommandId.make("cmd-project-flaky-create"), | |
| 638 | 651 | projectId: asProjectId("project-flaky"), |
| 639 | 652 | title: "Flaky Project", |
| 640 | 653 | workspaceRoot: "/tmp/project-flaky", |
| @@ -650,8 +663,8 @@ describe("OrchestrationEngine", () => { · 9 unmodified lines | ||
| 650 | 663 | runtime.runPromise( |
| 651 | 664 | engine.dispatch({ |
| 652 | 665 | type: "thread.create", |
| 653 | − commandId: CommandId.makeUnsafe("cmd-flaky-1"), | |
| 654 | − threadId: ThreadId.makeUnsafe("thread-flaky-fail"), | |
| 666 | + commandId: CommandId.make("cmd-flaky-1"), | |
| 667 | + threadId: ThreadId.make("thread-flaky-fail"), | |
| 655 | 668 | projectId: asProjectId("project-flaky"), |
| 656 | 669 | title: "flaky-fail", |
| 657 | 670 | modelSelection: { |
| @@ -670,8 +683,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines | ||
| 670 | 683 | const result = await runtime.runPromise( |
| 671 | 684 | engine.dispatch({ |
| 672 | 685 | type: "thread.create", |
| 673 | − commandId: CommandId.makeUnsafe("cmd-flaky-2"), | |
| 674 | − threadId: ThreadId.makeUnsafe("thread-flaky-ok"), | |
| 686 | + commandId: CommandId.make("cmd-flaky-2"), | |
| 687 | + threadId: ThreadId.make("thread-flaky-ok"), | |
| 675 | 688 | projectId: asProjectId("project-flaky"), |
| 676 | 689 | title: "flaky-ok", |
| 677 | 690 | modelSelection: { |
| @@ -698,7 +711,7 @@ describe("OrchestrationEngine", () => { · 20 unmodified lines | ||
| 698 | 711 | projectEvent: (event) => { |
| 699 | 712 | if ( |
| 700 | 713 | shouldFailRequestedProjection && |
| 701 | − event.commandId === CommandId.makeUnsafe("cmd-turn-start-atomic") && | |
| 714 | + event.commandId === CommandId.make("cmd-turn-start-atomic") && | |
| 702 | 715 | event.type === "thread.turn-start-requested" |
| 703 | 716 | ) { |
| 704 | 717 | shouldFailRequestedProjection = false; |
| @@ -719,6 +732,7 @@ describe("OrchestrationEngine", () => { · 14 unmodified lines | ||
| 719 | 732 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 720 | 733 | Layer.provide(OrchestrationEventStoreLive), |
| 721 | 734 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 735 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 722 | 736 | Layer.provide(SqlitePersistenceMemory), |
| 723 | 737 | ), |
| 724 | 738 | ); |
| @@ -728,7 +742,7 @@ describe("OrchestrationEngine", () => { · 3 unmodified lines | ||
| 728 | 742 | await runtime.runPromise( |
| 729 | 743 | engine.dispatch({ |
| 730 | 744 | type: "project.create", |
| 731 | − commandId: CommandId.makeUnsafe("cmd-project-atomic-create"), | |
| 745 | + commandId: CommandId.make("cmd-project-atomic-create"), | |
| 732 | 746 | projectId: asProjectId("project-atomic"), |
| 733 | 747 | title: "Atomic Project", |
| 734 | 748 | workspaceRoot: "/tmp/project-atomic", |
| @@ -742,8 +756,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines | ||
| 742 | 756 | await runtime.runPromise( |
| 743 | 757 | engine.dispatch({ |
| 744 | 758 | type: "thread.create", |
| 745 | − commandId: CommandId.makeUnsafe("cmd-thread-atomic-create"), | |
| 746 | − threadId: ThreadId.makeUnsafe("thread-atomic"), | |
| 759 | + commandId: CommandId.make("cmd-thread-atomic-create"), | |
| 760 | + threadId: ThreadId.make("thread-atomic"), | |
| 747 | 761 | projectId: asProjectId("project-atomic"), |
| 748 | 762 | title: "atomic", |
| 749 | 763 | modelSelection: { |
| @@ -760,8 +774,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines | ||
| 760 | 774 | |
| 761 | 775 | const turnStartCommand = { |
| 762 | 776 | type: "thread.turn.start" as const, |
| 763 | − commandId: CommandId.makeUnsafe("cmd-turn-start-atomic"), | |
| 764 | − threadId: ThreadId.makeUnsafe("thread-atomic"), | |
| 777 | + commandId: CommandId.make("cmd-turn-start-atomic"), | |
| 778 | + threadId: ThreadId.make("thread-atomic"), | |
| 765 | 779 | message: { |
| 766 | 780 | messageId: asMessageId("msg-atomic-1"), |
| 767 | 781 | role: "user" as const, |
| @@ -841,7 +855,7 @@ describe("OrchestrationEngine", () => { · 73 unmodified lines | ||
| 841 | 855 | projectEvent: (event) => { |
| 842 | 856 | if ( |
| 843 | 857 | shouldFailProjection && |
| 844 | − event.commandId === CommandId.makeUnsafe("cmd-thread-meta-sync-fail") | |
| 858 | + event.commandId === CommandId.make("cmd-thread-meta-sync-fail") | |
| 845 | 859 | ) { |
| 846 | 860 | shouldFailProjection = false; |
| 847 | 861 | return Effect.fail( |
| @@ -861,6 +875,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines | ||
| 861 | 875 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 862 | 876 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 863 | 877 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 878 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 864 | 879 | Layer.provide(SqlitePersistenceMemory), |
| 865 | 880 | ), |
| 866 | 881 | ); |
| @@ -870,7 +885,7 @@ describe("OrchestrationEngine", () => { · 3 unmodified lines | ||
| 870 | 885 | await runtime.runPromise( |
| 871 | 886 | engine.dispatch({ |
| 872 | 887 | type: "project.create", |
| 873 | − commandId: CommandId.makeUnsafe("cmd-project-sync-create"), | |
| 888 | + commandId: CommandId.make("cmd-project-sync-create"), | |
| 874 | 889 | projectId: asProjectId("project-sync"), |
| 875 | 890 | title: "Sync Project", |
| 876 | 891 | workspaceRoot: "/tmp/project-sync", |
| @@ -884,8 +899,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines | ||
| 884 | 899 | await runtime.runPromise( |
| 885 | 900 | engine.dispatch({ |
| 886 | 901 | type: "thread.create", |
| 887 | − commandId: CommandId.makeUnsafe("cmd-thread-sync-create"), | |
| 888 | − threadId: ThreadId.makeUnsafe("thread-sync"), | |
| 902 | + commandId: CommandId.make("cmd-thread-sync-create"), | |
| 903 | + threadId: ThreadId.make("thread-sync"), | |
| 889 | 904 | projectId: asProjectId("project-sync"), |
| 890 | 905 | title: "sync-before", |
| 891 | 906 | modelSelection: { |
| @@ -904,8 +919,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines | ||
| 904 | 919 | runtime.runPromise( |
| 905 | 920 | engine.dispatch({ |
| 906 | 921 | type: "thread.meta.update", |
| 907 | − commandId: CommandId.makeUnsafe("cmd-thread-meta-sync-fail"), | |
| 908 | − threadId: ThreadId.makeUnsafe("thread-sync"), | |
| 922 | + commandId: CommandId.make("cmd-thread-meta-sync-fail"), | |
| 923 | + threadId: ThreadId.make("thread-sync"), | |
| 909 | 924 | title: "sync-after-failed-projection", |
| 910 | 925 | }), |
| 911 | 926 | ), |
| @@ -929,8 +944,8 @@ describe("OrchestrationEngine", () => { · 17 unmodified lines | ||
| 929 | 944 | system.run( |
| 930 | 945 | engine.dispatch({ |
| 931 | 946 | type: "thread.turn.start", |
| 932 | − commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"), | |
| 933 | − threadId: ThreadId.makeUnsafe("thread-missing"), | |
| 947 | + commandId: CommandId.make("cmd-invariant-missing-thread"), | |
| 948 | + threadId: ThreadId.make("thread-missing"), | |
| 934 | 949 | message: { |
| 935 | 950 | messageId: asMessageId("msg-missing"), |
| 936 | 951 | role: "user", |
| @@ -955,7 +970,7 @@ describe("OrchestrationEngine", () => { · 18 unmodified lines | ||
| 955 | 970 | await system.run( |
| 956 | 971 | engine.dispatch({ |
| 957 | 972 | type: "project.create", |
| 958 | − commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"), | |
| 973 | + commandId: CommandId.make("cmd-project-duplicate-create"), | |
| 959 | 974 | projectId: asProjectId("project-duplicate"), |
| 960 | 975 | title: "Duplicate Project", |
| 961 | 976 | workspaceRoot: "/tmp/project-duplicate", |
| @@ -970,8 +985,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines | ||
| 970 | 985 | await system.run( |
| 971 | 986 | engine.dispatch({ |
| 972 | 987 | type: "thread.create", |
| 973 | − commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"), | |
| 974 | − threadId: ThreadId.makeUnsafe("thread-duplicate"), | |
| 988 | + commandId: CommandId.make("cmd-thread-duplicate-1"), | |
| 989 | + threadId: ThreadId.make("thread-duplicate"), | |
| 975 | 990 | projectId: asProjectId("project-duplicate"), |
| 976 | 991 | title: "duplicate", |
| 977 | 992 | modelSelection: { |
| @@ -990,8 +1005,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines | ||
| 990 | 1005 | system.run( |
| 991 | 1006 | engine.dispatch({ |
| 992 | 1007 | type: "thread.create", |
| 993 | − commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"), | |
| 994 | − threadId: ThreadId.makeUnsafe("thread-duplicate"), | |
| 1008 | + commandId: CommandId.make("cmd-thread-duplicate-2"), | |
| 1009 | + threadId: ThreadId.make("thread-duplicate"), | |
| 995 | 1010 | projectId: asProjectId("project-duplicate"), |
| 996 | 1011 | title: "duplicate", |
| 997 | 1012 | modelSelection: { |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | SqlitePersistenceMemory, |
| 21 | 21 | } from "../../persistence/Layers/Sqlite.ts"; |
| 22 | 22 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 23 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 23 | 24 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | 25 | import { |
| 25 | 26 | ORCHESTRATION_PROJECTOR_NAMES, |
| @@ -57,16 +58,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 31 unmodified lines | ||
| 57 | 58 | |
| 58 | 59 | yield* eventStore.append({ |
| 59 | 60 | type: "project.created", |
| 60 | − eventId: EventId.makeUnsafe("evt-1"), | |
| 61 | + eventId: EventId.make("evt-1"), | |
| 61 | 62 | aggregateKind: "project", |
| 62 | − aggregateId: ProjectId.makeUnsafe("project-1"), | |
| 63 | + aggregateId: ProjectId.make("project-1"), | |
| 63 | 64 | occurredAt: now, |
| 64 | − commandId: CommandId.makeUnsafe("cmd-1"), | |
| 65 | + commandId: CommandId.make("cmd-1"), | |
| 65 | 66 | causationEventId: null, |
| 66 | − correlationId: CommandId.makeUnsafe("cmd-1"), | |
| 67 | + correlationId: CommandId.make("cmd-1"), | |
| 67 | 68 | metadata: {}, |
| 68 | 69 | payload: { |
| 69 | − projectId: ProjectId.makeUnsafe("project-1"), | |
| 70 | + projectId: ProjectId.make("project-1"), | |
| 70 | 71 | title: "Project 1", |
| 71 | 72 | workspaceRoot: "/tmp/project-1", |
| 72 | 73 | defaultModelSelection: null, |
| @@ -78,17 +79,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 78 | 79 | |
| 79 | 80 | yield* eventStore.append({ |
| 80 | 81 | type: "thread.created", |
| 81 | − eventId: EventId.makeUnsafe("evt-2"), | |
| 82 | + eventId: EventId.make("evt-2"), | |
| 82 | 83 | aggregateKind: "thread", |
| 83 | − aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 84 | + aggregateId: ThreadId.make("thread-1"), | |
| 84 | 85 | occurredAt: now, |
| 85 | − commandId: CommandId.makeUnsafe("cmd-2"), | |
| 86 | + commandId: CommandId.make("cmd-2"), | |
| 86 | 87 | causationEventId: null, |
| 87 | − correlationId: CommandId.makeUnsafe("cmd-2"), | |
| 88 | + correlationId: CommandId.make("cmd-2"), | |
| 88 | 89 | metadata: {}, |
| 89 | 90 | payload: { |
| 90 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 91 | − projectId: ProjectId.makeUnsafe("project-1"), | |
| 91 | + threadId: ThreadId.make("thread-1"), | |
| 92 | + projectId: ProjectId.make("project-1"), | |
| 92 | 93 | title: "Thread 1", |
| 93 | 94 | modelSelection: { |
| 94 | 95 | provider: "codex", |
| @@ -104,17 +105,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines | ||
| 104 | 105 | |
| 105 | 106 | yield* eventStore.append({ |
| 106 | 107 | type: "thread.message-sent", |
| 107 | − eventId: EventId.makeUnsafe("evt-3"), | |
| 108 | + eventId: EventId.make("evt-3"), | |
| 108 | 109 | aggregateKind: "thread", |
| 109 | − aggregateId: ThreadId.makeUnsafe("thread-1"), | |
| 110 | + aggregateId: ThreadId.make("thread-1"), | |
| 110 | 111 | occurredAt: now, |
| 111 | − commandId: CommandId.makeUnsafe("cmd-3"), | |
| 112 | + commandId: CommandId.make("cmd-3"), | |
| 112 | 113 | causationEventId: null, |
| 113 | − correlationId: CommandId.makeUnsafe("cmd-3"), | |
| 114 | + correlationId: CommandId.make("cmd-3"), | |
| 114 | 115 | metadata: {}, |
| 115 | 116 | payload: { |
| 116 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 117 | − messageId: MessageId.makeUnsafe("message-1"), | |
| 117 | + threadId: ThreadId.make("thread-1"), | |
| 118 | + messageId: MessageId.make("message-1"), | |
| 118 | 119 | role: "assistant", |
| 119 | 120 | text: "hello", |
| 120 | 121 | turnId: null, |
| @@ -182,17 +183,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( · 61 unmodified lines | ||
| 182 | 183 | |
| 183 | 184 | yield* eventStore.append({ |
| 184 | 185 | type: "thread.message-sent", |
| 185 | − eventId: EventId.makeUnsafe("evt-attachments"), | |
| 186 | + eventId: EventId.make("evt-attachments"), | |
| 186 | 187 | aggregateKind: "thread", |
| 187 | − aggregateId: ThreadId.makeUnsafe("thread-attachments"), | |
| 188 | + aggregateId: ThreadId.make("thread-attachments"), | |
| 188 | 189 | occurredAt: now, |
| 189 | − commandId: CommandId.makeUnsafe("cmd-attachments"), | |
| 190 | + commandId: CommandId.make("cmd-attachments"), | |
| 190 | 191 | causationEventId: null, |
| 191 | − correlationId: CommandId.makeUnsafe("cmd-attachments"), | |
| 192 | + correlationId: CommandId.make("cmd-attachments"), | |
| 192 | 193 | metadata: {}, |
| 193 | 194 | payload: { |
| 194 | − threadId: ThreadId.makeUnsafe("thread-attachments"), | |
| 195 | − messageId: MessageId.makeUnsafe("message-attachments"), | |
| 195 | + threadId: ThreadId.make("thread-attachments"), | |
| 196 | + messageId: MessageId.make("message-attachments"), | |
| 196 | 197 | role: "user", |
| 197 | 198 | text: "Inspect this", |
| 198 | 199 | attachments: [ |
| @@ -248,17 +249,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 49 unmodified lines | ||
| 248 | 249 | |
| 249 | 250 | yield* eventStore.append({ |
| 250 | 251 | type: "thread.message-sent", |
| 251 | − eventId: EventId.makeUnsafe("evt-attachments-safe"), | |
| 252 | + eventId: EventId.make("evt-attachments-safe"), | |
| 252 | 253 | aggregateKind: "thread", |
| 253 | − aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), | |
| 254 | + aggregateId: ThreadId.make("thread-attachments-safe"), | |
| 254 | 255 | occurredAt: now, |
| 255 | − commandId: CommandId.makeUnsafe("cmd-attachments-safe"), | |
| 256 | + commandId: CommandId.make("cmd-attachments-safe"), | |
| 256 | 257 | causationEventId: null, |
| 257 | − correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), | |
| 258 | + correlationId: CommandId.make("cmd-attachments-safe"), | |
| 258 | 259 | metadata: {}, |
| 259 | 260 | payload: { |
| 260 | − threadId: ThreadId.makeUnsafe("thread-attachments-safe"), | |
| 261 | − messageId: MessageId.makeUnsafe("message-attachments-safe"), | |
| 261 | + threadId: ThreadId.make("thread-attachments-safe"), | |
| 262 | + messageId: MessageId.make("message-attachments-safe"), | |
| 262 | 263 | role: "user", |
| 263 | 264 | text: "Inspect this", |
| 264 | 265 | attachments: [ |
| @@ -329,16 +330,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 64 unmodified lines | ||
| 329 | 330 | |
| 330 | 331 | yield* eventStore.append({ |
| 331 | 332 | type: "project.created", |
| 332 | − eventId: EventId.makeUnsafe("evt-clear-attachments-1"), | |
| 333 | + eventId: EventId.make("evt-clear-attachments-1"), | |
| 333 | 334 | aggregateKind: "project", |
| 334 | − aggregateId: ProjectId.makeUnsafe("project-clear-attachments"), | |
| 335 | + aggregateId: ProjectId.make("project-clear-attachments"), | |
| 335 | 336 | occurredAt: now, |
| 336 | − commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"), | |
| 337 | + commandId: CommandId.make("cmd-clear-attachments-1"), | |
| 337 | 338 | causationEventId: null, |
| 338 | − correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"), | |
| 339 | + correlationId: CommandId.make("cmd-clear-attachments-1"), | |
| 339 | 340 | metadata: {}, |
| 340 | 341 | payload: { |
| 341 | − projectId: ProjectId.makeUnsafe("project-clear-attachments"), | |
| 342 | + projectId: ProjectId.make("project-clear-attachments"), | |
| 342 | 343 | title: "Project Clear Attachments", |
| 343 | 344 | workspaceRoot: "/tmp/project-clear-attachments", |
| 344 | 345 | defaultModelSelection: null, |
| @@ -350,17 +351,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 350 | 351 | |
| 351 | 352 | yield* eventStore.append({ |
| 352 | 353 | type: "thread.created", |
| 353 | − eventId: EventId.makeUnsafe("evt-clear-attachments-2"), | |
| 354 | + eventId: EventId.make("evt-clear-attachments-2"), | |
| 354 | 355 | aggregateKind: "thread", |
| 355 | − aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), | |
| 356 | + aggregateId: ThreadId.make("thread-clear-attachments"), | |
| 356 | 357 | occurredAt: now, |
| 357 | − commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"), | |
| 358 | + commandId: CommandId.make("cmd-clear-attachments-2"), | |
| 358 | 359 | causationEventId: null, |
| 359 | − correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"), | |
| 360 | + correlationId: CommandId.make("cmd-clear-attachments-2"), | |
| 360 | 361 | metadata: {}, |
| 361 | 362 | payload: { |
| 362 | − threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | |
| 363 | − projectId: ProjectId.makeUnsafe("project-clear-attachments"), | |
| 363 | + threadId: ThreadId.make("thread-clear-attachments"), | |
| 364 | + projectId: ProjectId.make("project-clear-attachments"), | |
| 364 | 365 | title: "Thread Clear Attachments", |
| 365 | 366 | modelSelection: { |
| 366 | 367 | provider: "codex", |
| @@ -376,17 +377,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines | ||
| 376 | 377 | |
| 377 | 378 | yield* eventStore.append({ |
| 378 | 379 | type: "thread.message-sent", |
| 379 | − eventId: EventId.makeUnsafe("evt-clear-attachments-3"), | |
| 380 | + eventId: EventId.make("evt-clear-attachments-3"), | |
| 380 | 381 | aggregateKind: "thread", |
| 381 | − aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), | |
| 382 | + aggregateId: ThreadId.make("thread-clear-attachments"), | |
| 382 | 383 | occurredAt: now, |
| 383 | − commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"), | |
| 384 | + commandId: CommandId.make("cmd-clear-attachments-3"), | |
| 384 | 385 | causationEventId: null, |
| 385 | − correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"), | |
| 386 | + correlationId: CommandId.make("cmd-clear-attachments-3"), | |
| 386 | 387 | metadata: {}, |
| 387 | 388 | payload: { |
| 388 | − threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | |
| 389 | − messageId: MessageId.makeUnsafe("message-clear-attachments"), | |
| 389 | + threadId: ThreadId.make("thread-clear-attachments"), | |
| 390 | + messageId: MessageId.make("message-clear-attachments"), | |
| 390 | 391 | role: "user", |
| 391 | 392 | text: "Has attachments", |
| 392 | 393 | attachments: [ |
| @@ -407,17 +408,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 14 unmodified lines | ||
| 407 | 408 | |
| 408 | 409 | yield* eventStore.append({ |
| 409 | 410 | type: "thread.message-sent", |
| 410 | − eventId: EventId.makeUnsafe("evt-clear-attachments-4"), | |
| 411 | + eventId: EventId.make("evt-clear-attachments-4"), | |
| 411 | 412 | aggregateKind: "thread", |
| 412 | − aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), | |
| 413 | + aggregateId: ThreadId.make("thread-clear-attachments"), | |
| 413 | 414 | occurredAt: later, |
| 414 | − commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"), | |
| 415 | + commandId: CommandId.make("cmd-clear-attachments-4"), | |
| 415 | 416 | causationEventId: null, |
| 416 | − correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"), | |
| 417 | + correlationId: CommandId.make("cmd-clear-attachments-4"), | |
| 417 | 418 | metadata: {}, |
| 418 | 419 | payload: { |
| 419 | − threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | |
| 420 | − messageId: MessageId.makeUnsafe("message-clear-attachments"), | |
| 420 | + threadId: ThreadId.make("thread-clear-attachments"), | |
| 421 | + messageId: MessageId.make("message-clear-attachments"), | |
| 421 | 422 | role: "user", |
| 422 | 423 | text: "", |
| 423 | 424 | attachments: [], |
| @@ -457,16 +458,16 @@ it.layer( · 33 unmodified lines | ||
| 457 | 458 | |
| 458 | 459 | yield* eventStore.append({ |
| 459 | 460 | type: "project.created", |
| 460 | − eventId: EventId.makeUnsafe("evt-overwrite-1"), | |
| 461 | + eventId: EventId.make("evt-overwrite-1"), | |
| 461 | 462 | aggregateKind: "project", |
| 462 | − aggregateId: ProjectId.makeUnsafe("project-overwrite"), | |
| 463 | + aggregateId: ProjectId.make("project-overwrite"), | |
| 463 | 464 | occurredAt: now, |
| 464 | − commandId: CommandId.makeUnsafe("cmd-overwrite-1"), | |
| 465 | + commandId: CommandId.make("cmd-overwrite-1"), | |
| 465 | 466 | causationEventId: null, |
| 466 | − correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), | |
| 467 | + correlationId: CommandId.make("cmd-overwrite-1"), | |
| 467 | 468 | metadata: {}, |
| 468 | 469 | payload: { |
| 469 | − projectId: ProjectId.makeUnsafe("project-overwrite"), | |
| 470 | + projectId: ProjectId.make("project-overwrite"), | |
| 470 | 471 | title: "Project Overwrite", |
| 471 | 472 | workspaceRoot: "/tmp/project-overwrite", |
| 472 | 473 | defaultModelSelection: null, |
| @@ -478,17 +479,17 @@ it.layer( · 5 unmodified lines | ||
| 478 | 479 | |
| 479 | 480 | yield* eventStore.append({ |
| 480 | 481 | type: "thread.created", |
| 481 | − eventId: EventId.makeUnsafe("evt-overwrite-2"), | |
| 482 | + eventId: EventId.make("evt-overwrite-2"), | |
| 482 | 483 | aggregateKind: "thread", |
| 483 | − aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | |
| 484 | + aggregateId: ThreadId.make("thread-overwrite"), | |
| 484 | 485 | occurredAt: now, |
| 485 | − commandId: CommandId.makeUnsafe("cmd-overwrite-2"), | |
| 486 | + commandId: CommandId.make("cmd-overwrite-2"), | |
| 486 | 487 | causationEventId: null, |
| 487 | − correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), | |
| 488 | + correlationId: CommandId.make("cmd-overwrite-2"), | |
| 488 | 489 | metadata: {}, |
| 489 | 490 | payload: { |
| 490 | − threadId: ThreadId.makeUnsafe("thread-overwrite"), | |
| 491 | − projectId: ProjectId.makeUnsafe("project-overwrite"), | |
| 491 | + threadId: ThreadId.make("thread-overwrite"), | |
| 492 | + projectId: ProjectId.make("project-overwrite"), | |
| 492 | 493 | title: "Thread Overwrite", |
| 493 | 494 | modelSelection: { |
| 494 | 495 | provider: "codex", |
| @@ -504,17 +505,17 @@ it.layer( · 9 unmodified lines | ||
| 504 | 505 | |
| 505 | 506 | yield* eventStore.append({ |
| 506 | 507 | type: "thread.message-sent", |
| 507 | − eventId: EventId.makeUnsafe("evt-overwrite-3"), | |
| 508 | + eventId: EventId.make("evt-overwrite-3"), | |
| 508 | 509 | aggregateKind: "thread", |
| 509 | − aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | |
| 510 | + aggregateId: ThreadId.make("thread-overwrite"), | |
| 510 | 511 | occurredAt: now, |
| 511 | − commandId: CommandId.makeUnsafe("cmd-overwrite-3"), | |
| 512 | + commandId: CommandId.make("cmd-overwrite-3"), | |
| 512 | 513 | causationEventId: null, |
| 513 | − correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), | |
| 514 | + correlationId: CommandId.make("cmd-overwrite-3"), | |
| 514 | 515 | metadata: {}, |
| 515 | 516 | payload: { |
| 516 | − threadId: ThreadId.makeUnsafe("thread-overwrite"), | |
| 517 | − messageId: MessageId.makeUnsafe("message-overwrite"), | |
| 517 | + threadId: ThreadId.make("thread-overwrite"), | |
| 518 | + messageId: MessageId.make("message-overwrite"), | |
| 518 | 519 | role: "user", |
| 519 | 520 | text: "first image", |
| 520 | 521 | attachments: [ |
| @@ -535,17 +536,17 @@ it.layer( · 14 unmodified lines | ||
| 535 | 536 | |
| 536 | 537 | yield* eventStore.append({ |
| 537 | 538 | type: "thread.message-sent", |
| 538 | − eventId: EventId.makeUnsafe("evt-overwrite-4"), | |
| 539 | + eventId: EventId.make("evt-overwrite-4"), | |
| 539 | 540 | aggregateKind: "thread", |
| 540 | − aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | |
| 541 | + aggregateId: ThreadId.make("thread-overwrite"), | |
| 541 | 542 | occurredAt: later, |
| 542 | − commandId: CommandId.makeUnsafe("cmd-overwrite-4"), | |
| 543 | + commandId: CommandId.make("cmd-overwrite-4"), | |
| 543 | 544 | causationEventId: null, |
| 544 | − correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), | |
| 545 | + correlationId: CommandId.make("cmd-overwrite-4"), | |
| 545 | 546 | metadata: {}, |
| 546 | 547 | payload: { |
| 547 | − threadId: ThreadId.makeUnsafe("thread-overwrite"), | |
| 548 | − messageId: MessageId.makeUnsafe("message-overwrite"), | |
| 548 | + threadId: ThreadId.make("thread-overwrite"), | |
| 549 | + messageId: MessageId.make("message-overwrite"), | |
| 549 | 550 | role: "user", |
| 550 | 551 | text: "", |
| 551 | 552 | attachments: [ |
| @@ -605,16 +606,16 @@ it.layer( · 53 unmodified lines | ||
| 605 | 606 | |
| 606 | 607 | yield* appendAndProject({ |
| 607 | 608 | type: "project.created", |
| 608 | − eventId: EventId.makeUnsafe("evt-rollback-1"), | |
| 609 | + eventId: EventId.make("evt-rollback-1"), | |
| 609 | 610 | aggregateKind: "project", |
| 610 | − aggregateId: ProjectId.makeUnsafe("project-rollback"), | |
| 611 | + aggregateId: ProjectId.make("project-rollback"), | |
| 611 | 612 | occurredAt: now, |
| 612 | − commandId: CommandId.makeUnsafe("cmd-rollback-1"), | |
| 613 | + commandId: CommandId.make("cmd-rollback-1"), | |
| 613 | 614 | causationEventId: null, |
| 614 | − correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), | |
| 615 | + correlationId: CorrelationId.make("cmd-rollback-1"), | |
| 615 | 616 | metadata: {}, |
| 616 | 617 | payload: { |
| 617 | − projectId: ProjectId.makeUnsafe("project-rollback"), | |
| 618 | + projectId: ProjectId.make("project-rollback"), | |
| 618 | 619 | title: "Project Rollback", |
| 619 | 620 | workspaceRoot: "/tmp/project-rollback", |
| 620 | 621 | defaultModelSelection: null, |
| @@ -626,17 +627,17 @@ it.layer( · 5 unmodified lines | ||
| 626 | 627 | |
| 627 | 628 | yield* appendAndProject({ |
| 628 | 629 | type: "thread.created", |
| 629 | − eventId: EventId.makeUnsafe("evt-rollback-2"), | |
| 630 | + eventId: EventId.make("evt-rollback-2"), | |
| 630 | 631 | aggregateKind: "thread", |
| 631 | − aggregateId: ThreadId.makeUnsafe("thread-rollback"), | |
| 632 | + aggregateId: ThreadId.make("thread-rollback"), | |
| 632 | 633 | occurredAt: now, |
| 633 | − commandId: CommandId.makeUnsafe("cmd-rollback-2"), | |
| 634 | + commandId: CommandId.make("cmd-rollback-2"), | |
| 634 | 635 | causationEventId: null, |
| 635 | − correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), | |
| 636 | + correlationId: CorrelationId.make("cmd-rollback-2"), | |
| 636 | 637 | metadata: {}, |
| 637 | 638 | payload: { |
| 638 | − threadId: ThreadId.makeUnsafe("thread-rollback"), | |
| 639 | − projectId: ProjectId.makeUnsafe("project-rollback"), | |
| 639 | + threadId: ThreadId.make("thread-rollback"), | |
| 640 | + projectId: ProjectId.make("project-rollback"), | |
| 640 | 641 | title: "Thread Rollback", |
| 641 | 642 | modelSelection: { |
| 642 | 643 | provider: "codex", |
| @@ -662,17 +663,17 @@ it.layer( · 19 unmodified lines | ||
| 662 | 663 | const result = yield* Effect.result( |
| 663 | 664 | appendAndProject({ |
| 664 | 665 | type: "thread.message-sent", |
| 665 | − eventId: EventId.makeUnsafe("evt-rollback-3"), | |
| 666 | + eventId: EventId.make("evt-rollback-3"), | |
| 666 | 667 | aggregateKind: "thread", |
| 667 | − aggregateId: ThreadId.makeUnsafe("thread-rollback"), | |
| 668 | + aggregateId: ThreadId.make("thread-rollback"), | |
| 668 | 669 | occurredAt: now, |
| 669 | − commandId: CommandId.makeUnsafe("cmd-rollback-3"), | |
| 670 | + commandId: CommandId.make("cmd-rollback-3"), | |
| 670 | 671 | causationEventId: null, |
| 671 | − correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), | |
| 672 | + correlationId: CorrelationId.make("cmd-rollback-3"), | |
| 672 | 673 | metadata: {}, |
| 673 | 674 | payload: { |
| 674 | − threadId: ThreadId.makeUnsafe("thread-rollback"), | |
| 675 | − messageId: MessageId.makeUnsafe("message-rollback"), | |
| 675 | + threadId: ThreadId.make("thread-rollback"), | |
| 676 | + messageId: MessageId.make("message-rollback"), | |
| 676 | 677 | role: "user", |
| 677 | 678 | text: "Rollback me", |
| 678 | 679 | attachments: [ |
| @@ -721,7 +722,7 @@ it.layer( · 42 unmodified lines | ||
| 721 | 722 | const eventStore = yield* OrchestrationEventStore; |
| 722 | 723 | const { attachmentsDir } = yield* ServerConfig; |
| 723 | 724 | const now = new Date().toISOString(); |
| 724 | − const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); | |
| 725 | + const threadId = ThreadId.make("Thread Revert.Files"); | |
| 725 | 726 | const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; |
| 726 | 727 | const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; |
| 727 | 728 | const otherThreadAttachmentId = |
| @@ -734,16 +735,16 @@ it.layer( · 6 unmodified lines | ||
| 734 | 735 | |
| 735 | 736 | yield* appendAndProject({ |
| 736 | 737 | type: "project.created", |
| 737 | − eventId: EventId.makeUnsafe("evt-revert-files-1"), | |
| 738 | + eventId: EventId.make("evt-revert-files-1"), | |
| 738 | 739 | aggregateKind: "project", |
| 739 | − aggregateId: ProjectId.makeUnsafe("project-revert-files"), | |
| 740 | + aggregateId: ProjectId.make("project-revert-files"), | |
| 740 | 741 | occurredAt: now, |
| 741 | − commandId: CommandId.makeUnsafe("cmd-revert-files-1"), | |
| 742 | + commandId: CommandId.make("cmd-revert-files-1"), | |
| 742 | 743 | causationEventId: null, |
| 743 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), | |
| 744 | + correlationId: CorrelationId.make("cmd-revert-files-1"), | |
| 744 | 745 | metadata: {}, |
| 745 | 746 | payload: { |
| 746 | − projectId: ProjectId.makeUnsafe("project-revert-files"), | |
| 747 | + projectId: ProjectId.make("project-revert-files"), | |
| 747 | 748 | title: "Project Revert Files", |
| 748 | 749 | workspaceRoot: "/tmp/project-revert-files", |
| 749 | 750 | defaultModelSelection: null, |
| @@ -755,17 +756,17 @@ it.layer( · 5 unmodified lines | ||
| 755 | 756 | |
| 756 | 757 | yield* appendAndProject({ |
| 757 | 758 | type: "thread.created", |
| 758 | − eventId: EventId.makeUnsafe("evt-revert-files-2"), | |
| 759 | + eventId: EventId.make("evt-revert-files-2"), | |
| 759 | 760 | aggregateKind: "thread", |
| 760 | 761 | aggregateId: threadId, |
| 761 | 762 | occurredAt: now, |
| 762 | − commandId: CommandId.makeUnsafe("cmd-revert-files-2"), | |
| 763 | + commandId: CommandId.make("cmd-revert-files-2"), | |
| 763 | 764 | causationEventId: null, |
| 764 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), | |
| 765 | + correlationId: CorrelationId.make("cmd-revert-files-2"), | |
| 765 | 766 | metadata: {}, |
| 766 | 767 | payload: { |
| 767 | 768 | threadId, |
| 768 | − projectId: ProjectId.makeUnsafe("project-revert-files"), | |
| 769 | + projectId: ProjectId.make("project-revert-files"), | |
| 769 | 770 | title: "Thread Revert Files", |
| 770 | 771 | modelSelection: { |
| 771 | 772 | provider: "codex", |
| @@ -781,39 +782,39 @@ it.layer( · 9 unmodified lines | ||
| 781 | 782 | |
| 782 | 783 | yield* appendAndProject({ |
| 783 | 784 | type: "thread.turn-diff-completed", |
| 784 | − eventId: EventId.makeUnsafe("evt-revert-files-3"), | |
| 785 | + eventId: EventId.make("evt-revert-files-3"), | |
| 785 | 786 | aggregateKind: "thread", |
| 786 | 787 | aggregateId: threadId, |
| 787 | 788 | occurredAt: now, |
| 788 | − commandId: CommandId.makeUnsafe("cmd-revert-files-3"), | |
| 789 | + commandId: CommandId.make("cmd-revert-files-3"), | |
| 789 | 790 | causationEventId: null, |
| 790 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), | |
| 791 | + correlationId: CorrelationId.make("cmd-revert-files-3"), | |
| 791 | 792 | metadata: {}, |
| 792 | 793 | payload: { |
| 793 | 794 | threadId, |
| 794 | − turnId: TurnId.makeUnsafe("turn-keep"), | |
| 795 | + turnId: TurnId.make("turn-keep"), | |
| 795 | 796 | checkpointTurnCount: 1, |
| 796 | − checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), | |
| 797 | + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/1"), | |
| 797 | 798 | status: "ready", |
| 798 | 799 | files: [], |
| 799 | − assistantMessageId: MessageId.makeUnsafe("message-keep"), | |
| 800 | + assistantMessageId: MessageId.make("message-keep"), | |
| 800 | 801 | completedAt: now, |
| 801 | 802 | }, |
| 802 | 803 | }); |
| 803 | 804 | |
| 804 | 805 | yield* appendAndProject({ |
| 805 | 806 | type: "thread.message-sent", |
| 806 | − eventId: EventId.makeUnsafe("evt-revert-files-4"), | |
| 807 | + eventId: EventId.make("evt-revert-files-4"), | |
| 807 | 808 | aggregateKind: "thread", |
| 808 | 809 | aggregateId: threadId, |
| 809 | 810 | occurredAt: now, |
| 810 | − commandId: CommandId.makeUnsafe("cmd-revert-files-4"), | |
| 811 | + commandId: CommandId.make("cmd-revert-files-4"), | |
| 811 | 812 | causationEventId: null, |
| 812 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), | |
| 813 | + correlationId: CorrelationId.make("cmd-revert-files-4"), | |
| 813 | 814 | metadata: {}, |
| 814 | 815 | payload: { |
| 815 | 816 | threadId, |
| 816 | − messageId: MessageId.makeUnsafe("message-keep"), | |
| 817 | + messageId: MessageId.make("message-keep"), | |
| 817 | 818 | role: "assistant", |
| 818 | 819 | text: "Keep", |
| 819 | 820 | attachments: [ |
| @@ -825,7 +826,7 @@ it.layer( · 5 unmodified lines | ||
| 825 | 826 | sizeBytes: 5, |
| 826 | 827 | }, |
| 827 | 828 | ], |
| 828 | − turnId: TurnId.makeUnsafe("turn-keep"), | |
| 829 | + turnId: TurnId.make("turn-keep"), | |
| 829 | 830 | streaming: false, |
| 830 | 831 | createdAt: now, |
| 831 | 832 | updatedAt: now, |
| @@ -834,39 +835,39 @@ it.layer( · 2 unmodified lines | ||
| 834 | 835 | |
| 835 | 836 | yield* appendAndProject({ |
| 836 | 837 | type: "thread.turn-diff-completed", |
| 837 | − eventId: EventId.makeUnsafe("evt-revert-files-5"), | |
| 838 | + eventId: EventId.make("evt-revert-files-5"), | |
| 838 | 839 | aggregateKind: "thread", |
| 839 | 840 | aggregateId: threadId, |
| 840 | 841 | occurredAt: now, |
| 841 | − commandId: CommandId.makeUnsafe("cmd-revert-files-5"), | |
| 842 | + commandId: CommandId.make("cmd-revert-files-5"), | |
| 842 | 843 | causationEventId: null, |
| 843 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), | |
| 844 | + correlationId: CorrelationId.make("cmd-revert-files-5"), | |
| 844 | 845 | metadata: {}, |
| 845 | 846 | payload: { |
| 846 | 847 | threadId, |
| 847 | − turnId: TurnId.makeUnsafe("turn-remove"), | |
| 848 | + turnId: TurnId.make("turn-remove"), | |
| 848 | 849 | checkpointTurnCount: 2, |
| 849 | − checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), | |
| 850 | + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/2"), | |
| 850 | 851 | status: "ready", |
| 851 | 852 | files: [], |
| 852 | − assistantMessageId: MessageId.makeUnsafe("message-remove"), | |
| 853 | + assistantMessageId: MessageId.make("message-remove"), | |
| 853 | 854 | completedAt: now, |
| 854 | 855 | }, |
| 855 | 856 | }); |
| 856 | 857 | |
| 857 | 858 | yield* appendAndProject({ |
| 858 | 859 | type: "thread.message-sent", |
| 859 | − eventId: EventId.makeUnsafe("evt-revert-files-6"), | |
| 860 | + eventId: EventId.make("evt-revert-files-6"), | |
| 860 | 861 | aggregateKind: "thread", |
| 861 | 862 | aggregateId: threadId, |
| 862 | 863 | occurredAt: now, |
| 863 | − commandId: CommandId.makeUnsafe("cmd-revert-files-6"), | |
| 864 | + commandId: CommandId.make("cmd-revert-files-6"), | |
| 864 | 865 | causationEventId: null, |
| 865 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), | |
| 866 | + correlationId: CorrelationId.make("cmd-revert-files-6"), | |
| 866 | 867 | metadata: {}, |
| 867 | 868 | payload: { |
| 868 | 869 | threadId, |
| 869 | − messageId: MessageId.makeUnsafe("message-remove"), | |
| 870 | + messageId: MessageId.make("message-remove"), | |
| 870 | 871 | role: "assistant", |
| 871 | 872 | text: "Remove", |
| 872 | 873 | attachments: [ |
| @@ -878,7 +879,7 @@ it.layer( · 5 unmodified lines | ||
| 878 | 879 | sizeBytes: 5, |
| 879 | 880 | }, |
| 880 | 881 | ], |
| 881 | − turnId: TurnId.makeUnsafe("turn-remove"), | |
| 882 | + turnId: TurnId.make("turn-remove"), | |
| 882 | 883 | streaming: false, |
| 883 | 884 | createdAt: now, |
| 884 | 885 | updatedAt: now, |
| @@ -898,13 +899,13 @@ it.layer( · 13 unmodified lines | ||
| 898 | 899 | |
| 899 | 900 | yield* appendAndProject({ |
| 900 | 901 | type: "thread.reverted", |
| 901 | − eventId: EventId.makeUnsafe("evt-revert-files-7"), | |
| 902 | + eventId: EventId.make("evt-revert-files-7"), | |
| 902 | 903 | aggregateKind: "thread", |
| 903 | 904 | aggregateId: threadId, |
| 904 | 905 | occurredAt: now, |
| 905 | − commandId: CommandId.makeUnsafe("cmd-revert-files-7"), | |
| 906 | + commandId: CommandId.make("cmd-revert-files-7"), | |
| 906 | 907 | causationEventId: null, |
| 907 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), | |
| 908 | + correlationId: CorrelationId.make("cmd-revert-files-7"), | |
| 908 | 909 | metadata: {}, |
| 909 | 910 | payload: { |
| 910 | 911 | threadId, |
| @@ -930,7 +931,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 19 unmodified lines | ||
| 930 | 931 | const eventStore = yield* OrchestrationEventStore; |
| 931 | 932 | const { attachmentsDir } = yield* ServerConfig; |
| 932 | 933 | const now = new Date().toISOString(); |
| 933 | − const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); | |
| 934 | + const threadId = ThreadId.make("Thread Delete.Files"); | |
| 934 | 935 | const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; |
| 935 | 936 | const otherThreadAttachmentId = |
| 936 | 937 | "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; |
| @@ -942,16 +943,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 5 unmodified lines | ||
| 942 | 943 | |
| 943 | 944 | yield* appendAndProject({ |
| 944 | 945 | type: "project.created", |
| 945 | − eventId: EventId.makeUnsafe("evt-delete-files-1"), | |
| 946 | + eventId: EventId.make("evt-delete-files-1"), | |
| 946 | 947 | aggregateKind: "project", |
| 947 | − aggregateId: ProjectId.makeUnsafe("project-delete-files"), | |
| 948 | + aggregateId: ProjectId.make("project-delete-files"), | |
| 948 | 949 | occurredAt: now, |
| 949 | − commandId: CommandId.makeUnsafe("cmd-delete-files-1"), | |
| 950 | + commandId: CommandId.make("cmd-delete-files-1"), | |
| 950 | 951 | causationEventId: null, |
| 951 | − correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), | |
| 952 | + correlationId: CorrelationId.make("cmd-delete-files-1"), | |
| 952 | 953 | metadata: {}, |
| 953 | 954 | payload: { |
| 954 | − projectId: ProjectId.makeUnsafe("project-delete-files"), | |
| 955 | + projectId: ProjectId.make("project-delete-files"), | |
| 955 | 956 | title: "Project Delete Files", |
| 956 | 957 | workspaceRoot: "/tmp/project-delete-files", |
| 957 | 958 | defaultModelSelection: null, |
| @@ -963,17 +964,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 5 unmodified lines | ||
| 963 | 964 | |
| 964 | 965 | yield* appendAndProject({ |
| 965 | 966 | type: "thread.created", |
| 966 | − eventId: EventId.makeUnsafe("evt-delete-files-2"), | |
| 967 | + eventId: EventId.make("evt-delete-files-2"), | |
| 967 | 968 | aggregateKind: "thread", |
| 968 | 969 | aggregateId: threadId, |
| 969 | 970 | occurredAt: now, |
| 970 | − commandId: CommandId.makeUnsafe("cmd-delete-files-2"), | |
| 971 | + commandId: CommandId.make("cmd-delete-files-2"), | |
| 971 | 972 | causationEventId: null, |
| 972 | − correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), | |
| 973 | + correlationId: CorrelationId.make("cmd-delete-files-2"), | |
| 973 | 974 | metadata: {}, |
| 974 | 975 | payload: { |
| 975 | 976 | threadId, |
| 976 | − projectId: ProjectId.makeUnsafe("project-delete-files"), | |
| 977 | + projectId: ProjectId.make("project-delete-files"), | |
| 977 | 978 | title: "Thread Delete Files", |
| 978 | 979 | modelSelection: { |
| 979 | 980 | provider: "codex", |
| @@ -989,17 +990,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 9 unmodified lines | ||
| 989 | 990 | |
| 990 | 991 | yield* appendAndProject({ |
| 991 | 992 | type: "thread.message-sent", |
| 992 | − eventId: EventId.makeUnsafe("evt-delete-files-3"), | |
| 993 | + eventId: EventId.make("evt-delete-files-3"), | |
| 993 | 994 | aggregateKind: "thread", |
| 994 | 995 | aggregateId: threadId, |
| 995 | 996 | occurredAt: now, |
| 996 | − commandId: CommandId.makeUnsafe("cmd-delete-files-3"), | |
| 997 | + commandId: CommandId.make("cmd-delete-files-3"), | |
| 997 | 998 | causationEventId: null, |
| 998 | − correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), | |
| 999 | + correlationId: CorrelationId.make("cmd-delete-files-3"), | |
| 999 | 1000 | metadata: {}, |
| 1000 | 1001 | payload: { |
| 1001 | 1002 | threadId, |
| 1002 | − messageId: MessageId.makeUnsafe("message-delete-files"), | |
| 1003 | + messageId: MessageId.make("message-delete-files"), | |
| 1003 | 1004 | role: "user", |
| 1004 | 1005 | text: "Delete", |
| 1005 | 1006 | attachments: [ |
| @@ -1031,13 +1032,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 25 unmodified lines | ||
| 1031 | 1032 | |
| 1032 | 1033 | yield* appendAndProject({ |
| 1033 | 1034 | type: "thread.deleted", |
| 1034 | − eventId: EventId.makeUnsafe("evt-delete-files-4"), | |
| 1035 | + eventId: EventId.make("evt-delete-files-4"), | |
| 1035 | 1036 | aggregateKind: "thread", |
| 1036 | 1037 | aggregateId: threadId, |
| 1037 | 1038 | occurredAt: now, |
| 1038 | − commandId: CommandId.makeUnsafe("cmd-delete-files-4"), | |
| 1039 | + commandId: CommandId.make("cmd-delete-files-4"), | |
| 1039 | 1040 | causationEventId: null, |
| 1040 | − correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), | |
| 1041 | + correlationId: CorrelationId.make("cmd-delete-files-4"), | |
| 1041 | 1042 | metadata: {}, |
| 1042 | 1043 | payload: { |
| 1043 | 1044 | threadId, |
| @@ -1071,16 +1072,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 27 unmodified lines | ||
| 1071 | 1072 | |
| 1072 | 1073 | yield* eventStore.append({ |
| 1073 | 1074 | type: "thread.deleted", |
| 1074 | − eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), | |
| 1075 | + eventId: EventId.make("evt-unsafe-thread-delete"), | |
| 1075 | 1076 | aggregateKind: "thread", |
| 1076 | − aggregateId: ThreadId.makeUnsafe(".."), | |
| 1077 | + aggregateId: ThreadId.make(".."), | |
| 1077 | 1078 | occurredAt: now, |
| 1078 | − commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), | |
| 1079 | + commandId: CommandId.make("cmd-unsafe-thread-delete"), | |
| 1079 | 1080 | causationEventId: null, |
| 1080 | − correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), | |
| 1081 | + correlationId: CorrelationId.make("cmd-unsafe-thread-delete"), | |
| 1081 | 1082 | metadata: {}, |
| 1082 | 1083 | payload: { |
| 1083 | − threadId: ThreadId.makeUnsafe(".."), | |
| 1084 | + threadId: ThreadId.make(".."), | |
| 1084 | 1085 | deletedAt: now, |
| 1085 | 1086 | }, |
| 1086 | 1087 | }); |
| @@ -1105,16 +1106,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 18 unmodified lines | ||
| 1105 | 1106 | |
| 1106 | 1107 | yield* eventStore.append({ |
| 1107 | 1108 | type: "project.created", |
| 1108 | − eventId: EventId.makeUnsafe("evt-a1"), | |
| 1109 | + eventId: EventId.make("evt-a1"), | |
| 1109 | 1110 | aggregateKind: "project", |
| 1110 | − aggregateId: ProjectId.makeUnsafe("project-a"), | |
| 1111 | + aggregateId: ProjectId.make("project-a"), | |
| 1111 | 1112 | occurredAt: now, |
| 1112 | − commandId: CommandId.makeUnsafe("cmd-a1"), | |
| 1113 | + commandId: CommandId.make("cmd-a1"), | |
| 1113 | 1114 | causationEventId: null, |
| 1114 | − correlationId: CorrelationId.makeUnsafe("cmd-a1"), | |
| 1115 | + correlationId: CorrelationId.make("cmd-a1"), | |
| 1115 | 1116 | metadata: {}, |
| 1116 | 1117 | payload: { |
| 1117 | − projectId: ProjectId.makeUnsafe("project-a"), | |
| 1118 | + projectId: ProjectId.make("project-a"), | |
| 1118 | 1119 | title: "Project A", |
| 1119 | 1120 | workspaceRoot: "/tmp/project-a", |
| 1120 | 1121 | defaultModelSelection: null, |
| @@ -1126,17 +1127,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 1126 | 1127 | |
| 1127 | 1128 | yield* eventStore.append({ |
| 1128 | 1129 | type: "thread.created", |
| 1129 | − eventId: EventId.makeUnsafe("evt-a2"), | |
| 1130 | + eventId: EventId.make("evt-a2"), | |
| 1130 | 1131 | aggregateKind: "thread", |
| 1131 | − aggregateId: ThreadId.makeUnsafe("thread-a"), | |
| 1132 | + aggregateId: ThreadId.make("thread-a"), | |
| 1132 | 1133 | occurredAt: now, |
| 1133 | − commandId: CommandId.makeUnsafe("cmd-a2"), | |
| 1134 | + commandId: CommandId.make("cmd-a2"), | |
| 1134 | 1135 | causationEventId: null, |
| 1135 | − correlationId: CorrelationId.makeUnsafe("cmd-a2"), | |
| 1136 | + correlationId: CorrelationId.make("cmd-a2"), | |
| 1136 | 1137 | metadata: {}, |
| 1137 | 1138 | payload: { |
| 1138 | − threadId: ThreadId.makeUnsafe("thread-a"), | |
| 1139 | − projectId: ProjectId.makeUnsafe("project-a"), | |
| 1139 | + threadId: ThreadId.make("thread-a"), | |
| 1140 | + projectId: ProjectId.make("project-a"), | |
| 1140 | 1141 | title: "Thread A", |
| 1141 | 1142 | modelSelection: { |
| 1142 | 1143 | provider: "codex", |
| @@ -1152,17 +1153,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines | ||
| 1152 | 1153 | |
| 1153 | 1154 | yield* eventStore.append({ |
| 1154 | 1155 | type: "thread.message-sent", |
| 1155 | − eventId: EventId.makeUnsafe("evt-a3"), | |
| 1156 | + eventId: EventId.make("evt-a3"), | |
| 1156 | 1157 | aggregateKind: "thread", |
| 1157 | − aggregateId: ThreadId.makeUnsafe("thread-a"), | |
| 1158 | + aggregateId: ThreadId.make("thread-a"), | |
| 1158 | 1159 | occurredAt: now, |
| 1159 | − commandId: CommandId.makeUnsafe("cmd-a3"), | |
| 1160 | + commandId: CommandId.make("cmd-a3"), | |
| 1160 | 1161 | causationEventId: null, |
| 1161 | − correlationId: CorrelationId.makeUnsafe("cmd-a3"), | |
| 1162 | + correlationId: CorrelationId.make("cmd-a3"), | |
| 1162 | 1163 | metadata: {}, |
| 1163 | 1164 | payload: { |
| 1164 | − threadId: ThreadId.makeUnsafe("thread-a"), | |
| 1165 | − messageId: MessageId.makeUnsafe("message-a"), | |
| 1165 | + threadId: ThreadId.make("thread-a"), | |
| 1166 | + messageId: MessageId.make("message-a"), | |
| 1166 | 1167 | role: "assistant", |
| 1167 | 1168 | text: "hello", |
| 1168 | 1169 | turnId: null, |
| @@ -1176,17 +1177,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 7 unmodified lines | ||
| 1176 | 1177 | |
| 1177 | 1178 | yield* eventStore.append({ |
| 1178 | 1179 | type: "thread.message-sent", |
| 1179 | − eventId: EventId.makeUnsafe("evt-a4"), | |
| 1180 | + eventId: EventId.make("evt-a4"), | |
| 1180 | 1181 | aggregateKind: "thread", |
| 1181 | − aggregateId: ThreadId.makeUnsafe("thread-a"), | |
| 1182 | + aggregateId: ThreadId.make("thread-a"), | |
| 1182 | 1183 | occurredAt: now, |
| 1183 | − commandId: CommandId.makeUnsafe("cmd-a4"), | |
| 1184 | + commandId: CommandId.make("cmd-a4"), | |
| 1184 | 1185 | causationEventId: null, |
| 1185 | − correlationId: CorrelationId.makeUnsafe("cmd-a4"), | |
| 1186 | + correlationId: CorrelationId.make("cmd-a4"), | |
| 1186 | 1187 | metadata: {}, |
| 1187 | 1188 | payload: { |
| 1188 | − threadId: ThreadId.makeUnsafe("thread-a"), | |
| 1189 | − messageId: MessageId.makeUnsafe("message-a"), | |
| 1189 | + threadId: ThreadId.make("thread-a"), | |
| 1190 | + messageId: MessageId.make("message-a"), | |
| 1190 | 1191 | role: "assistant", |
| 1191 | 1192 | text: " world", |
| 1192 | 1193 | turnId: null, |
| @@ -1232,16 +1233,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 39 unmodified lines | ||
| 1232 | 1233 | |
| 1233 | 1234 | yield* eventStore.append({ |
| 1234 | 1235 | type: "project.created", |
| 1235 | − eventId: EventId.makeUnsafe("evt-empty-1"), | |
| 1236 | + eventId: EventId.make("evt-empty-1"), | |
| 1236 | 1237 | aggregateKind: "project", |
| 1237 | − aggregateId: ProjectId.makeUnsafe("project-empty"), | |
| 1238 | + aggregateId: ProjectId.make("project-empty"), | |
| 1238 | 1239 | occurredAt: now, |
| 1239 | − commandId: CommandId.makeUnsafe("cmd-empty-1"), | |
| 1240 | + commandId: CommandId.make("cmd-empty-1"), | |
| 1240 | 1241 | causationEventId: null, |
| 1241 | − correlationId: CorrelationId.makeUnsafe("cmd-empty-1"), | |
| 1242 | + correlationId: CorrelationId.make("cmd-empty-1"), | |
| 1242 | 1243 | metadata: {}, |
| 1243 | 1244 | payload: { |
| 1244 | − projectId: ProjectId.makeUnsafe("project-empty"), | |
| 1245 | + projectId: ProjectId.make("project-empty"), | |
| 1245 | 1246 | title: "Project Empty", |
| 1246 | 1247 | workspaceRoot: "/tmp/project-empty", |
| 1247 | 1248 | defaultModelSelection: null, |
| @@ -1253,17 +1254,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 1253 | 1254 | |
| 1254 | 1255 | yield* eventStore.append({ |
| 1255 | 1256 | type: "thread.created", |
| 1256 | − eventId: EventId.makeUnsafe("evt-empty-2"), | |
| 1257 | + eventId: EventId.make("evt-empty-2"), | |
| 1257 | 1258 | aggregateKind: "thread", |
| 1258 | − aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 1259 | + aggregateId: ThreadId.make("thread-empty"), | |
| 1259 | 1260 | occurredAt: now, |
| 1260 | − commandId: CommandId.makeUnsafe("cmd-empty-2"), | |
| 1261 | + commandId: CommandId.make("cmd-empty-2"), | |
| 1261 | 1262 | causationEventId: null, |
| 1262 | − correlationId: CorrelationId.makeUnsafe("cmd-empty-2"), | |
| 1263 | + correlationId: CorrelationId.make("cmd-empty-2"), | |
| 1263 | 1264 | metadata: {}, |
| 1264 | 1265 | payload: { |
| 1265 | − threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 1266 | − projectId: ProjectId.makeUnsafe("project-empty"), | |
| 1266 | + threadId: ThreadId.make("thread-empty"), | |
| 1267 | + projectId: ProjectId.make("project-empty"), | |
| 1267 | 1268 | title: "Thread Empty", |
| 1268 | 1269 | modelSelection: { |
| 1269 | 1270 | provider: "codex", |
| @@ -1279,17 +1280,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines | ||
| 1279 | 1280 | |
| 1280 | 1281 | yield* eventStore.append({ |
| 1281 | 1282 | type: "thread.message-sent", |
| 1282 | − eventId: EventId.makeUnsafe("evt-empty-3"), | |
| 1283 | + eventId: EventId.make("evt-empty-3"), | |
| 1283 | 1284 | aggregateKind: "thread", |
| 1284 | − aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 1285 | + aggregateId: ThreadId.make("thread-empty"), | |
| 1285 | 1286 | occurredAt: now, |
| 1286 | − commandId: CommandId.makeUnsafe("cmd-empty-3"), | |
| 1287 | + commandId: CommandId.make("cmd-empty-3"), | |
| 1287 | 1288 | causationEventId: null, |
| 1288 | − correlationId: CorrelationId.makeUnsafe("cmd-empty-3"), | |
| 1289 | + correlationId: CorrelationId.make("cmd-empty-3"), | |
| 1289 | 1290 | metadata: {}, |
| 1290 | 1291 | payload: { |
| 1291 | − threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 1292 | − messageId: MessageId.makeUnsafe("assistant-empty"), | |
| 1292 | + threadId: ThreadId.make("thread-empty"), | |
| 1293 | + messageId: MessageId.make("assistant-empty"), | |
| 1293 | 1294 | role: "assistant", |
| 1294 | 1295 | text: "Hello", |
| 1295 | 1296 | turnId: null, |
| @@ -1301,17 +1302,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 1301 | 1302 | |
| 1302 | 1303 | yield* eventStore.append({ |
| 1303 | 1304 | type: "thread.message-sent", |
| 1304 | − eventId: EventId.makeUnsafe("evt-empty-4"), | |
| 1305 | + eventId: EventId.make("evt-empty-4"), | |
| 1305 | 1306 | aggregateKind: "thread", |
| 1306 | − aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 1307 | + aggregateId: ThreadId.make("thread-empty"), | |
| 1307 | 1308 | occurredAt: now, |
| 1308 | − commandId: CommandId.makeUnsafe("cmd-empty-4"), | |
| 1309 | + commandId: CommandId.make("cmd-empty-4"), | |
| 1309 | 1310 | causationEventId: null, |
| 1310 | − correlationId: CorrelationId.makeUnsafe("cmd-empty-4"), | |
| 1311 | + correlationId: CorrelationId.make("cmd-empty-4"), | |
| 1311 | 1312 | metadata: {}, |
| 1312 | 1313 | payload: { |
| 1313 | − threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 1314 | − messageId: MessageId.makeUnsafe("assistant-empty"), | |
| 1314 | + threadId: ThreadId.make("thread-empty"), | |
| 1315 | + messageId: MessageId.make("assistant-empty"), | |
| 1315 | 1316 | role: "assistant", |
| 1316 | 1317 | text: " world", |
| 1317 | 1318 | turnId: null, |
| @@ -1323,17 +1324,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 1323 | 1324 | |
| 1324 | 1325 | yield* eventStore.append({ |
| 1325 | 1326 | type: "thread.message-sent", |
| 1326 | − eventId: EventId.makeUnsafe("evt-empty-5"), | |
| 1327 | + eventId: EventId.make("evt-empty-5"), | |
| 1327 | 1328 | aggregateKind: "thread", |
| 1328 | − aggregateId: ThreadId.makeUnsafe("thread-empty"), | |
| 1329 | + aggregateId: ThreadId.make("thread-empty"), | |
| 1329 | 1330 | occurredAt: now, |
| 1330 | − commandId: CommandId.makeUnsafe("cmd-empty-5"), | |
| 1331 | + commandId: CommandId.make("cmd-empty-5"), | |
| 1331 | 1332 | causationEventId: null, |
| 1332 | − correlationId: CorrelationId.makeUnsafe("cmd-empty-5"), | |
| 1333 | + correlationId: CorrelationId.make("cmd-empty-5"), | |
| 1333 | 1334 | metadata: {}, |
| 1334 | 1335 | payload: { |
| 1335 | − threadId: ThreadId.makeUnsafe("thread-empty"), | |
| 1336 | − messageId: MessageId.makeUnsafe("assistant-empty"), | |
| 1336 | + threadId: ThreadId.make("thread-empty"), | |
| 1337 | + messageId: MessageId.make("assistant-empty"), | |
| 1337 | 1338 | role: "assistant", |
| 1338 | 1339 | text: "", |
| 1339 | 1340 | turnId: null, |
| @@ -1372,16 +1373,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 32 unmodified lines | ||
| 1372 | 1373 | |
| 1373 | 1374 | yield* appendAndProject({ |
| 1374 | 1375 | type: "project.created", |
| 1375 | − eventId: EventId.makeUnsafe("evt-conflict-1"), | |
| 1376 | + eventId: EventId.make("evt-conflict-1"), | |
| 1376 | 1377 | aggregateKind: "project", |
| 1377 | − aggregateId: ProjectId.makeUnsafe("project-conflict"), | |
| 1378 | + aggregateId: ProjectId.make("project-conflict"), | |
| 1378 | 1379 | occurredAt: "2026-02-26T13:00:00.000Z", |
| 1379 | − commandId: CommandId.makeUnsafe("cmd-conflict-1"), | |
| 1380 | + commandId: CommandId.make("cmd-conflict-1"), | |
| 1380 | 1381 | causationEventId: null, |
| 1381 | − correlationId: CorrelationId.makeUnsafe("cmd-conflict-1"), | |
| 1382 | + correlationId: CorrelationId.make("cmd-conflict-1"), | |
| 1382 | 1383 | metadata: {}, |
| 1383 | 1384 | payload: { |
| 1384 | − projectId: ProjectId.makeUnsafe("project-conflict"), | |
| 1385 | + projectId: ProjectId.make("project-conflict"), | |
| 1385 | 1386 | title: "Project Conflict", |
| 1386 | 1387 | workspaceRoot: "/tmp/project-conflict", |
| 1387 | 1388 | defaultModelSelection: null, |
| @@ -1393,17 +1394,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 1393 | 1394 | |
| 1394 | 1395 | yield* appendAndProject({ |
| 1395 | 1396 | type: "thread.created", |
| 1396 | − eventId: EventId.makeUnsafe("evt-conflict-2"), | |
| 1397 | + eventId: EventId.make("evt-conflict-2"), | |
| 1397 | 1398 | aggregateKind: "thread", |
| 1398 | − aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1399 | + aggregateId: ThreadId.make("thread-conflict"), | |
| 1399 | 1400 | occurredAt: "2026-02-26T13:00:01.000Z", |
| 1400 | − commandId: CommandId.makeUnsafe("cmd-conflict-2"), | |
| 1401 | + commandId: CommandId.make("cmd-conflict-2"), | |
| 1401 | 1402 | causationEventId: null, |
| 1402 | − correlationId: CorrelationId.makeUnsafe("cmd-conflict-2"), | |
| 1403 | + correlationId: CorrelationId.make("cmd-conflict-2"), | |
| 1403 | 1404 | metadata: {}, |
| 1404 | 1405 | payload: { |
| 1405 | − threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1406 | − projectId: ProjectId.makeUnsafe("project-conflict"), | |
| 1406 | + threadId: ThreadId.make("thread-conflict"), | |
| 1407 | + projectId: ProjectId.make("project-conflict"), | |
| 1407 | 1408 | title: "Thread Conflict", |
| 1408 | 1409 | modelSelection: { |
| 1409 | 1410 | provider: "codex", |
| @@ -1419,37 +1420,37 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines | ||
| 1419 | 1420 | |
| 1420 | 1421 | yield* appendAndProject({ |
| 1421 | 1422 | type: "thread.turn-interrupt-requested", |
| 1422 | − eventId: EventId.makeUnsafe("evt-conflict-3"), | |
| 1423 | + eventId: EventId.make("evt-conflict-3"), | |
| 1423 | 1424 | aggregateKind: "thread", |
| 1424 | − aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1425 | + aggregateId: ThreadId.make("thread-conflict"), | |
| 1425 | 1426 | occurredAt: "2026-02-26T13:00:02.000Z", |
| 1426 | − commandId: CommandId.makeUnsafe("cmd-conflict-3"), | |
| 1427 | + commandId: CommandId.make("cmd-conflict-3"), | |
| 1427 | 1428 | causationEventId: null, |
| 1428 | − correlationId: CorrelationId.makeUnsafe("cmd-conflict-3"), | |
| 1429 | + correlationId: CorrelationId.make("cmd-conflict-3"), | |
| 1429 | 1430 | metadata: {}, |
| 1430 | 1431 | payload: { |
| 1431 | − threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1432 | − turnId: TurnId.makeUnsafe("turn-interrupted"), | |
| 1432 | + threadId: ThreadId.make("thread-conflict"), | |
| 1433 | + turnId: TurnId.make("turn-interrupted"), | |
| 1433 | 1434 | createdAt: "2026-02-26T13:00:02.000Z", |
| 1434 | 1435 | }, |
| 1435 | 1436 | }); |
| 1436 | 1437 | |
| 1437 | 1438 | yield* appendAndProject({ |
| 1438 | 1439 | type: "thread.message-sent", |
| 1439 | − eventId: EventId.makeUnsafe("evt-conflict-4"), | |
| 1440 | + eventId: EventId.make("evt-conflict-4"), | |
| 1440 | 1441 | aggregateKind: "thread", |
| 1441 | − aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1442 | + aggregateId: ThreadId.make("thread-conflict"), | |
| 1442 | 1443 | occurredAt: "2026-02-26T13:00:03.000Z", |
| 1443 | − commandId: CommandId.makeUnsafe("cmd-conflict-4"), | |
| 1444 | + commandId: CommandId.make("cmd-conflict-4"), | |
| 1444 | 1445 | causationEventId: null, |
| 1445 | − correlationId: CorrelationId.makeUnsafe("cmd-conflict-4"), | |
| 1446 | + correlationId: CorrelationId.make("cmd-conflict-4"), | |
| 1446 | 1447 | metadata: {}, |
| 1447 | 1448 | payload: { |
| 1448 | − threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1449 | − messageId: MessageId.makeUnsafe("assistant-conflict"), | |
| 1449 | + threadId: ThreadId.make("thread-conflict"), | |
| 1450 | + messageId: MessageId.make("assistant-conflict"), | |
| 1450 | 1451 | role: "assistant", |
| 1451 | 1452 | text: "done", |
| 1452 | − turnId: TurnId.makeUnsafe("turn-completed"), | |
| 1453 | + turnId: TurnId.make("turn-completed"), | |
| 1453 | 1454 | streaming: false, |
| 1454 | 1455 | createdAt: "2026-02-26T13:00:03.000Z", |
| 1455 | 1456 | updatedAt: "2026-02-26T13:00:03.000Z", |
| @@ -1458,22 +1459,22 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines | ||
| 1458 | 1459 | |
| 1459 | 1460 | yield* appendAndProject({ |
| 1460 | 1461 | type: "thread.turn-diff-completed", |
| 1461 | − eventId: EventId.makeUnsafe("evt-conflict-5"), | |
| 1462 | + eventId: EventId.make("evt-conflict-5"), | |
| 1462 | 1463 | aggregateKind: "thread", |
| 1463 | − aggregateId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1464 | + aggregateId: ThreadId.make("thread-conflict"), | |
| 1464 | 1465 | occurredAt: "2026-02-26T13:00:04.000Z", |
| 1465 | − commandId: CommandId.makeUnsafe("cmd-conflict-5"), | |
| 1466 | + commandId: CommandId.make("cmd-conflict-5"), | |
| 1466 | 1467 | causationEventId: null, |
| 1467 | − correlationId: CorrelationId.makeUnsafe("cmd-conflict-5"), | |
| 1468 | + correlationId: CorrelationId.make("cmd-conflict-5"), | |
| 1468 | 1469 | metadata: {}, |
| 1469 | 1470 | payload: { |
| 1470 | − threadId: ThreadId.makeUnsafe("thread-conflict"), | |
| 1471 | − turnId: TurnId.makeUnsafe("turn-completed"), | |
| 1471 | + threadId: ThreadId.make("thread-conflict"), | |
| 1472 | + turnId: TurnId.make("turn-completed"), | |
| 1472 | 1473 | checkpointTurnCount: 1, |
| 1473 | − checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-conflict/turn/1"), | |
| 1474 | + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-conflict/turn/1"), | |
| 1474 | 1475 | status: "ready", |
| 1475 | 1476 | files: [], |
| 1476 | − assistantMessageId: MessageId.makeUnsafe("assistant-conflict"), | |
| 1477 | + assistantMessageId: MessageId.make("assistant-conflict"), | |
| 1477 | 1478 | completedAt: "2026-02-26T13:00:04.000Z", |
| 1478 | 1479 | }, |
| 1479 | 1480 | }); |
| @@ -1504,6 +1505,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 24 unmodified lines | ||
| 1504 | 1505 | }), |
| 1505 | 1506 | ); |
| 1506 | 1507 | |
| 1508 | + it.effect("clears stale pending approvals from projected shell summaries", () => | |
| 1509 | + Effect.gen(function* () { | |
| 1510 | + const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 1511 | + const eventStore = yield* OrchestrationEventStore; | |
| 1512 | + const sql = yield* SqlClient.SqlClient; | |
| 1513 | + const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | |
| 1514 | + eventStore | |
| 1515 | + .append(event) | |
| 1516 | + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); | |
| 1517 | + | |
| 1518 | + yield* appendAndProject({ | |
| 1519 | + type: "project.created", | |
| 1520 | + eventId: EventId.make("evt-stale-approval-1"), | |
| 1521 | + aggregateKind: "project", | |
| 1522 | + aggregateId: ProjectId.make("project-stale-approval"), | |
| 1523 | + occurredAt: "2026-02-26T12:30:00.000Z", | |
| 1524 | + commandId: CommandId.make("cmd-stale-approval-1"), | |
| 1525 | + causationEventId: null, | |
| 1526 | + correlationId: CorrelationId.make("cmd-stale-approval-1"), | |
| 1527 | + metadata: {}, | |
| 1528 | + payload: { | |
| 1529 | + projectId: ProjectId.make("project-stale-approval"), | |
| 1530 | + title: "Project Stale Approval", | |
| 1531 | + workspaceRoot: "/tmp/project-stale-approval", | |
| 1532 | + defaultModelSelection: null, | |
| 1533 | + scripts: [], | |
| 1534 | + createdAt: "2026-02-26T12:30:00.000Z", | |
| 1535 | + updatedAt: "2026-02-26T12:30:00.000Z", | |
| 1536 | + }, | |
| 1537 | + }); | |
| 1538 | + | |
| 1539 | + yield* appendAndProject({ | |
| 1540 | + type: "thread.created", | |
| 1541 | + eventId: EventId.make("evt-stale-approval-2"), | |
| 1542 | + aggregateKind: "thread", | |
| 1543 | + aggregateId: ThreadId.make("thread-stale-approval"), | |
| 1544 | + occurredAt: "2026-02-26T12:30:01.000Z", | |
| 1545 | + commandId: CommandId.make("cmd-stale-approval-2"), | |
| 1546 | + causationEventId: null, | |
| 1547 | + correlationId: CorrelationId.make("cmd-stale-approval-2"), | |
| 1548 | + metadata: {}, | |
| 1549 | + payload: { | |
| 1550 | + threadId: ThreadId.make("thread-stale-approval"), | |
| 1551 | + projectId: ProjectId.make("project-stale-approval"), | |
| 1552 | + title: "Thread Stale Approval", | |
| 1553 | + modelSelection: { | |
| 1554 | + provider: "codex", | |
| 1555 | + model: "gpt-5-codex", | |
| 1556 | + }, | |
| 1557 | + runtimeMode: "approval-required", | |
| 1558 | + interactionMode: "default", | |
| 1559 | + branch: null, | |
| 1560 | + worktreePath: null, | |
| 1561 | + createdAt: "2026-02-26T12:30:01.000Z", | |
| 1562 | + updatedAt: "2026-02-26T12:30:01.000Z", | |
| 1563 | + }, | |
| 1564 | + }); | |
| 1565 | + | |
| 1566 | + yield* appendAndProject({ | |
| 1567 | + type: "thread.activity-appended", | |
| 1568 | + eventId: EventId.make("evt-stale-approval-3"), | |
| 1569 | + aggregateKind: "thread", | |
| 1570 | + aggregateId: ThreadId.make("thread-stale-approval"), | |
| 1571 | + occurredAt: "2026-02-26T12:30:02.000Z", | |
| 1572 | + commandId: CommandId.make("cmd-stale-approval-3"), | |
| 1573 | + causationEventId: null, | |
| 1574 | + correlationId: CorrelationId.make("cmd-stale-approval-3"), | |
| 1575 | + metadata: {}, | |
| 1576 | + payload: { | |
| 1577 | + threadId: ThreadId.make("thread-stale-approval"), | |
| 1578 | + activity: { | |
| 1579 | + id: EventId.make("activity-stale-approval-requested"), | |
| 1580 | + tone: "approval", | |
| 1581 | + kind: "approval.requested", | |
| 1582 | + summary: "Command approval requested", | |
| 1583 | + payload: { | |
| 1584 | + requestId: "approval-request-stale-1", | |
| 1585 | + requestKind: "command", | |
| 1586 | + }, | |
| 1587 | + turnId: null, | |
| 1588 | + createdAt: "2026-02-26T12:30:02.000Z", | |
| 1589 | + }, | |
| 1590 | + }, | |
| 1591 | + }); | |
| 1592 | + | |
| 1593 | + yield* appendAndProject({ | |
| 1594 | + type: "thread.activity-appended", | |
| 1595 | + eventId: EventId.make("evt-stale-approval-4"), | |
| 1596 | + aggregateKind: "thread", | |
| 1597 | + aggregateId: ThreadId.make("thread-stale-approval"), | |
| 1598 | + occurredAt: "2026-02-26T12:30:03.000Z", | |
| 1599 | + commandId: CommandId.make("cmd-stale-approval-4"), | |
| 1600 | + causationEventId: null, | |
| 1601 | + correlationId: CorrelationId.make("cmd-stale-approval-4"), | |
| 1602 | + metadata: {}, | |
| 1603 | + payload: { | |
| 1604 | + threadId: ThreadId.make("thread-stale-approval"), | |
| 1605 | + activity: { | |
| 1606 | + id: EventId.make("activity-stale-approval-failed"), | |
| 1607 | + tone: "error", | |
| 1608 | + kind: "provider.approval.respond.failed", | |
| 1609 | + summary: "Provider approval response failed", | |
| 1610 | + payload: { | |
| 1611 | + requestId: "approval-request-stale-1", | |
| 1612 | + detail: "Unknown pending permission request: approval-request-stale-1", | |
| 1613 | + }, | |
| 1614 | + turnId: null, | |
| 1615 | + createdAt: "2026-02-26T12:30:03.000Z", | |
| 1616 | + }, | |
| 1617 | + }, | |
| 1618 | + }); | |
| 1619 | + | |
| 1620 | + const approvalRows = yield* sql<{ | |
| 1621 | + readonly requestId: string; | |
| 1622 | + readonly status: string; | |
| 1623 | + readonly resolvedAt: string | null; | |
| 1624 | + }>` | |
| 1625 | + SELECT | |
| 1626 | + request_id AS "requestId", | |
| 1627 | + status, | |
| 1628 | + resolved_at AS "resolvedAt" | |
| 1629 | + FROM projection_pending_approvals | |
| 1630 | + WHERE request_id = 'approval-request-stale-1' | |
| 1631 | + `; | |
| 1632 | + assert.deepEqual(approvalRows, [ | |
| 1633 | + { | |
| 1634 | + requestId: "approval-request-stale-1", | |
| 1635 | + status: "resolved", | |
| 1636 | + resolvedAt: "2026-02-26T12:30:03.000Z", | |
| 1637 | + }, | |
| 1638 | + ]); | |
| 1639 | + | |
| 1640 | + const threadRows = yield* sql<{ | |
| 1641 | + readonly pendingApprovalCount: number; | |
| 1642 | + }>` | |
| 1643 | + SELECT pending_approval_count AS "pendingApprovalCount" | |
| 1644 | + FROM projection_threads | |
| 1645 | + WHERE thread_id = 'thread-stale-approval' | |
| 1646 | + `; | |
| 1647 | + assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]); | |
| 1648 | + }), | |
| 1649 | + ); | |
| 1650 | + | |
| 1651 | + it.effect("ignores non-stale provider approval response failures", () => | |
| 1652 | + Effect.gen(function* () { | |
| 1653 | + const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 1654 | + const eventStore = yield* OrchestrationEventStore; | |
| 1655 | + const sql = yield* SqlClient.SqlClient; | |
| 1656 | + const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | |
| 1657 | + eventStore | |
| 1658 | + .append(event) | |
| 1659 | + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); | |
| 1660 | + | |
| 1661 | + yield* appendAndProject({ | |
| 1662 | + type: "project.created", | |
| 1663 | + eventId: EventId.make("evt-nonstale-approval-1"), | |
| 1664 | + aggregateKind: "project", | |
| 1665 | + aggregateId: ProjectId.make("project-nonstale-approval"), | |
| 1666 | + occurredAt: "2026-02-26T12:45:00.000Z", | |
| 1667 | + commandId: CommandId.make("cmd-nonstale-approval-1"), | |
| 1668 | + causationEventId: null, | |
| 1669 | + correlationId: CorrelationId.make("cmd-nonstale-approval-1"), | |
| 1670 | + metadata: {}, | |
| 1671 | + payload: { | |
| 1672 | + projectId: ProjectId.make("project-nonstale-approval"), | |
| 1673 | + title: "Project Non-Stale Approval", | |
| 1674 | + workspaceRoot: "/tmp/project-nonstale-approval", | |
| 1675 | + defaultModelSelection: null, | |
| 1676 | + scripts: [], | |
| 1677 | + createdAt: "2026-02-26T12:45:00.000Z", | |
| 1678 | + updatedAt: "2026-02-26T12:45:00.000Z", | |
| 1679 | + }, | |
| 1680 | + }); | |
| 1681 | + | |
| 1682 | + yield* appendAndProject({ | |
| 1683 | + type: "thread.created", | |
| 1684 | + eventId: EventId.make("evt-nonstale-approval-2"), | |
| 1685 | + aggregateKind: "thread", | |
| 1686 | + aggregateId: ThreadId.make("thread-nonstale-approval"), | |
| 1687 | + occurredAt: "2026-02-26T12:45:01.000Z", | |
| 1688 | + commandId: CommandId.make("cmd-nonstale-approval-2"), | |
| 1689 | + causationEventId: null, | |
| 1690 | + correlationId: CorrelationId.make("cmd-nonstale-approval-2"), | |
| 1691 | + metadata: {}, | |
| 1692 | + payload: { | |
| 1693 | + threadId: ThreadId.make("thread-nonstale-approval"), | |
| 1694 | + projectId: ProjectId.make("project-nonstale-approval"), | |
| 1695 | + title: "Thread Non-Stale Approval", | |
| 1696 | + modelSelection: { | |
| 1697 | + provider: "codex", | |
| 1698 | + model: "gpt-5-codex", | |
| 1699 | + }, | |
| 1700 | + runtimeMode: "approval-required", | |
| 1701 | + interactionMode: "default", | |
| 1702 | + branch: null, | |
| 1703 | + worktreePath: null, | |
| 1704 | + createdAt: "2026-02-26T12:45:01.000Z", | |
| 1705 | + updatedAt: "2026-02-26T12:45:01.000Z", | |
| 1706 | + }, | |
| 1707 | + }); | |
| 1708 | + | |
| 1709 | + yield* appendAndProject({ | |
| 1710 | + type: "thread.activity-appended", | |
| 1711 | + eventId: EventId.make("evt-nonstale-approval-3"), | |
| 1712 | + aggregateKind: "thread", | |
| 1713 | + aggregateId: ThreadId.make("thread-nonstale-approval"), | |
| 1714 | + occurredAt: "2026-02-26T12:45:02.000Z", | |
| 1715 | + commandId: CommandId.make("cmd-nonstale-approval-3"), | |
| 1716 | + causationEventId: null, | |
| 1717 | + correlationId: CorrelationId.make("cmd-nonstale-approval-3"), | |
| 1718 | + metadata: {}, | |
| 1719 | + payload: { | |
| 1720 | + threadId: ThreadId.make("thread-nonstale-approval"), | |
| 1721 | + activity: { | |
| 1722 | + id: EventId.make("activity-nonstale-approval-requested"), | |
| 1723 | + tone: "approval", | |
| 1724 | + kind: "approval.requested", | |
| 1725 | + summary: "Command approval requested", | |
| 1726 | + payload: { | |
| 1727 | + requestId: "approval-request-nonstale-existing", | |
| 1728 | + requestKind: "command", | |
| 1729 | + }, | |
| 1730 | + turnId: null, | |
| 1731 | + createdAt: "2026-02-26T12:45:02.000Z", | |
| 1732 | + }, | |
| 1733 | + }, | |
| 1734 | + }); | |
| 1735 | + | |
| 1736 | + yield* appendAndProject({ | |
| 1737 | + type: "thread.activity-appended", | |
| 1738 | + eventId: EventId.make("evt-nonstale-approval-4"), | |
| 1739 | + aggregateKind: "thread", | |
| 1740 | + aggregateId: ThreadId.make("thread-nonstale-approval"), | |
| 1741 | + occurredAt: "2026-02-26T12:45:03.000Z", | |
| 1742 | + commandId: CommandId.make("cmd-nonstale-approval-4"), | |
| 1743 | + causationEventId: null, | |
| 1744 | + correlationId: CorrelationId.make("cmd-nonstale-approval-4"), | |
| 1745 | + metadata: {}, | |
| 1746 | + payload: { | |
| 1747 | + threadId: ThreadId.make("thread-nonstale-approval"), | |
| 1748 | + activity: { | |
| 1749 | + id: EventId.make("activity-nonstale-approval-failed-existing"), | |
| 1750 | + tone: "error", | |
| 1751 | + kind: "provider.approval.respond.failed", | |
| 1752 | + summary: "Provider approval response failed", | |
| 1753 | + payload: { | |
| 1754 | + requestId: "approval-request-nonstale-existing", | |
| 1755 | + detail: "Provider timed out while responding to approval request", | |
| 1756 | + }, | |
| 1757 | + turnId: TurnId.make("turn-nonstale-failure"), | |
| 1758 | + createdAt: "2026-02-26T12:45:03.000Z", | |
| 1759 | + }, | |
| 1760 | + }, | |
| 1761 | + }); | |
| 1762 | + | |
| 1763 | + yield* appendAndProject({ | |
| 1764 | + type: "thread.activity-appended", | |
| 1765 | + eventId: EventId.make("evt-nonstale-approval-5"), | |
| 1766 | + aggregateKind: "thread", | |
| 1767 | + aggregateId: ThreadId.make("thread-nonstale-approval"), | |
| 1768 | + occurredAt: "2026-02-26T12:45:04.000Z", | |
| 1769 | + commandId: CommandId.make("cmd-nonstale-approval-5"), | |
| 1770 | + causationEventId: null, | |
| 1771 | + correlationId: CorrelationId.make("cmd-nonstale-approval-5"), | |
| 1772 | + metadata: {}, | |
| 1773 | + payload: { | |
| 1774 | + threadId: ThreadId.make("thread-nonstale-approval"), | |
| 1775 | + activity: { | |
| 1776 | + id: EventId.make("activity-nonstale-approval-failed-missing"), | |
| 1777 | + tone: "error", | |
| 1778 | + kind: "provider.approval.respond.failed", | |
| 1779 | + summary: "Provider approval response failed", | |
| 1780 | + payload: { | |
| 1781 | + requestId: "approval-request-nonstale-missing", | |
| 1782 | + detail: "Provider timed out while responding to approval request", | |
| 1783 | + }, | |
| 1784 | + turnId: null, | |
| 1785 | + createdAt: "2026-02-26T12:45:04.000Z", | |
| 1786 | + }, | |
| 1787 | + }, | |
| 1788 | + }); | |
| 1789 | + | |
| 1790 | + const approvalRows = yield* sql<{ | |
| 1791 | + readonly requestId: string; | |
| 1792 | + readonly status: string; | |
| 1793 | + readonly turnId: string | null; | |
| 1794 | + readonly createdAt: string; | |
| 1795 | + readonly resolvedAt: string | null; | |
| 1796 | + }>` | |
| 1797 | + SELECT | |
| 1798 | + request_id AS "requestId", | |
| 1799 | + status, | |
| 1800 | + turn_id AS "turnId", | |
| 1801 | + created_at AS "createdAt", | |
| 1802 | + resolved_at AS "resolvedAt" | |
| 1803 | + FROM projection_pending_approvals | |
| 1804 | + WHERE request_id IN ( | |
| 1805 | + 'approval-request-nonstale-existing', | |
| 1806 | + 'approval-request-nonstale-missing' | |
| 1807 | + ) | |
| 1808 | + ORDER BY request_id | |
| 1809 | + `; | |
| 1810 | + assert.deepEqual(approvalRows, [ | |
| 1811 | + { | |
| 1812 | + requestId: "approval-request-nonstale-existing", | |
| 1813 | + status: "pending", | |
| 1814 | + turnId: null, | |
| 1815 | + createdAt: "2026-02-26T12:45:02.000Z", | |
| 1816 | + resolvedAt: null, | |
| 1817 | + }, | |
| 1818 | + ]); | |
| 1819 | + | |
| 1820 | + const threadRows = yield* sql<{ | |
| 1821 | + readonly pendingApprovalCount: number; | |
| 1822 | + }>` | |
| 1823 | + SELECT pending_approval_count AS "pendingApprovalCount" | |
| 1824 | + FROM projection_threads | |
| 1825 | + WHERE thread_id = 'thread-nonstale-approval' | |
| 1826 | + `; | |
| 1827 | + assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]); | |
| 1828 | + }), | |
| 1829 | + ); | |
| 1830 | + | |
| 1507 | 1831 | it.effect("does not fallback-retain messages whose turnId is removed by revert", () => |
| 1508 | 1832 | Effect.gen(function* () { |
| 1509 | 1833 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| @@ -1516,16 +1840,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 6 unmodified lines | ||
| 1516 | 1840 | |
| 1517 | 1841 | yield* appendAndProject({ |
| 1518 | 1842 | type: "project.created", |
| 1519 | − eventId: EventId.makeUnsafe("evt-revert-1"), | |
| 1843 | + eventId: EventId.make("evt-revert-1"), | |
| 1520 | 1844 | aggregateKind: "project", |
| 1521 | − aggregateId: ProjectId.makeUnsafe("project-revert"), | |
| 1845 | + aggregateId: ProjectId.make("project-revert"), | |
| 1522 | 1846 | occurredAt: "2026-02-26T12:00:00.000Z", |
| 1523 | − commandId: CommandId.makeUnsafe("cmd-revert-1"), | |
| 1847 | + commandId: CommandId.make("cmd-revert-1"), | |
| 1524 | 1848 | causationEventId: null, |
| 1525 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-1"), | |
| 1849 | + correlationId: CorrelationId.make("cmd-revert-1"), | |
| 1526 | 1850 | metadata: {}, |
| 1527 | 1851 | payload: { |
| 1528 | − projectId: ProjectId.makeUnsafe("project-revert"), | |
| 1852 | + projectId: ProjectId.make("project-revert"), | |
| 1529 | 1853 | title: "Project Revert", |
| 1530 | 1854 | workspaceRoot: "/tmp/project-revert", |
| 1531 | 1855 | defaultModelSelection: null, |
| @@ -1537,17 +1861,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines | ||
| 1537 | 1861 | |
| 1538 | 1862 | yield* appendAndProject({ |
| 1539 | 1863 | type: "thread.created", |
| 1540 | − eventId: EventId.makeUnsafe("evt-revert-2"), | |
| 1864 | + eventId: EventId.make("evt-revert-2"), | |
| 1541 | 1865 | aggregateKind: "thread", |
| 1542 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 1866 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1543 | 1867 | occurredAt: "2026-02-26T12:00:01.000Z", |
| 1544 | − commandId: CommandId.makeUnsafe("cmd-revert-2"), | |
| 1868 | + commandId: CommandId.make("cmd-revert-2"), | |
| 1545 | 1869 | causationEventId: null, |
| 1546 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-2"), | |
| 1870 | + correlationId: CorrelationId.make("cmd-revert-2"), | |
| 1547 | 1871 | metadata: {}, |
| 1548 | 1872 | payload: { |
| 1549 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 1550 | − projectId: ProjectId.makeUnsafe("project-revert"), | |
| 1873 | + threadId: ThreadId.make("thread-revert"), | |
| 1874 | + projectId: ProjectId.make("project-revert"), | |
| 1551 | 1875 | title: "Thread Revert", |
| 1552 | 1876 | modelSelection: { |
| 1553 | 1877 | provider: "codex", |
| @@ -1563,42 +1887,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines | ||
| 1563 | 1887 | |
| 1564 | 1888 | yield* appendAndProject({ |
| 1565 | 1889 | type: "thread.turn-diff-completed", |
| 1566 | − eventId: EventId.makeUnsafe("evt-revert-3"), | |
| 1890 | + eventId: EventId.make("evt-revert-3"), | |
| 1567 | 1891 | aggregateKind: "thread", |
| 1568 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 1892 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1569 | 1893 | occurredAt: "2026-02-26T12:00:02.000Z", |
| 1570 | − commandId: CommandId.makeUnsafe("cmd-revert-3"), | |
| 1894 | + commandId: CommandId.make("cmd-revert-3"), | |
| 1571 | 1895 | causationEventId: null, |
| 1572 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-3"), | |
| 1896 | + correlationId: CorrelationId.make("cmd-revert-3"), | |
| 1573 | 1897 | metadata: {}, |
| 1574 | 1898 | payload: { |
| 1575 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 1576 | − turnId: TurnId.makeUnsafe("turn-1"), | |
| 1899 | + threadId: ThreadId.make("thread-revert"), | |
| 1900 | + turnId: TurnId.make("turn-1"), | |
| 1577 | 1901 | checkpointTurnCount: 1, |
| 1578 | − checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"), | |
| 1902 | + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/1"), | |
| 1579 | 1903 | status: "ready", |
| 1580 | 1904 | files: [], |
| 1581 | − assistantMessageId: MessageId.makeUnsafe("assistant-keep"), | |
| 1905 | + assistantMessageId: MessageId.make("assistant-keep"), | |
| 1582 | 1906 | completedAt: "2026-02-26T12:00:02.000Z", |
| 1583 | 1907 | }, |
| 1584 | 1908 | }); |
| 1585 | 1909 | |
| 1586 | 1910 | yield* appendAndProject({ |
| 1587 | 1911 | type: "thread.message-sent", |
| 1588 | − eventId: EventId.makeUnsafe("evt-revert-4"), | |
| 1912 | + eventId: EventId.make("evt-revert-4"), | |
| 1589 | 1913 | aggregateKind: "thread", |
| 1590 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 1914 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1591 | 1915 | occurredAt: "2026-02-26T12:00:02.100Z", |
| 1592 | − commandId: CommandId.makeUnsafe("cmd-revert-4"), | |
| 1916 | + commandId: CommandId.make("cmd-revert-4"), | |
| 1593 | 1917 | causationEventId: null, |
| 1594 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-4"), | |
| 1918 | + correlationId: CorrelationId.make("cmd-revert-4"), | |
| 1595 | 1919 | metadata: {}, |
| 1596 | 1920 | payload: { |
| 1597 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 1598 | − messageId: MessageId.makeUnsafe("assistant-keep"), | |
| 1921 | + threadId: ThreadId.make("thread-revert"), | |
| 1922 | + messageId: MessageId.make("assistant-keep"), | |
| 1599 | 1923 | role: "assistant", |
| 1600 | 1924 | text: "kept", |
| 1601 | − turnId: TurnId.makeUnsafe("turn-1"), | |
| 1925 | + turnId: TurnId.make("turn-1"), | |
| 1602 | 1926 | streaming: false, |
| 1603 | 1927 | createdAt: "2026-02-26T12:00:02.100Z", |
| 1604 | 1928 | updatedAt: "2026-02-26T12:00:02.100Z", |
| @@ -1607,42 +1931,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines | ||
| 1607 | 1931 | |
| 1608 | 1932 | yield* appendAndProject({ |
| 1609 | 1933 | type: "thread.turn-diff-completed", |
| 1610 | − eventId: EventId.makeUnsafe("evt-revert-5"), | |
| 1934 | + eventId: EventId.make("evt-revert-5"), | |
| 1611 | 1935 | aggregateKind: "thread", |
| 1612 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 1936 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1613 | 1937 | occurredAt: "2026-02-26T12:00:03.000Z", |
| 1614 | − commandId: CommandId.makeUnsafe("cmd-revert-5"), | |
| 1938 | + commandId: CommandId.make("cmd-revert-5"), | |
| 1615 | 1939 | causationEventId: null, |
| 1616 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-5"), | |
| 1940 | + correlationId: CorrelationId.make("cmd-revert-5"), | |
| 1617 | 1941 | metadata: {}, |
| 1618 | 1942 | payload: { |
| 1619 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 1620 | − turnId: TurnId.makeUnsafe("turn-2"), | |
| 1943 | + threadId: ThreadId.make("thread-revert"), | |
| 1944 | + turnId: TurnId.make("turn-2"), | |
| 1621 | 1945 | checkpointTurnCount: 2, |
| 1622 | − checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"), | |
| 1946 | + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/2"), | |
| 1623 | 1947 | status: "ready", |
| 1624 | 1948 | files: [], |
| 1625 | − assistantMessageId: MessageId.makeUnsafe("assistant-remove"), | |
| 1949 | + assistantMessageId: MessageId.make("assistant-remove"), | |
| 1626 | 1950 | completedAt: "2026-02-26T12:00:03.000Z", |
| 1627 | 1951 | }, |
| 1628 | 1952 | }); |
| 1629 | 1953 | |
| 1630 | 1954 | yield* appendAndProject({ |
| 1631 | 1955 | type: "thread.message-sent", |
| 1632 | − eventId: EventId.makeUnsafe("evt-revert-6"), | |
| 1956 | + eventId: EventId.make("evt-revert-6"), | |
| 1633 | 1957 | aggregateKind: "thread", |
| 1634 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 1958 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1635 | 1959 | occurredAt: "2026-02-26T12:00:03.050Z", |
| 1636 | − commandId: CommandId.makeUnsafe("cmd-revert-6"), | |
| 1960 | + commandId: CommandId.make("cmd-revert-6"), | |
| 1637 | 1961 | causationEventId: null, |
| 1638 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-6"), | |
| 1962 | + correlationId: CorrelationId.make("cmd-revert-6"), | |
| 1639 | 1963 | metadata: {}, |
| 1640 | 1964 | payload: { |
| 1641 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 1642 | − messageId: MessageId.makeUnsafe("user-remove"), | |
| 1965 | + threadId: ThreadId.make("thread-revert"), | |
| 1966 | + messageId: MessageId.make("user-remove"), | |
| 1643 | 1967 | role: "user", |
| 1644 | 1968 | text: "removed", |
| 1645 | − turnId: TurnId.makeUnsafe("turn-2"), | |
| 1969 | + turnId: TurnId.make("turn-2"), | |
| 1646 | 1970 | streaming: false, |
| 1647 | 1971 | createdAt: "2026-02-26T12:00:03.050Z", |
| 1648 | 1972 | updatedAt: "2026-02-26T12:00:03.050Z", |
| @@ -1651,20 +1975,20 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines | ||
| 1651 | 1975 | |
| 1652 | 1976 | yield* appendAndProject({ |
| 1653 | 1977 | type: "thread.message-sent", |
| 1654 | − eventId: EventId.makeUnsafe("evt-revert-7"), | |
| 1978 | + eventId: EventId.make("evt-revert-7"), | |
| 1655 | 1979 | aggregateKind: "thread", |
| 1656 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 1980 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1657 | 1981 | occurredAt: "2026-02-26T12:00:03.100Z", |
| 1658 | − commandId: CommandId.makeUnsafe("cmd-revert-7"), | |
| 1982 | + commandId: CommandId.make("cmd-revert-7"), | |
| 1659 | 1983 | causationEventId: null, |
| 1660 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-7"), | |
| 1984 | + correlationId: CorrelationId.make("cmd-revert-7"), | |
| 1661 | 1985 | metadata: {}, |
| 1662 | 1986 | payload: { |
| 1663 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 1664 | − messageId: MessageId.makeUnsafe("assistant-remove"), | |
| 1987 | + threadId: ThreadId.make("thread-revert"), | |
| 1988 | + messageId: MessageId.make("assistant-remove"), | |
| 1665 | 1989 | role: "assistant", |
| 1666 | 1990 | text: "removed", |
| 1667 | − turnId: TurnId.makeUnsafe("turn-2"), | |
| 1991 | + turnId: TurnId.make("turn-2"), | |
| 1668 | 1992 | streaming: false, |
| 1669 | 1993 | createdAt: "2026-02-26T12:00:03.100Z", |
| 1670 | 1994 | updatedAt: "2026-02-26T12:00:03.100Z", |
| @@ -1673,16 +1997,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines | ||
| 1673 | 1997 | |
| 1674 | 1998 | yield* appendAndProject({ |
| 1675 | 1999 | type: "thread.reverted", |
| 1676 | − eventId: EventId.makeUnsafe("evt-revert-8"), | |
| 2000 | + eventId: EventId.make("evt-revert-8"), | |
| 1677 | 2001 | aggregateKind: "thread", |
| 1678 | − aggregateId: ThreadId.makeUnsafe("thread-revert"), | |
| 2002 | + aggregateId: ThreadId.make("thread-revert"), | |
| 1679 | 2003 | occurredAt: "2026-02-26T12:00:04.000Z", |
| 1680 | − commandId: CommandId.makeUnsafe("cmd-revert-8"), | |
| 2004 | + commandId: CommandId.make("cmd-revert-8"), | |
| 1681 | 2005 | causationEventId: null, |
| 1682 | − correlationId: CorrelationId.makeUnsafe("cmd-revert-8"), | |
| 2006 | + correlationId: CorrelationId.make("cmd-revert-8"), | |
| 1683 | 2007 | metadata: {}, |
| 1684 | 2008 | payload: { |
| 1685 | − threadId: ThreadId.makeUnsafe("thread-revert"), | |
| 2009 | + threadId: ThreadId.make("thread-revert"), | |
| 1686 | 2010 | turnCount: 1, |
| 1687 | 2011 | }, |
| 1688 | 2012 | }); |
| @@ -1724,10 +2048,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 35 unmodified lines | ||
| 1724 | 2048 | Layer.provideMerge(persistenceLayer), |
| 1725 | 2049 | ); |
| 1726 | 2050 | |
| 1727 | − const threadId = ThreadId.makeUnsafe("thread-restart"); | |
| 1728 | − const turnId = TurnId.makeUnsafe("turn-restart"); | |
| 1729 | − const messageId = MessageId.makeUnsafe("message-restart"); | |
| 1730 | − const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source"); | |
| 2051 | + const threadId = ThreadId.make("thread-restart"); | |
| 2052 | + const turnId = TurnId.make("turn-restart"); | |
| 2053 | + const messageId = MessageId.make("message-restart"); | |
| 2054 | + const sourcePlanThreadId = ThreadId.make("thread-plan-source"); | |
| 1731 | 2055 | const sourcePlanId = "plan-source"; |
| 1732 | 2056 | const turnStartedAt = "2026-02-26T14:00:00.000Z"; |
| 1733 | 2057 | const sessionSetAt = "2026-02-26T14:00:05.000Z"; |
| @@ -1738,13 +2062,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 4 unmodified lines | ||
| 1738 | 2062 | |
| 1739 | 2063 | yield* eventStore.append({ |
| 1740 | 2064 | type: "thread.turn-start-requested", |
| 1741 | − eventId: EventId.makeUnsafe("evt-restart-1"), | |
| 2065 | + eventId: EventId.make("evt-restart-1"), | |
| 1742 | 2066 | aggregateKind: "thread", |
| 1743 | 2067 | aggregateId: threadId, |
| 1744 | 2068 | occurredAt: turnStartedAt, |
| 1745 | − commandId: CommandId.makeUnsafe("cmd-restart-1"), | |
| 2069 | + commandId: CommandId.make("cmd-restart-1"), | |
| 1746 | 2070 | causationEventId: null, |
| 1747 | − correlationId: CorrelationId.makeUnsafe("cmd-restart-1"), | |
| 2071 | + correlationId: CorrelationId.make("cmd-restart-1"), | |
| 1748 | 2072 | metadata: {}, |
| 1749 | 2073 | payload: { |
| 1750 | 2074 | threadId, |
| @@ -1768,13 +2092,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 17 unmodified lines | ||
| 1768 | 2092 | |
| 1769 | 2093 | yield* eventStore.append({ |
| 1770 | 2094 | type: "thread.session-set", |
| 1771 | − eventId: EventId.makeUnsafe("evt-restart-2"), | |
| 2095 | + eventId: EventId.make("evt-restart-2"), | |
| 1772 | 2096 | aggregateKind: "thread", |
| 1773 | 2097 | aggregateId: threadId, |
| 1774 | 2098 | occurredAt: sessionSetAt, |
| 1775 | − commandId: CommandId.makeUnsafe("cmd-restart-2"), | |
| 2099 | + commandId: CommandId.make("cmd-restart-2"), | |
| 1776 | 2100 | causationEventId: null, |
| 1777 | − correlationId: CorrelationId.makeUnsafe("cmd-restart-2"), | |
| 2101 | + correlationId: CorrelationId.make("cmd-restart-2"), | |
| 1778 | 2102 | metadata: {}, |
| 1779 | 2103 | payload: { |
| 1780 | 2104 | threadId, |
| @@ -1846,6 +2170,7 @@ const engineLayer = it.layer( · 65 unmodified lines | ||
| 1846 | 2170 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 1847 | 2171 | Layer.provide(OrchestrationEventStoreLive), |
| 1848 | 2172 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 2173 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 1849 | 2174 | Layer.provideMerge(SqlitePersistenceMemory), |
| 1850 | 2175 | Layer.provideMerge( |
| 1851 | 2176 | ServerConfig.layerTest(process.cwd(), { |
| @@ -1865,8 +2190,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 13 unmodified lines | ||
| 1865 | 2190 | |
| 1866 | 2191 | yield* engine.dispatch({ |
| 1867 | 2192 | type: "project.create", |
| 1868 | − commandId: CommandId.makeUnsafe("cmd-live-project"), | |
| 1869 | − projectId: ProjectId.makeUnsafe("project-live"), | |
| 2193 | + commandId: CommandId.make("cmd-live-project"), | |
| 2194 | + projectId: ProjectId.make("project-live"), | |
| 1870 | 2195 | title: "Live Project", |
| 1871 | 2196 | workspaceRoot: "/tmp/project-live", |
| 1872 | 2197 | defaultModelSelection: { |
| @@ -1903,8 +2228,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 30 unmodified lines | ||
| 1903 | 2228 | |
| 1904 | 2229 | yield* engine.dispatch({ |
| 1905 | 2230 | type: "project.create", |
| 1906 | − commandId: CommandId.makeUnsafe("cmd-scripts-project-create"), | |
| 1907 | − projectId: ProjectId.makeUnsafe("project-scripts"), | |
| 2231 | + commandId: CommandId.make("cmd-scripts-project-create"), | |
| 2232 | + projectId: ProjectId.make("project-scripts"), | |
| 1908 | 2233 | title: "Scripts Project", |
| 1909 | 2234 | workspaceRoot: "/tmp/project-scripts", |
| 1910 | 2235 | defaultModelSelection: { |
| @@ -1916,8 +2241,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 5 unmodified lines | ||
| 1916 | 2241 | |
| 1917 | 2242 | yield* engine.dispatch({ |
| 1918 | 2243 | type: "project.meta.update", |
| 1919 | − commandId: CommandId.makeUnsafe("cmd-scripts-project-update"), | |
| 1920 | − projectId: ProjectId.makeUnsafe("project-scripts"), | |
| 2244 | + commandId: CommandId.make("cmd-scripts-project-update"), | |
| 2245 | + projectId: ProjectId.make("project-scripts"), | |
| 1921 | 2246 | scripts: [ |
| 1922 | 2247 | { |
| 1923 | 2248 | id: "script-1", |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | ApprovalRequestId, |
| 3 | 3 | type ChatAttachment, |
| 4 | 4 | type OrchestrationEvent, |
| 5 | + ThreadId, | |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; |
| 7 | 8 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| @@ -86,7 +87,89 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { · 78 unmodified lines | ||
| 86 | 87 | return null; |
| 87 | 88 | } |
| 88 | 89 | const requestId = (payload as Record<string, unknown>).requestId; |
| 89 | − return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null; | |
| 90 | + return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null; | |
| 91 | +} | |
| 92 | + | |
| 93 | +function isStalePendingApprovalFailureDetail(detail: string | null): boolean { | |
| 94 | + if (detail === null) { | |
| 95 | + return false; | |
| 96 | + } | |
| 97 | + return ( | |
| 98 | + detail.includes("stale pending approval request") || | |
| 99 | + detail.includes("unknown pending approval request") || | |
| 100 | + detail.includes("unknown pending permission request") | |
| 101 | + ); | |
| 102 | +} | |
| 103 | + | |
| 104 | +function derivePendingUserInputCountFromActivities( | |
| 105 | + activities: ReadonlyArray<ProjectionThreadActivity>, | |
| 106 | +): number { | |
| 107 | + const openRequestIds = new Set<string>(); | |
| 108 | + const ordered = [...activities].toSorted( | |
| 109 | + (left, right) => | |
| 110 | + left.createdAt.localeCompare(right.createdAt) || | |
| 111 | + left.activityId.localeCompare(right.activityId), | |
| 112 | + ); | |
| 113 | + | |
| 114 | + for (const activity of ordered) { | |
| 115 | + const requestId = extractActivityRequestId(activity.payload); | |
| 116 | + if (requestId === null) { | |
| 117 | + continue; | |
| 118 | + } | |
| 119 | + const payload = | |
| 120 | + typeof activity.payload === "object" && activity.payload !== null | |
| 121 | + ? (activity.payload as Record<string, unknown>) | |
| 122 | + : null; | |
| 123 | + const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; | |
| 124 | + | |
| 125 | + if (activity.kind === "user-input.requested") { | |
| 126 | + openRequestIds.add(requestId); | |
| 127 | + continue; | |
| 128 | + } | |
| 129 | + | |
| 130 | + if (activity.kind === "user-input.resolved") { | |
| 131 | + openRequestIds.delete(requestId); | |
| 132 | + continue; | |
| 133 | + } | |
| 134 | + | |
| 135 | + if ( | |
| 136 | + activity.kind === "provider.user-input.respond.failed" && | |
| 137 | + detail !== null && | |
| 138 | + (detail.includes("stale pending user-input request") || | |
| 139 | + detail.includes("unknown pending user-input request")) | |
| 140 | + ) { | |
| 141 | + openRequestIds.delete(requestId); | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + return openRequestIds.size; | |
| 146 | +} | |
| 147 | + | |
| 148 | +function deriveHasActionableProposedPlan(input: { | |
| 149 | + readonly latestTurnId: string | null; | |
| 150 | + readonly proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>; | |
| 151 | +}): boolean { | |
| 152 | + const sorted = [...input.proposedPlans].toSorted( | |
| 153 | + (left, right) => | |
| 154 | + left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + let latestForTurn: ProjectionThreadProposedPlan | null = null; | |
| 158 | + if (input.latestTurnId !== null) { | |
| 159 | + for (let index = sorted.length - 1; index >= 0; index -= 1) { | |
| 160 | + const plan = sorted[index]; | |
| 161 | + if (plan?.turnId === input.latestTurnId) { | |
| 162 | + latestForTurn = plan; | |
| 163 | + break; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + } | |
| 167 | + if (latestForTurn !== null) { | |
| 168 | + return latestForTurn.implementedAt === null; | |
| 169 | + } | |
| 170 | + | |
| 171 | + const latestPlan = sorted.at(-1) ?? null; | |
| 172 | + return latestPlan !== null && latestPlan.implementedAt === null; | |
| 90 | 173 | } |
| 91 | 174 | |
| 92 | 175 | function retainProjectionMessagesAfterRevert( |
| @@ -432,6 +515,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 339 unmodified lines | ||
| 432 | 515 | } |
| 433 | 516 | }); |
| 434 | 517 | |
| 518 | + const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* ( | |
| 519 | + threadId: ThreadId, | |
| 520 | + ) { | |
| 521 | + const existingRow = yield* projectionThreadRepository.getById({ | |
| 522 | + threadId, | |
| 523 | + }); | |
| 524 | + if (Option.isNone(existingRow)) { | |
| 525 | + return; | |
| 526 | + } | |
| 527 | + | |
| 528 | + const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([ | |
| 529 | + projectionThreadMessageRepository.listByThreadId({ threadId }), | |
| 530 | + projectionThreadProposedPlanRepository.listByThreadId({ threadId }), | |
| 531 | + projectionThreadActivityRepository.listByThreadId({ threadId }), | |
| 532 | + projectionPendingApprovalRepository.listByThreadId({ threadId }), | |
| 533 | + ]); | |
| 534 | + | |
| 535 | + const latestUserMessageAt = | |
| 536 | + messages | |
| 537 | + .filter((message) => message.role === "user") | |
| 538 | + .map((message) => message.createdAt) | |
| 539 | + .toSorted() | |
| 540 | + .at(-1) ?? null; | |
| 541 | + | |
| 542 | + const pendingApprovalCount = pendingApprovals.filter( | |
| 543 | + (approval) => approval.status === "pending", | |
| 544 | + ).length; | |
| 545 | + const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); | |
| 546 | + const hasActionableProposedPlan = deriveHasActionableProposedPlan({ | |
| 547 | + latestTurnId: existingRow.value.latestTurnId, | |
| 548 | + proposedPlans, | |
| 549 | + }); | |
| 550 | + | |
| 551 | + yield* projectionThreadRepository.upsert({ | |
| 552 | + ...existingRow.value, | |
| 553 | + latestUserMessageAt, | |
| 554 | + pendingApprovalCount, | |
| 555 | + pendingUserInputCount, | |
| 556 | + hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, | |
| 557 | + }); | |
| 558 | + }); | |
| 559 | + | |
| 435 | 560 | const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn( |
| 436 | 561 | "applyThreadsProjection", |
| 437 | 562 | )(function* (event, attachmentSideEffects) { |
| @@ -450,6 +575,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 12 unmodified lines | ||
| 450 | 575 | createdAt: event.payload.createdAt, |
| 451 | 576 | updatedAt: event.payload.updatedAt, |
| 452 | 577 | archivedAt: null, |
| 578 | + latestUserMessageAt: null, | |
| 579 | + pendingApprovalCount: 0, | |
| 580 | + pendingUserInputCount: 0, | |
| 581 | + hasActionableProposedPlan: 0, | |
| 453 | 582 | deletedAt: null, |
| 454 | 583 | }); |
| 455 | 584 | return; |
| @@ -554,7 +683,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 98 unmodified lines | ||
| 554 | 683 | |
| 555 | 684 | case "thread.message-sent": |
| 556 | 685 | case "thread.proposed-plan-upserted": |
| 557 | − case "thread.activity-appended": { | |
| 686 | + case "thread.activity-appended": | |
| 687 | + case "thread.approval-response-requested": | |
| 688 | + case "thread.user-input-response-requested": { | |
| 558 | 689 | const existingRow = yield* projectionThreadRepository.getById({ |
| 559 | 690 | threadId: event.payload.threadId, |
| 560 | 691 | }); |
| @@ -565,6 +696,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 4 unmodified lines | ||
| 565 | 696 | ...existingRow.value, |
| 566 | 697 | updatedAt: event.occurredAt, |
| 567 | 698 | }); |
| 699 | + yield* refreshThreadShellSummary(event.payload.threadId); | |
| 568 | 700 | return; |
| 569 | 701 | } |
| 570 | 702 | |
| @@ -580,6 +712,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines | ||
| 580 | 712 | latestTurnId: event.payload.session.activeTurnId, |
| 581 | 713 | updatedAt: event.occurredAt, |
| 582 | 714 | }); |
| 715 | + yield* refreshThreadShellSummary(event.payload.threadId); | |
| 583 | 716 | return; |
| 584 | 717 | } |
| 585 | 718 | |
| @@ -595,6 +728,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines | ||
| 595 | 728 | latestTurnId: event.payload.turnId, |
| 596 | 729 | updatedAt: event.occurredAt, |
| 597 | 730 | }); |
| 731 | + yield* refreshThreadShellSummary(event.payload.threadId); | |
| 598 | 732 | return; |
| 599 | 733 | } |
| 600 | 734 | |
| @@ -610,6 +744,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines | ||
| 610 | 744 | latestTurnId: null, |
| 611 | 745 | updatedAt: event.occurredAt, |
| 612 | 746 | }); |
| 747 | + yield* refreshThreadShellSummary(event.payload.threadId); | |
| 613 | 748 | return; |
| 614 | 749 | } |
| 615 | 750 | |
| @@ -1121,6 +1256,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 505 unmodified lines | ||
| 1121 | 1256 | }); |
| 1122 | 1257 | return; |
| 1123 | 1258 | } |
| 1259 | + if (event.payload.activity.kind === "provider.approval.respond.failed") { | |
| 1260 | + const payload = | |
| 1261 | + typeof event.payload.activity.payload === "object" && | |
| 1262 | + event.payload.activity.payload !== null | |
| 1263 | + ? (event.payload.activity.payload as Record<string, unknown>) | |
| 1264 | + : null; | |
| 1265 | + const detail = | |
| 1266 | + typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; | |
| 1267 | + if (isStalePendingApprovalFailureDetail(detail)) { | |
| 1268 | + if (Option.isNone(existingRow)) { | |
| 1269 | + return; | |
| 1270 | + } | |
| 1271 | + if (existingRow.value.status === "resolved") { | |
| 1272 | + return; | |
| 1273 | + } | |
| 1274 | + yield* projectionPendingApprovalRepository.upsert({ | |
| 1275 | + requestId, | |
| 1276 | + threadId: existingRow.value.threadId, | |
| 1277 | + turnId: existingRow.value.turnId, | |
| 1278 | + status: "resolved", | |
| 1279 | + decision: null, | |
| 1280 | + createdAt: existingRow.value.createdAt, | |
| 1281 | + resolvedAt: event.payload.activity.createdAt, | |
| 1282 | + }); | |
| 1283 | + return; | |
| 1284 | + } | |
| 1285 | + return; | |
| 1286 | + } | |
| 1287 | + // Only approval-requested activities should create pending-approval | |
| 1288 | + // rows. Other activity kinds that happen to carry a requestId | |
| 1289 | + // (e.g. user-input.requested / user-input.resolved) must not | |
| 1290 | + // pollute this projection — they have their own accounting via | |
| 1291 | + // derivePendingUserInputCountFromActivities. | |
| 1292 | + if (event.payload.activity.kind !== "approval.requested") { | |
| 1293 | + return; | |
| 1294 | + } | |
| 1124 | 1295 | if (Option.isSome(existingRow) && existingRow.value.status === "resolved") { |
| 1125 | 1296 | return; |
| 1126 | 1297 | } |
| @@ -4,18 +4,22 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 5 | 5 | |
| 6 | 6 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 7 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 7 | 8 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 8 | 9 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 9 | 10 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| 10 | 11 | |
| 11 | −const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); | |
| 12 | −const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | |
| 13 | −const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | |
| 14 | −const asEventId = (value: string): EventId => EventId.makeUnsafe(value); | |
| 15 | −const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); | |
| 12 | +const asProjectId = (value: string): ProjectId => ProjectId.make(value); | |
| 13 | +const asTurnId = (value: string): TurnId => TurnId.make(value); | |
| 14 | +const asMessageId = (value: string): MessageId => MessageId.make(value); | |
| 15 | +const asEventId = (value: string): EventId => EventId.make(value); | |
| 16 | +const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); | |
| 16 | 17 | |
| 17 | 18 | const projectionSnapshotLayer = it.layer( |
| 18 | − OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 19 | + OrchestrationProjectionSnapshotQueryLive.pipe( | |
| 20 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 21 | + Layer.provideMerge(SqlitePersistenceMemory), | |
| 22 | + ), | |
| 19 | 23 | ); |
| 20 | 24 | |
| 21 | 25 | projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| @@ -58,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 36 unmodified lines | ||
| 58 | 62 | project_id, |
| 59 | 63 | title, |
| 60 | 64 | model_selection_json, |
| 65 | + runtime_mode, | |
| 66 | + interaction_mode, | |
| 61 | 67 | branch, |
| 62 | 68 | worktree_path, |
| 63 | 69 | latest_turn_id, |
| 70 | + latest_user_message_at, | |
| 71 | + pending_approval_count, | |
| 72 | + pending_user_input_count, | |
| 73 | + has_actionable_proposed_plan, | |
| 64 | 74 | created_at, |
| 65 | 75 | updated_at, |
| 66 | 76 | deleted_at |
| @@ -70,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 3 unmodified lines | ||
| 70 | 80 | 'project-1', |
| 71 | 81 | 'Thread 1', |
| 72 | 82 | '{"provider":"codex","model":"gpt-5-codex"}', |
| 83 | + 'full-access', | |
| 84 | + 'default', | |
| 73 | 85 | NULL, |
| 74 | 86 | NULL, |
| 75 | 87 | 'turn-1', |
| 88 | + '2026-02-24T00:00:04.000Z', | |
| 89 | + 1, | |
| 90 | + 0, | |
| 91 | + 0, | |
| 76 | 92 | '2026-02-24T00:00:02.000Z', |
| 77 | 93 | '2026-02-24T00:00:03.000Z', |
| 78 | 94 | NULL |
| @@ -234,6 +250,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 155 unmodified lines | ||
| 234 | 250 | id: asProjectId("project-1"), |
| 235 | 251 | title: "Project 1", |
| 236 | 252 | workspaceRoot: "/tmp/project-1", |
| 253 | + repositoryIdentity: null, | |
| 237 | 254 | defaultModelSelection: { |
| 238 | 255 | provider: "codex", |
| 239 | 256 | model: "gpt-5-codex", |
| @@ -254,7 +271,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 14 unmodified lines | ||
| 254 | 271 | ]); |
| 255 | 272 | assert.deepEqual(snapshot.threads, [ |
| 256 | 273 | { |
| 257 | − id: ThreadId.makeUnsafe("thread-1"), | |
| 274 | + id: ThreadId.make("thread-1"), | |
| 258 | 275 | projectId: asProjectId("project-1"), |
| 259 | 276 | title: "Thread 1", |
| 260 | 277 | modelSelection: { |
| @@ -273,7 +290,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 12 unmodified lines | ||
| 273 | 290 | completedAt: "2026-02-24T00:00:08.000Z", |
| 274 | 291 | assistantMessageId: asMessageId("message-1"), |
| 275 | 292 | sourceProposedPlan: { |
| 276 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 293 | + threadId: ThreadId.make("thread-1"), | |
| 277 | 294 | planId: "plan-1", |
| 278 | 295 | }, |
| 279 | 296 | }, |
| @@ -298,7 +315,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 18 unmodified lines | ||
| 298 | 315 | turnId: asTurnId("turn-1"), |
| 299 | 316 | planMarkdown: "# Ship it", |
| 300 | 317 | implementedAt: "2026-02-24T00:00:05.500Z", |
| 301 | − implementationThreadId: ThreadId.makeUnsafe("thread-2"), | |
| 318 | + implementationThreadId: ThreadId.make("thread-2"), | |
| 302 | 319 | createdAt: "2026-02-24T00:00:05.000Z", |
| 303 | 320 | updatedAt: "2026-02-24T00:00:05.500Z", |
| 304 | 321 | }, |
| @@ -326,7 +343,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 21 unmodified lines | ||
| 326 | 343 | }, |
| 327 | 344 | ], |
| 328 | 345 | session: { |
| 329 | − threadId: ThreadId.makeUnsafe("thread-1"), | |
| 346 | + threadId: ThreadId.make("thread-1"), | |
| 330 | 347 | status: "running", |
| 331 | 348 | providerName: "codex", |
| 332 | 349 | runtimeMode: "approval-required", |
| @@ -336,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 3 unmodified lines | ||
| 336 | 353 | }, |
| 337 | 354 | }, |
| 338 | 355 | ]); |
| 356 | + | |
| 357 | + const shellSnapshot = yield* snapshotQuery.getShellSnapshot(); | |
| 358 | + assert.equal(shellSnapshot.snapshotSequence, 5); | |
| 359 | + assert.deepEqual(shellSnapshot.projects, [ | |
| 360 | + { | |
| 361 | + id: asProjectId("project-1"), | |
| 362 | + title: "Project 1", | |
| 363 | + workspaceRoot: "/tmp/project-1", | |
| 364 | + repositoryIdentity: null, | |
| 365 | + defaultModelSelection: { | |
| 366 | + provider: "codex", | |
| 367 | + model: "gpt-5-codex", | |
| 368 | + }, | |
| 369 | + scripts: [ | |
| 370 | + { | |
| 371 | + id: "script-1", | |
| 372 | + name: "Build", | |
| 373 | + command: "bun run build", | |
| 374 | + icon: "build", | |
| 375 | + runOnWorktreeCreate: false, | |
| 376 | + }, | |
| 377 | + ], | |
| 378 | + createdAt: "2026-02-24T00:00:00.000Z", | |
| 379 | + updatedAt: "2026-02-24T00:00:01.000Z", | |
| 380 | + }, | |
| 381 | + ]); | |
| 382 | + assert.deepEqual(shellSnapshot.threads, [ | |
| 383 | + { | |
| 384 | + id: ThreadId.make("thread-1"), | |
| 385 | + projectId: asProjectId("project-1"), | |
| 386 | + title: "Thread 1", | |
| 387 | + modelSelection: { | |
| 388 | + provider: "codex", | |
| 389 | + model: "gpt-5-codex", | |
| 390 | + }, | |
| 391 | + interactionMode: "default", | |
| 392 | + runtimeMode: "full-access", | |
| 393 | + branch: null, | |
| 394 | + worktreePath: null, | |
| 395 | + latestTurn: { | |
| 396 | + turnId: asTurnId("turn-1"), | |
| 397 | + state: "completed", | |
| 398 | + requestedAt: "2026-02-24T00:00:08.000Z", | |
| 399 | + startedAt: "2026-02-24T00:00:08.000Z", | |
| 400 | + completedAt: "2026-02-24T00:00:08.000Z", | |
| 401 | + assistantMessageId: asMessageId("message-1"), | |
| 402 | + sourceProposedPlan: { | |
| 403 | + threadId: ThreadId.make("thread-1"), | |
| 404 | + planId: "plan-1", | |
| 405 | + }, | |
| 406 | + }, | |
| 407 | + createdAt: "2026-02-24T00:00:02.000Z", | |
| 408 | + updatedAt: "2026-02-24T00:00:03.000Z", | |
| 409 | + archivedAt: null, | |
| 410 | + session: { | |
| 411 | + threadId: ThreadId.make("thread-1"), | |
| 412 | + status: "running", | |
| 413 | + providerName: "codex", | |
| 414 | + runtimeMode: "approval-required", | |
| 415 | + activeTurnId: asTurnId("turn-1"), | |
| 416 | + lastError: null, | |
| 417 | + updatedAt: "2026-02-24T00:00:07.000Z", | |
| 418 | + }, | |
| 419 | + latestUserMessageAt: "2026-02-24T00:00:04.000Z", | |
| 420 | + hasPendingApprovals: true, | |
| 421 | + hasPendingUserInput: false, | |
| 422 | + hasActionableProposedPlan: false, | |
| 423 | + }, | |
| 424 | + ]); | |
| 425 | + | |
| 426 | + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); | |
| 427 | + assert.equal(threadDetail._tag, "Some"); | |
| 428 | + if (threadDetail._tag === "Some") { | |
| 429 | + assert.deepEqual(threadDetail.value, snapshot.threads[0]); | |
| 430 | + } | |
| 339 | 431 | }), |
| 340 | 432 | ); |
| 341 | 433 | |
| @@ -468,7 +560,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 126 unmodified lines | ||
| 468 | 560 | ); |
| 469 | 561 | assert.equal(firstThreadId._tag, "Some"); |
| 470 | 562 | if (firstThreadId._tag === "Some") { |
| 471 | − assert.equal(firstThreadId.value, ThreadId.makeUnsafe("thread-first")); | |
| 563 | + assert.equal(firstThreadId.value, ThreadId.make("thread-first")); | |
| 472 | 564 | } |
| 473 | 565 | }), |
| 474 | 566 | ); |
| @@ -591,12 +683,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 116 unmodified lines | ||
| 591 | 683 | `; |
| 592 | 684 | |
| 593 | 685 | const context = yield* snapshotQuery.getThreadCheckpointContext( |
| 594 | − ThreadId.makeUnsafe("thread-context"), | |
| 686 | + ThreadId.make("thread-context"), | |
| 595 | 687 | ); |
| 596 | 688 | assert.equal(context._tag, "Some"); |
| 597 | 689 | if (context._tag === "Some") { |
| 598 | 690 | assert.deepEqual(context.value, { |
| 599 | − threadId: ThreadId.makeUnsafe("thread-context"), | |
| 691 | + threadId: ThreadId.make("thread-context"), | |
| 600 | 692 | projectId: asProjectId("project-context"), |
| 601 | 693 | workspaceRoot: "/tmp/context-workspace", |
| 602 | 694 | worktreePath: "/tmp/context-worktree", |
| @@ -624,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 21 unmodified lines | ||
| 624 | 716 | } |
| 625 | 717 | }), |
| 626 | 718 | ); |
| 719 | + | |
| 720 | + it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () => | |
| 721 | + Effect.gen(function* () { | |
| 722 | + const snapshotQuery = yield* ProjectionSnapshotQuery; | |
| 723 | + const sql = yield* SqlClient.SqlClient; | |
| 724 | + | |
| 725 | + yield* sql`DELETE FROM projection_projects`; | |
| 726 | + yield* sql`DELETE FROM projection_threads`; | |
| 727 | + yield* sql`DELETE FROM projection_thread_activities`; | |
| 728 | + yield* sql`DELETE FROM projection_state`; | |
| 729 | + | |
| 730 | + yield* sql` | |
| 731 | + INSERT INTO projection_projects ( | |
| 732 | + project_id, | |
| 733 | + title, | |
| 734 | + workspace_root, | |
| 735 | + default_model_selection_json, | |
| 736 | + scripts_json, | |
| 737 | + created_at, | |
| 738 | + updated_at, | |
| 739 | + deleted_at | |
| 740 | + ) | |
| 741 | + VALUES ( | |
| 742 | + 'project-1', | |
| 743 | + 'Project 1', | |
| 744 | + '/tmp/project-1', | |
| 745 | + '{"provider":"codex","model":"gpt-5-codex"}', | |
| 746 | + '[]', | |
| 747 | + '2026-04-01T00:00:00.000Z', | |
| 748 | + '2026-04-01T00:00:01.000Z', | |
| 749 | + NULL | |
| 750 | + ) | |
| 751 | + `; | |
| 752 | + | |
| 753 | + yield* sql` | |
| 754 | + INSERT INTO projection_threads ( | |
| 755 | + thread_id, | |
| 756 | + project_id, | |
| 757 | + title, | |
| 758 | + model_selection_json, | |
| 759 | + runtime_mode, | |
| 760 | + interaction_mode, | |
| 761 | + branch, | |
| 762 | + worktree_path, | |
| 763 | + latest_turn_id, | |
| 764 | + latest_user_message_at, | |
| 765 | + pending_approval_count, | |
| 766 | + pending_user_input_count, | |
| 767 | + has_actionable_proposed_plan, | |
| 768 | + created_at, | |
| 769 | + updated_at, | |
| 770 | + deleted_at | |
| 771 | + ) | |
| 772 | + VALUES ( | |
| 773 | + 'thread-1', | |
| 774 | + 'project-1', | |
| 775 | + 'Thread 1', | |
| 776 | + '{"provider":"codex","model":"gpt-5-codex"}', | |
| 777 | + 'full-access', | |
| 778 | + 'default', | |
| 779 | + NULL, | |
| 780 | + NULL, | |
| 781 | + NULL, | |
| 782 | + NULL, | |
| 783 | + 0, | |
| 784 | + 0, | |
| 785 | + 0, | |
| 786 | + '2026-04-01T00:00:02.000Z', | |
| 787 | + '2026-04-01T00:00:03.000Z', | |
| 788 | + NULL | |
| 789 | + ) | |
| 790 | + `; | |
| 791 | + | |
| 792 | + yield* sql` | |
| 793 | + INSERT INTO projection_thread_activities ( | |
| 794 | + activity_id, | |
| 795 | + thread_id, | |
| 796 | + turn_id, | |
| 797 | + tone, | |
| 798 | + kind, | |
| 799 | + summary, | |
| 800 | + payload_json, | |
| 801 | + sequence, | |
| 802 | + created_at | |
| 803 | + ) | |
| 804 | + VALUES | |
| 805 | + ( | |
| 806 | + 'activity-unsequenced', | |
| 807 | + 'thread-1', | |
| 808 | + NULL, | |
| 809 | + 'info', | |
| 810 | + 'runtime.note', | |
| 811 | + 'unsequenced first', | |
| 812 | + '{"source":"unsequenced"}', | |
| 813 | + NULL, | |
| 814 | + '2026-04-01T00:00:06.000Z' | |
| 815 | + ), | |
| 816 | + ( | |
| 817 | + 'activity-sequence-2', | |
| 818 | + 'thread-1', | |
| 819 | + NULL, | |
| 820 | + 'info', | |
| 821 | + 'runtime.note', | |
| 822 | + 'sequence two', | |
| 823 | + '{"source":"sequence-2"}', | |
| 824 | + 2, | |
| 825 | + '2026-04-01T00:00:04.000Z' | |
| 826 | + ), | |
| 827 | + ( | |
| 828 | + 'activity-sequence-1', | |
| 829 | + 'thread-1', | |
| 830 | + NULL, | |
| 831 | + 'info', | |
| 832 | + 'runtime.note', | |
| 833 | + 'sequence one', | |
| 834 | + '{"source":"sequence-1"}', | |
| 835 | + 1, | |
| 836 | + '2026-04-01T00:00:05.000Z' | |
| 837 | + ) | |
| 838 | + `; | |
| 839 | + | |
| 840 | + const snapshot = yield* snapshotQuery.getSnapshot(); | |
| 841 | + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); | |
| 842 | + | |
| 843 | + assert.equal(threadDetail._tag, "Some"); | |
| 844 | + if (threadDetail._tag === "Some") { | |
| 845 | + assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []); | |
| 846 | + } | |
| 847 | + | |
| 848 | + assert.deepEqual(snapshot.threads[0]?.activities ?? [], [ | |
| 849 | + { | |
| 850 | + id: asEventId("activity-unsequenced"), | |
| 851 | + tone: "info", | |
| 852 | + kind: "runtime.note", | |
| 853 | + summary: "unsequenced first", | |
| 854 | + payload: { source: "unsequenced" }, | |
| 855 | + turnId: null, | |
| 856 | + createdAt: "2026-04-01T00:00:06.000Z", | |
| 857 | + }, | |
| 858 | + { | |
| 859 | + id: asEventId("activity-sequence-1"), | |
| 860 | + tone: "info", | |
| 861 | + kind: "runtime.note", | |
| 862 | + summary: "sequence one", | |
| 863 | + payload: { source: "sequence-1" }, | |
| 864 | + turnId: null, | |
| 865 | + sequence: 1, | |
| 866 | + createdAt: "2026-04-01T00:00:05.000Z", | |
| 867 | + }, | |
| 868 | + { | |
| 869 | + id: asEventId("activity-sequence-2"), | |
| 870 | + tone: "info", | |
| 871 | + kind: "runtime.note", | |
| 872 | + summary: "sequence two", | |
| 873 | + payload: { source: "sequence-2" }, | |
| 874 | + turnId: null, | |
| 875 | + sequence: 2, | |
| 876 | + createdAt: "2026-04-01T00:00:04.000Z", | |
| 877 | + }, | |
| 878 | + ]); | |
| 879 | + }), | |
| 880 | + ); | |
| 881 | + | |
| 882 | + it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () => | |
| 883 | + Effect.gen(function* () { | |
| 884 | + const snapshotQuery = yield* ProjectionSnapshotQuery; | |
| 885 | + const sql = yield* SqlClient.SqlClient; | |
| 886 | + | |
| 887 | + yield* sql`DELETE FROM projection_projects`; | |
| 888 | + yield* sql`DELETE FROM projection_threads`; | |
| 889 | + yield* sql`DELETE FROM projection_turns`; | |
| 890 | + | |
| 891 | + yield* sql` | |
| 892 | + INSERT INTO projection_projects ( | |
| 893 | + project_id, | |
| 894 | + title, | |
| 895 | + workspace_root, | |
| 896 | + default_model_selection_json, | |
| 897 | + scripts_json, | |
| 898 | + created_at, | |
| 899 | + updated_at, | |
| 900 | + deleted_at | |
| 901 | + ) | |
| 902 | + VALUES ( | |
| 903 | + 'project-1', | |
| 904 | + 'Project 1', | |
| 905 | + '/tmp/project-1', | |
| 906 | + '{"provider":"codex","model":"gpt-5-codex"}', | |
| 907 | + '[]', | |
| 908 | + '2026-04-02T00:00:00.000Z', | |
| 909 | + '2026-04-02T00:00:01.000Z', | |
| 910 | + NULL | |
| 911 | + ) | |
| 912 | + `; | |
| 913 | + | |
| 914 | + yield* sql` | |
| 915 | + INSERT INTO projection_threads ( | |
| 916 | + thread_id, | |
| 917 | + project_id, | |
| 918 | + title, | |
| 919 | + model_selection_json, | |
| 920 | + runtime_mode, | |
| 921 | + interaction_mode, | |
| 922 | + branch, | |
| 923 | + worktree_path, | |
| 924 | + latest_turn_id, | |
| 925 | + latest_user_message_at, | |
| 926 | + pending_approval_count, | |
| 927 | + pending_user_input_count, | |
| 928 | + has_actionable_proposed_plan, | |
| 929 | + created_at, | |
| 930 | + updated_at, | |
| 931 | + archived_at, | |
| 932 | + deleted_at | |
| 933 | + ) | |
| 934 | + VALUES ( | |
| 935 | + 'thread-1', | |
| 936 | + 'project-1', | |
| 937 | + 'Thread 1', | |
| 938 | + '{"provider":"codex","model":"gpt-5-codex"}', | |
| 939 | + 'full-access', | |
| 940 | + 'default', | |
| 941 | + NULL, | |
| 942 | + NULL, | |
| 943 | + 'turn-running', | |
| 944 | + '2026-04-02T00:00:04.000Z', | |
| 945 | + 0, | |
| 946 | + 0, | |
| 947 | + 0, | |
| 948 | + '2026-04-02T00:00:02.000Z', | |
| 949 | + '2026-04-02T00:00:03.000Z', | |
| 950 | + NULL, | |
| 951 | + NULL | |
| 952 | + ) | |
| 953 | + `; | |
| 954 | + | |
| 955 | + yield* sql` | |
| 956 | + INSERT INTO projection_turns ( | |
| 957 | + thread_id, | |
| 958 | + turn_id, | |
| 959 | + pending_message_id, | |
| 960 | + source_proposed_plan_thread_id, | |
| 961 | + source_proposed_plan_id, | |
| 962 | + assistant_message_id, | |
| 963 | + state, | |
| 964 | + requested_at, | |
| 965 | + started_at, | |
| 966 | + completed_at, | |
| 967 | + checkpoint_turn_count, | |
| 968 | + checkpoint_ref, | |
| 969 | + checkpoint_status, | |
| 970 | + checkpoint_files_json | |
| 971 | + ) | |
| 972 | + VALUES | |
| 973 | + ( | |
| 974 | + 'thread-1', | |
| 975 | + 'turn-completed', | |
| 976 | + 'message-user-1', | |
| 977 | + NULL, | |
| 978 | + NULL, | |
| 979 | + 'message-assistant-1', | |
| 980 | + 'completed', | |
| 981 | + '2026-04-02T00:00:05.000Z', | |
| 982 | + '2026-04-02T00:00:06.000Z', | |
| 983 | + '2026-04-02T00:00:20.000Z', | |
| 984 | + 5, | |
| 985 | + 'checkpoint-5', | |
| 986 | + 'ready', | |
| 987 | + '[]' | |
| 988 | + ), | |
| 989 | + ( | |
| 990 | + 'thread-1', | |
| 991 | + 'turn-running', | |
| 992 | + 'message-user-2', | |
| 993 | + NULL, | |
| 994 | + NULL, | |
| 995 | + NULL, | |
| 996 | + 'running', | |
| 997 | + '2026-04-02T00:00:30.000Z', | |
| 998 | + '2026-04-02T00:00:30.000Z', | |
| 999 | + NULL, | |
| 1000 | + NULL, | |
| 1001 | + NULL, | |
| 1002 | + NULL, | |
| 1003 | + '[]' | |
| 1004 | + ) | |
| 1005 | + `; | |
| 1006 | + | |
| 1007 | + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); | |
| 1008 | + assert.equal(threadShell._tag, "Some"); | |
| 1009 | + if (threadShell._tag === "Some") { | |
| 1010 | + assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running")); | |
| 1011 | + assert.equal(threadShell.value.latestTurn?.state, "running"); | |
| 1012 | + assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); | |
| 1013 | + } | |
| 1014 | + | |
| 1015 | + const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1")); | |
| 1016 | + assert.equal(threadDetail._tag, "Some"); | |
| 1017 | + if (threadDetail._tag === "Some") { | |
| 1018 | + assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running")); | |
| 1019 | + assert.equal(threadDetail.value.latestTurn?.state, "running"); | |
| 1020 | + assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z"); | |
| 1021 | + } | |
| 1022 | + }), | |
| 1023 | + ); | |
| 627 | 1024 | }); |
| @@ -6,16 +6,19 @@ import { · 5 unmodified lines | ||
| 6 | 6 | OrchestrationCheckpointFile, |
| 7 | 7 | OrchestrationProposedPlanId, |
| 8 | 8 | OrchestrationReadModel, |
| 9 | + OrchestrationShellSnapshot, | |
| 10 | + OrchestrationThread, | |
| 9 | 11 | ProjectScript, |
| 10 | 12 | TurnId, |
| 11 | 13 | type OrchestrationCheckpointSummary, |
| 12 | 14 | type OrchestrationLatestTurn, |
| 13 | 15 | type OrchestrationMessage, |
| 16 | + type OrchestrationProjectShell, | |
| 14 | 17 | type OrchestrationProposedPlan, |
| 15 | 18 | type OrchestrationProject, |
| 16 | 19 | type OrchestrationSession, |
| 17 | − type OrchestrationThread, | |
| 18 | 20 | type OrchestrationThreadActivity, |
| 21 | + type OrchestrationThreadShell, | |
| 19 | 22 | ModelSelection, |
| 20 | 23 | ProjectId, |
| 21 | 24 | ThreadId, |
| @@ -38,6 +41,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 16 unmodified lines | ||
| 38 | 41 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 39 | 42 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 40 | 43 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 44 | +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; | |
| 41 | 45 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 42 | 46 | import { |
| 43 | 47 | ProjectionSnapshotQuery, |
1208 more lines aren’t shown here.
This diff is large: its 984 lines aren’t shown here.
This diff is large: its 86 lines aren’t shown here.
This diff is large: its 320 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 48 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 77 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 199 lines aren’t shown here.
This diff is large: its 94 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 182 lines aren’t shown here.
This diff is large: its 94 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 210 lines aren’t shown here.
This diff is large: its 280 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 49 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 55 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 43 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 219 lines aren’t shown here.
This diff is large: its 278 lines aren’t shown here.
This diff is large: its 197 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 70 lines aren’t shown here.
This diff is large: its 77 lines aren’t shown here.
This diff is large: its 94 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 39 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 20 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 224 lines aren’t shown here.
This diff is large: its 152 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 631 lines aren’t shown here.
This diff is large: its 686 lines aren’t shown here.
This diff is large: its 466 lines aren’t shown here.
This diff is large: its 90 lines aren’t shown here.
This diff is large: its 140 lines aren’t shown here.
This diff is large: its 260 lines aren’t shown here.
This diff is large: its 73 lines aren’t shown here.
This diff is large: its 126 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 347 lines aren’t shown here.
This diff is large: its 216 lines aren’t shown here.
This diff is large: its 88 lines aren’t shown here.
This diff is large: its 86 lines aren’t shown here.
This diff is large: its 121 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 523 lines aren’t shown here.
This diff is large: its 134 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 124 lines aren’t shown here.
This diff is large: its 176 lines aren’t shown here.
This diff is large: its 139 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 106 lines aren’t shown here.
This diff is large: its 4066 lines aren’t shown here.
This diff is large: its 338 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 164 lines aren’t shown here.
This diff is large: its 320 lines aren’t shown here.
This diff is large: its 78 lines aren’t shown here.
This diff is large: its 94 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 149 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 184 lines aren’t shown here.
This diff is large: its 299 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 19 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 353 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 57 lines aren’t shown here.
This diff is large: its 1751 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 101 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 496 lines aren’t shown here.
This diff is large: its 38 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 81 lines aren’t shown here.
This diff is large: its 195 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 115 lines aren’t shown here.
This diff is large: its 77 lines aren’t shown here.
This diff is large: its 255 lines aren’t shown here.
This diff is large: its 591 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 142 lines aren’t shown here.
This diff is large: its 369 lines aren’t shown here.
This diff is large: its 5020 lines aren’t shown here.
This diff is large: its 674 lines aren’t shown here.
This diff is large: its 172 lines aren’t shown here.
This diff is large: its 4440 lines aren’t shown here.
This diff is large: its 167 lines aren’t shown here.
This diff is large: its 360 lines aren’t shown here.
This diff is large: its 1116 lines aren’t shown here.
This diff is large: its 117 lines aren’t shown here.
This diff is large: its 937 lines aren’t shown here.
This diff is large: its 137 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 469 lines aren’t shown here.
This diff is large: its 16 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 305 lines aren’t shown here.
This diff is large: its 153 lines aren’t shown here.
This diff is large: its 333 lines aren’t shown here.
This diff is large: its 46 lines aren’t shown here.
This diff is large: its 87 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 654 lines aren’t shown here.
This diff is large: its 152 lines aren’t shown here.
This diff is large: its 4700 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 242 lines aren’t shown here.
This diff is large: its 319 lines aren’t shown here.
This diff is large: its 322 lines aren’t shown here.
This diff is large: its 114 lines aren’t shown here.
This diff is large: its 391 lines aren’t shown here.
This diff is large: its 196 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 2000 lines aren’t shown here.
This diff is large: its 50 lines aren’t shown here.
This diff is large: its 111 lines aren’t shown here.
This diff is large: its 39 lines aren’t shown here.
This diff is large: its 251 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 155 lines aren’t shown here.
This diff is large: its 94 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 121 lines aren’t shown here.
This diff is large: its 95 lines aren’t shown here.
This diff is large: its 161 lines aren’t shown here.
This diff is large: its 301 lines aren’t shown here.
This diff is large: its 221 lines aren’t shown here.
This diff is large: its 202 lines aren’t shown here.
This diff is large: its 1230 lines aren’t shown here.
This diff is large: its 1038 lines aren’t shown here.
This diff is large: its 63 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 164 lines aren’t shown here.
This diff is large: its 48 lines aren’t shown here.
This diff is large: its 52 lines aren’t shown here.
This diff is large: its 21 lines aren’t shown here.
This diff is large: its 44 lines aren’t shown here.
This diff is large: its 227 lines aren’t shown here.
This diff is large: its 69 lines aren’t shown here.
This diff is large: its 84 lines aren’t shown here.
This diff is large: its 110 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 1433 lines aren’t shown here.
This diff is large: its 686 lines aren’t shown here.
This diff is large: its 532 lines aren’t shown here.
This diff is large: its 47 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 139 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 29 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 82 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 167 lines aren’t shown here.
This diff is large: its 107 lines aren’t shown here.
This diff is large: its 267 lines aren’t shown here.
This diff is large: its 81 lines aren’t shown here.
This diff is large: its 121 lines aren’t shown here.
This diff is large: its 1103 lines aren’t shown here.
This diff is large: its 3100 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 222 lines aren’t shown here.
This diff is large: its 188 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 87 lines aren’t shown here.
This diff is large: its 615 lines aren’t shown here.
This diff is large: its 374 lines aren’t shown here.
This diff is large: its 171 lines aren’t shown here.
This diff is large: its 121 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 159 lines aren’t shown here.
This diff is large: its 236 lines aren’t shown here.
This diff is large: its 144 lines aren’t shown here.
This diff is large: its 87 lines aren’t shown here.
This diff is large: its 142 lines aren’t shown here.
This diff is large: its 342 lines aren’t shown here.
This diff is large: its 250 lines aren’t shown here.
This diff is large: its 174 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 106 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 299 lines aren’t shown here.
This diff is large: its 1082 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 239 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 318 lines aren’t shown here.
This diff is large: its 148 lines aren’t shown here.
This diff is large: its 323 lines aren’t shown here.
This diff is large: its 145 lines aren’t shown here.
This diff is large: its 90 lines aren’t shown here.
This diff is large: its 37 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 168 lines aren’t shown here.
This diff is large: its 373 lines aren’t shown here.
This diff is large: its 326 lines aren’t shown here.
This diff is large: its 288 lines aren’t shown here.
This diff is large: its 118 lines aren’t shown here.
This diff is large: its 260 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 76 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 188 lines aren’t shown here.
This diff is large: its 74 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 639 lines aren’t shown here.
This diff is large: its 155 lines aren’t shown here.
This diff is large: its 158 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 56 lines aren’t shown here.
This diff is large: its 99 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 145 lines aren’t shown here.
This diff is large: its 101 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 10 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 277 lines aren’t shown here.
This diff is large: its 152 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 53 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 106 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 3 lines aren’t shown here.
This diff is large: its 224 lines aren’t shown here.
This diff is large: its 565 lines aren’t shown here.
This diff is large: its 234 lines aren’t shown here.
This diff is large: its 87 lines aren’t shown here.
This diff is large: its 36 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 41 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 232 lines aren’t shown here.
This diff is large: its 34 lines aren’t shown here.
This diff is large: its 191 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 134 lines aren’t shown here.
This diff is large: its 77 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 24 lines aren’t shown here.
This diff is large: its 76 lines aren’t shown here.
This diff is large: its 200 lines aren’t shown here.
This diff is large: its 105 lines aren’t shown here.
This diff is large: its 230 lines aren’t shown here.
This diff is large: its 1106 lines aren’t shown here.
This diff is large: its 288 lines aren’t shown here.
This diff is large: its 564 lines aren’t shown here.
This diff is large: its 349 lines aren’t shown here.
This diff is large: its 119 lines aren’t shown here.
This diff is large: its 1300 lines aren’t shown here.
This diff is large: its 1931 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 123 lines aren’t shown here.
This diff is large: its 134 lines aren’t shown here.
This diff is large: its 399 lines aren’t shown here.
This diff is large: its 511 lines aren’t shown here.
This diff is large: its 153 lines aren’t shown here.
This diff is large: its 68 lines aren’t shown here.
This diff is large: its 60 lines aren’t shown here.
This diff is large: its 280 lines aren’t shown here.
This diff is large: its 173 lines aren’t shown here.
This diff is large: its 97 lines aren’t shown here.
This diff is large: its 101 lines aren’t shown here.
This diff is large: its 61 lines aren’t shown here.
This diff is large: its 345 lines aren’t shown here.
This diff is large: its 450 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 80 lines aren’t shown here.
This diff is large: its 359 lines aren’t shown here.
This diff is large: its 108 lines aren’t shown here.
This diff is large: its 444 lines aren’t shown here.
This diff is large: its 132 lines aren’t shown here.
This diff is large: its 42 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 11 lines aren’t shown here.
This diff is large: its 85 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 4 lines aren’t shown here.
Binary file not shown.
Binary file not shown.
This diff is large: its 48 lines aren’t shown here.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
This diff is large: its 698 lines aren’t shown here.
This diff is large: its 30 lines aren’t shown here.
This diff is large: its 85 lines aren’t shown here.
This diff is large: its 66 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 3 lines aren’t shown here.
This diff is large: its 93 lines aren’t shown here.
This diff is large: its 54 lines aren’t shown here.
This diff is large: its 65 lines aren’t shown here.
This diff is large: its 5 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 267 lines aren’t shown here.
This diff is large: its 28 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 79 lines aren’t shown here.
This diff is large: its 31 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 103 lines aren’t shown here.
This diff is large: its 32 lines aren’t shown here.
This diff is large: its 286 lines aren’t shown here.
This diff is large: its 22 lines aren’t shown here.
This diff is large: its 15 lines aren’t shown here.
This diff is large: its 58 lines aren’t shown here.
This diff is large: its 40 lines aren’t shown here.
This diff is large: its 321 lines aren’t shown here.
This diff is large: its 7 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 25 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 26 lines aren’t shown here.
This diff is large: its 232 lines aren’t shown here.
This diff is large: its 27 lines aren’t shown here.
This diff is large: its 89 lines aren’t shown here.
This diff is large: its 143 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 9 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 8 lines aren’t shown here.
This diff is large: its 135 lines aren’t shown here.
This diff is large: its 77 lines aren’t shown here.
This diff is large: its 136 lines aren’t shown here.
This diff is large: its 218 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 18 lines aren’t shown here.
This diff is large: its 35 lines aren’t shown here.
This diff is large: its 23 lines aren’t shown here.
This diff is large: its 986 lines aren’t shown here.
This diff is large: its 96 lines aren’t shown here.
This diff is large: its 193 lines aren’t shown here.
This diff is large: its 75 lines aren’t shown here.
This diff is large: its 45 lines aren’t shown here.
This diff is large: its 352 lines aren’t shown here.
This diff is large: its 450 lines aren’t shown here.
This diff is large: its 102 lines aren’t shown here.
This diff is large: its 619 lines aren’t shown here.
This diff is large: its 256 lines aren’t shown here.
This diff is large: its 225 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 62 lines aren’t shown here.
This diff is large: its 51 lines aren’t shown here.
This diff is large: its 14 lines aren’t shown here.
This diff is large: its 296 lines aren’t shown here.
This diff is large: its 109 lines aren’t shown here.
This diff is large: its 307 lines aren’t shown here.
This diff is large: its 109 lines aren’t shown here.
This diff is large: its 105 lines aren’t shown here.
This diff is large: its 192 lines aren’t shown here.
This diff is large: its 299 lines aren’t shown here.
This diff is large: its 33 lines aren’t shown here.
This diff is large: its 138 lines aren’t shown here.
This diff is large: its 206 lines aren’t shown here.
This diff is large: its 13 lines aren’t shown here.
This diff is large: its 214 lines aren’t shown here.
This diff is large: its 179 lines aren’t shown here.
This diff is large: its 17 lines aren’t shown here.
This diff is large: its 12 lines aren’t shown here.